diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 88e0bc11..cc5c1324 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -45,6 +45,11 @@ Added * 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). +* 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 0029). Notes ===== diff --git a/Makefile b/Makefile index 58a20476..d5789ce6 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 0029) + 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 diff --git a/docs/decisions/0028-api-contract-for-role-catalog.rst b/docs/decisions/0028-api-contract-for-role-catalog.rst index eae6e97d..39636e02 100644 --- a/docs/decisions/0028-api-contract-for-role-catalog.rst +++ b/docs/decisions/0028-api-contract-for-role-catalog.rst @@ -215,7 +215,7 @@ Localization the language of the request, following `ADR 0020`_ and Django's normal fallback rules. Identifiers (``role``, ``id``) and ``icon`` names are never translated. -Icons are Paragon icon names (validated by the schema, see `ADR 0026`_). The client maps +Icons are Paragon icon names (validated by the schema, see `ADR 0029`_). The client maps the name to its component; the API only returns the name, or ``null``. Pagination @@ -398,7 +398,7 @@ References * `ADR 0023`_ * `ADR 0024`_ * `ADR 0025`_ -* `ADR 0026`_ +* `ADR 0029`_ .. _frontend-app-admin-console: https://github.com/openedx/frontend-app-admin-console .. _ADR 0020: 0020-authorization-schema-internationalization.rst @@ -406,4 +406,4 @@ References .. _ADR 0023: 0023-extend-static-roles.rst .. _ADR 0024: 0024-api-contract-for-user-grouped-role-assignments.rst .. _ADR 0025: 0025-authorization-schema-source-tracking.rst -.. _ADR 0026: 0026-paragon-icon-list-maintenance.rst +.. _ADR 0029: 0029-paragon-icon-list-maintenance.rst diff --git a/docs/decisions/0029-paragon-icon-list-maintenance.rst b/docs/decisions/0029-paragon-icon-list-maintenance.rst new file mode 100644 index 00000000..85fb4a25 --- /dev/null +++ b/docs/decisions/0029-paragon-icon-list-maintenance.rst @@ -0,0 +1,88 @@ +0029: 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 } from "./";``, 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 diff --git a/scripts/generate_paragon_icons.py b/scripts/generate_paragon_icons.py new file mode 100644 index 00000000..7456a0ae --- /dev/null +++ b/scripts/generate_paragon_icons.py @@ -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 } from "./";``. +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()) diff --git a/src/openedx_authz/engine/schema/paragon_icons.py b/src/openedx_authz/engine/schema/paragon_icons.py new file mode 100644 index 00000000..5953fd4b --- /dev/null +++ b/src/openedx_authz/engine/schema/paragon_icons.py @@ -0,0 +1,2340 @@ +"""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: https://unpkg.com/@openedx/paragon@23.21.3/icons/es5/index.js +Paragon version: 23.21.3 +Icon count: 2317 +""" + +from __future__ import annotations + +PARAGON_VERSION = "23.21.3" + +PARAGON_ICON_NAMES: frozenset[str] = frozenset( + { + "Abc", + "AcUnit", + "AccessAlarm", + "AccessAlarms", + "AccessTime", + "AccessTimeFilled", + "Accessibility", + "AccessibilityNew", + "Accessible", + "AccessibleForward", + "AccountBalance", + "AccountBalanceWallet", + "AccountBox", + "AccountCircle", + "AccountTree", + "AdUnits", + "Adb", + "Add", + "AddAPhoto", + "AddAlarm", + "AddAlert", + "AddBox", + "AddBusiness", + "AddCard", + "AddCircle", + "AddCircleOutline", + "AddComment", + "AddHome", + "AddHomeWork", + "AddIcCall", + "AddLink", + "AddLocation", + "AddLocationAlt", + "AddModerator", + "AddPhotoAlternate", + "AddReaction", + "AddRoad", + "AddShoppingCart", + "AddTask", + "AddToDrive", + "AddToHomeScreen", + "AddToPhotos", + "AddToQueue", + "Addchart", + "AdfScanner", + "Adjust", + "AdminPanelSettings", + "Adobe", + "AdsClick", + "Agriculture", + "Air", + "AirlineSeatFlat", + "AirlineSeatFlatAngled", + "AirlineSeatIndividualSuite", + "AirlineSeatLegroomExtra", + "AirlineSeatLegroomNormal", + "AirlineSeatLegroomReduced", + "AirlineSeatReclineExtra", + "AirlineSeatReclineNormal", + "AirlineStops", + "Airlines", + "AirplaneTicket", + "AirplanemodeActive", + "AirplanemodeInactive", + "Airplay", + "AirportShuttle", + "Alarm", + "AlarmAdd", + "AlarmOff", + "AlarmOn", + "Album", + "AlignHorizontalCenter", + "AlignHorizontalLeft", + "AlignHorizontalRight", + "AlignVerticalBottom", + "AlignVerticalCenter", + "AlignVerticalTop", + "AllInbox", + "AllInclusive", + "AllOut", + "AltRoute", + "AlternateEmail", + "AmpStories", + "Analytics", + "Anchor", + "Android", + "Animation", + "Announcement", + "Aod", + "Apartment", + "Api", + "AppBlocking", + "AppRegistration", + "AppSettingsAlt", + "AppShortcut", + "Apple", + "Approval", + "Apps", + "AppsOutage", + "Architecture", + "Archive", + "AreaChart", + "ArrowBack", + "ArrowBackIos", + "ArrowBackIosNew", + "ArrowCircleDown", + "ArrowCircleLeft", + "ArrowCircleRight", + "ArrowCircleUp", + "ArrowDownward", + "ArrowDropDown", + "ArrowDropDownCircle", + "ArrowDropUp", + "ArrowDropUpDown", + "ArrowForward", + "ArrowForwardIos", + "ArrowLeft", + "ArrowOutward", + "ArrowRight", + "ArrowRightAlt", + "ArrowUpward", + "ArtTrack", + "Article", + "AspectRatio", + "Assessment", + "Assignment", + "AssignmentInd", + "AssignmentLate", + "AssignmentReturn", + "AssignmentReturned", + "AssignmentTurnedIn", + "AssistWalker", + "Assistant", + "AssistantDirection", + "AssistantPhoto", + "AssuredWorkload", + "Atm", + "AttachEmail", + "AttachFile", + "AttachMoney", + "Attachment", + "Attractions", + "Attribution", + "AudioFile", + "Audiotrack", + "AutoAwesome", + "AutoAwesomeMosaic", + "AutoAwesomeMotion", + "AutoDelete", + "AutoFixHigh", + "AutoFixNormal", + "AutoFixOff", + "AutoGraph", + "AutoMode", + "AutoStories", + "AutofpsSelect", + "Autorenew", + "AvTimer", + "Award", + "BabyChangingStation", + "BackHand", + "Backpack", + "Backspace", + "Backup", + "BackupTable", + "BadgeIcon", + "BakeryDining", + "Balance", + "Balcony", + "Ballot", + "BarChart", + "Barcode", + "BatchPrediction", + "Bathroom", + "Bathtub", + "Battery0Bar", + "Battery1Bar", + "Battery20", + "Battery2Bar", + "Battery30", + "Battery3Bar", + "Battery4Bar", + "Battery50", + "Battery5Bar", + "Battery60", + "Battery6Bar", + "Battery80", + "Battery90", + "BatteryAlert", + "BatteryCharging20", + "BatteryCharging30", + "BatteryCharging50", + "BatteryCharging60", + "BatteryCharging80", + "BatteryCharging90", + "BatteryChargingFull", + "BatteryFull", + "BatterySaver", + "BatteryStd", + "BatteryUnknown", + "Bbb", + "BeachAccess", + "Bed", + "BedroomBaby", + "BedroomChild", + "BedroomParent", + "Bedtime", + "BedtimeOff", + "Beenhere", + "Bento", + "BikeScooter", + "Biotech", + "Blender", + "Blind", + "Blinds", + "BlindsClosed", + "Block", + "Blocked", + "Bloodtype", + "Bluetooth", + "BluetoothAudio", + "BluetoothConnected", + "BluetoothDisabled", + "BluetoothDrive", + "BluetoothSearching", + "BlurCircular", + "BlurLinear", + "BlurOff", + "BlurOn", + "Bolt", + "Book", + "BookOnline", + "BookOpen", + "Bookmark", + "BookmarkAdd", + "BookmarkAdded", + "BookmarkBorder", + "BookmarkRemove", + "Bookmarks", + "BorderAll", + "BorderBottom", + "BorderClear", + "BorderColor", + "BorderHorizontal", + "BorderInner", + "BorderLeft", + "BorderOuter", + "BorderRight", + "BorderStyle", + "BorderTop", + "BorderVertical", + "Boy", + "BrandingWatermark", + "BreakfastDining", + "Brightness1", + "Brightness2", + "Brightness3", + "Brightness4", + "Brightness5", + "Brightness6", + "Brightness7", + "BrightnessAuto", + "BrightnessHigh", + "BrightnessLow", + "BrightnessMedium", + "BroadcastOnHome", + "BroadcastOnPersonal", + "BrokenImage", + "BrowseGallery", + "BrowserNotSupported", + "BrowserUpdated", + "BrunchDining", + "Brush", + "BsAlexa", + "BsAlipay", + "BsAmd", + "BsAndroid", + "BsAndroid2", + "BsApple", + "BsBehance", + "BsBluetooth", + "BsBrowserChrome", + "BsBrowserEdge", + "BsBrowserFirefox", + "BsBrowserSafari", + "BsDiscord", + "BsDribbble", + "BsDropbox", + "BsFacebook", + "BsGit", + "BsGithub", + "BsGoogle", + "BsGooglePlay", + "BsInstagram", + "BsLine", + "BsLinkedin", + "BsMastodon", + "BsMedium", + "BsMessenger", + "BsMeta", + "BsMicrosoft", + "BsMicrosoftTeams", + "BsNintendoSwitch", + "BsNvidia", + "BsPaypal", + "BsPinterest", + "BsPlaystation", + "BsQuora", + "BsReddit", + "BsSignal", + "BsSinaWeibo", + "BsSkype", + "BsSlack", + "BsSnapchat", + "BsSpotify", + "BsStackOverflow", + "BsSteam", + "BsStrava", + "BsStripe", + "BsTelegram", + "BsTencentQq", + "BsTiktok", + "BsTrello", + "BsTwitch", + "BsTwitter", + "BsTwitterX", + "BsUbuntu", + "BsUnity", + "BsVimeo", + "BsWechat", + "BsWhatsapp", + "BsWikipedia", + "BsWindows", + "BsWordpress", + "BsXbox", + "BsYelp", + "BsYoutube", + "BubbleChart", + "BugReport", + "Build", + "BuildCircle", + "Bungalow", + "BurstMode", + "BusAlert", + "Business", + "BusinessCenter", + "Cabin", + "Cable", + "Cached", + "Cake", + "Calculate", + "Calendar", + "CalendarMonth", + "CalendarToday", + "CalendarViewDay", + "CalendarViewMonth", + "CalendarViewWeek", + "Call", + "CallEnd", + "CallMade", + "CallMerge", + "CallMissed", + "CallMissedOutgoing", + "CallReceived", + "CallSplit", + "CallToAction", + "Camera", + "CameraAlt", + "CameraEnhance", + "CameraFront", + "CameraIndoor", + "CameraOutdoor", + "CameraRear", + "CameraRoll", + "Cameraswitch", + "Campaign", + "Cancel", + "CancelPresentation", + "CancelScheduleSend", + "CandlestickChart", + "CarCrash", + "CarRental", + "CarRepair", + "CardGiftcard", + "CardMembership", + "CardTravel", + "Carpenter", + "Cases", + "Casino", + "Cast", + "CastConnected", + "CastForEducation", + "Castle", + "CatchingPokemon", + "Category", + "Cc", + "Celebration", + "CellTower", + "CellWifi", + "CenterFocusStrong", + "CenterFocusWeak", + "Chair", + "ChairAlt", + "Chalet", + "ChangeCircle", + "ChangeHistory", + "ChargingStation", + "Chat", + "ChatBubble", + "ChatBubbleOutline", + "Check", + "CheckBoxIcon", + "CheckBoxOutlineBlank", + "CheckCircle", + "CheckCircleLightOutline", + "CheckCircleOutline", + "Checklist", + "ChecklistRtl", + "Checkroom", + "ChevronLeft", + "ChevronRight", + "ChildCare", + "ChildFriendly", + "ChromeReaderMode", + "Church", + "Circle", + "CircleNotifications", + "Class", + "CleanHands", + "CleaningServices", + "Clear", + "ClearAll", + "Close", + "CloseFullscreen", + "CloseSmall", + "ClosedCaption", + "ClosedCaptionDisabled", + "ClosedCaptionOff", + "Cloud", + "CloudCircle", + "CloudDone", + "CloudDownload", + "CloudOff", + "CloudQueue", + "CloudSync", + "CloudUpload", + "Co2", + "CoPresent", + "Code", + "CodeOff", + "Coffee", + "CoffeeMaker", + "Collections", + "CollectionsBookmark", + "ColorLens", + "Colorize", + "Comment", + "CommentBank", + "CommentsDisabled", + "Commit", + "Commute", + "Compare", + "CompareArrows", + "Compass", + "CompassCalibration", + "Compost", + "Compress", + "Computer", + "ConfirmationNumber", + "ConnectWithoutContact", + "ConnectedTv", + "ConnectingAirports", + "Construction", + "ContactEmergency", + "ContactMail", + "ContactPage", + "ContactPhone", + "ContactSupport", + "Contactless", + "Contacts", + "ContentCopy", + "ContentCut", + "ContentPaste", + "ContentPasteGo", + "ContentPasteOff", + "ContentPasteSearch", + "Contrast", + "ControlCamera", + "ControlPoint", + "ControlPointDuplicate", + "Cookie", + "CopyAll", + "Copyright", + "Coronavirus", + "CorporateFare", + "Cottage", + "Countertops", + "Create", + "CreateNewFolder", + "CreditCard", + "CreditCardOff", + "CreditScore", + "Crib", + "CrisisAlert", + "Crop", + "Crop169", + "Crop32", + "Crop54", + "Crop75", + "CropDin", + "CropFree", + "CropLandscape", + "CropOriginal", + "CropPortrait", + "CropRotate", + "CropSquare", + "CrueltyFree", + "Css", + "CurrencyBitcoin", + "CurrencyExchange", + "CurrencyFranc", + "CurrencyLira", + "CurrencyPound", + "CurrencyRuble", + "CurrencyRupee", + "CurrencyYen", + "CurrencyYuan", + "Curtains", + "CurtainsClosed", + "Cyclone", + "Dangerous", + "DarkMode", + "Dashboard", + "DashboardCustomize", + "DataArray", + "DataExploration", + "DataObject", + "DataSaverOff", + "DataSaverOn", + "DataThresholding", + "DataUsage", + "Dataset", + "DatasetLinked", + "DateRange", + "Deblur", + "Deck", + "Dehaze", + "Delete", + "DeleteForever", + "DeleteOutline", + "DeleteSweep", + "DeliveryDining", + "DensityLarge", + "DensityMedium", + "DensitySmall", + "DepartureBoard", + "Description", + "Deselect", + "DesignServices", + "Desk", + "DesktopAccessDisabled", + "DesktopMac", + "DesktopWindows", + "Details", + "DeveloperBoard", + "DeveloperBoardOff", + "DeveloperMode", + "DeviceHub", + "DeviceThermostat", + "DeviceUnknown", + "Devices", + "DevicesFold", + "DevicesOther", + "DialerSip", + "Dialpad", + "Diamond", + "Difference", + "Dining", + "DinnerDining", + "Directions", + "DirectionsBike", + "DirectionsBoat", + "DirectionsBoatFilled", + "DirectionsBus", + "DirectionsBusFilled", + "DirectionsCar", + "DirectionsCarFilled", + "DirectionsOff", + "DirectionsRailway", + "DirectionsRailwayFilled", + "DirectionsRun", + "DirectionsSubway", + "DirectionsSubwayFilled", + "DirectionsTransit", + "DirectionsTransitFilled", + "DirectionsWalk", + "DirtyLens", + "DisabledByDefault", + "DisabledVisible", + "DiscFull", + "Discord", + "Discount", + "DisplaySettings", + "Diversity1", + "Diversity2", + "Diversity3", + "Divide", + "DjangoShort", + "Dns", + "DoDisturb", + "DoDisturbAlt", + "DoDisturbOff", + "DoDisturbOn", + "DoNotDisturb", + "DoNotDisturbAlt", + "DoNotDisturbOff", + "DoNotDisturbOn", + "DoNotDisturbOnTotalSilence", + "DoNotStep", + "DoNotTouch", + "Dock", + "DocumentScanner", + "Domain", + "DomainAdd", + "DomainDisabled", + "DomainVerification", + "Done", + "DoneAll", + "DoneOutline", + "DonutLarge", + "DonutSmall", + "DoorBack", + "DoorFront", + "DoorSliding", + "Doorbell", + "DoubleArrow", + "DownhillSkiing", + "Download", + "DownloadDone", + "DownloadForOffline", + "Downloading", + "Drafts", + "DragHandle", + "DragIndicator", + "Draw", + "DrawShapes", + "DriveEta", + "DriveFileMove", + "DriveFileMoveRtl", + "DriveFileRenameOutline", + "DriveFolderUpload", + "Dry", + "DryCleaning", + "Duo", + "Dvr", + "DynamicFeed", + "DynamicForm", + "EMobiledata", + "Earbuds", + "EarbudsBattery", + "East", + "Eco", + "EdgesensorHigh", + "EdgesensorLow", + "Edit", + "EditAttributes", + "EditCalendar", + "EditCircle", + "EditLocation", + "EditLocationAlt", + "EditNote", + "EditNotifications", + "EditOff", + "EditOutline", + "EditRoad", + "Egg", + "EggAlt", + "Eject", + "Elderly", + "ElderlyWoman", + "ElectricBike", + "ElectricBolt", + "ElectricCar", + "ElectricMeter", + "ElectricMoped", + "ElectricRickshaw", + "ElectricScooter", + "ElectricalServices", + "Elevator", + "Email", + "Emergency", + "EmergencyRecording", + "EmergencyShare", + "EmojiEmotions", + "EmojiEvents", + "EmojiFlags", + "EmojiFoodBeverage", + "EmojiNature", + "EmojiObjects", + "EmojiPeople", + "EmojiSymbols", + "EmojiTransportation", + "EnergySavingsLeaf", + "Engineering", + "EnhancedEncryption", + "Equalizer", + "Equals", + "Error", + "ErrorOutline", + "Escalator", + "EscalatorWarning", + "Euro", + "EuroSymbol", + "EvStation", + "Event", + "EventAvailable", + "EventBusy", + "EventNote", + "EventRepeat", + "EventSeat", + "ExitToApp", + "Expand", + "ExpandCircleDown", + "ExpandLess", + "ExpandMore", + "Explicit", + "Explore", + "ExploreOff", + "Exposure", + "ExposureNeg1", + "ExposureNeg2", + "ExposurePlus1", + "ExposurePlus2", + "ExposureZero", + "Extension", + "ExtensionOff", + "Face", + "Face2", + "Face3", + "Face4", + "Face5", + "Face6", + "FaceRetouchingNatural", + "FaceRetouchingOff", + "Facebook", + "FacebookCircle", + "FactCheck", + "Factory", + "FamilyRestroom", + "FastForward", + "FastRewind", + "Fastfood", + "Favorite", + "FavoriteBorder", + "Fax", + "FeaturedPlayList", + "FeaturedVideo", + "Feed", + "Feedback", + "FeedbackOutline", + "Female", + "Fence", + "Festival", + "FiberDvr", + "FiberManualRecord", + "FiberNew", + "FiberPin", + "FiberSmartRecord", + "FileCopy", + "FileDownload", + "FileDownloadDone", + "FileDownloadOff", + "FileOpen", + "FilePresent", + "FileUpload", + "Filter", + "Filter1", + "Filter2", + "Filter3", + "Filter4", + "Filter5", + "Filter6", + "Filter7", + "Filter8", + "Filter9", + "Filter9Plus", + "FilterAlt", + "FilterAltOff", + "FilterBAndW", + "FilterCenterFocus", + "FilterDrama", + "FilterFrames", + "FilterHdr", + "FilterList", + "FilterListOff", + "FilterNone", + "FilterTiltShift", + "FilterVintage", + "FindInPage", + "FindReplace", + "Fingerprint", + "FireExtinguisher", + "FireHydrantAlt", + "FireTruck", + "Fireplace", + "FirstPage", + "FitScreen", + "Fitbit", + "FitnessCenter", + "Flag", + "FlagCircle", + "Flaky", + "Flare", + "FlashAuto", + "FlashOff", + "FlashOn", + "FlashlightOff", + "FlashlightOn", + "Flatware", + "Flight", + "FlightClass", + "FlightLand", + "FlightTakeoff", + "Flip", + "FlipCameraAndroid", + "FlipCameraIos", + "FlipToBack", + "FlipToFront", + "Flood", + "Flourescent", + "Fluorescent", + "FlutterDash", + "FmdBad", + "FmdGood", + "Folder", + "FolderCopy", + "FolderDelete", + "FolderOff", + "FolderOpen", + "FolderShared", + "FolderSpecial", + "FolderZip", + "FollowTheSigns", + "FontDownload", + "FontDownloadOff", + "FoodBank", + "Forest", + "ForkLeft", + "ForkRight", + "FormatAlignCenter", + "FormatAlignJustify", + "FormatAlignLeft", + "FormatAlignRight", + "FormatBold", + "FormatClear", + "FormatColorFill", + "FormatColorReset", + "FormatColorText", + "FormatIndentDecrease", + "FormatIndentIncrease", + "FormatItalic", + "FormatLineSpacing", + "FormatListBulleted", + "FormatListNumbered", + "FormatListNumberedRtl", + "FormatOverline", + "FormatPaint", + "FormatQuote", + "FormatShapes", + "FormatSize", + "FormatStrikethrough", + "FormatTextdirectionLToR", + "FormatTextdirectionRToL", + "FormatUnderlined", + "Fort", + "Forum", + "Forward", + "Forward10", + "Forward30", + "Forward5", + "ForwardToInbox", + "Foundation", + "FreeBreakfast", + "FreeCancellation", + "FrontHand", + "Fullscreen", + "FullscreenExit", + "Functions", + "GMobiledata", + "GTranslate", + "Gamepad", + "Games", + "Garage", + "GasMeter", + "Gavel", + "GeneratingTokens", + "Gesture", + "GetApp", + "Gif", + "GifBox", + "Girl", + "Gite", + "GolfCourse", + "GoogleMeet", + "GppBad", + "GppGood", + "GppMaybe", + "GpsFixed", + "GpsNotFixed", + "GpsOff", + "Grade", + "Gradient", + "Grading", + "Grain", + "GraphicEq", + "Grass", + "GreaterThan", + "GreaterThanEqual", + "Grid3X3", + "Grid4X4", + "GridGoldenratio", + "GridOff", + "GridOn", + "GridView", + "Group", + "GroupAdd", + "GroupOff", + "GroupRemove", + "GroupWork", + "Groups", + "Groups2", + "Groups3", + "HMobiledata", + "HPlusMobiledata", + "Hail", + "Handshake", + "Handyman", + "Hardware", + "Hd", + "HdrAuto", + "HdrAutoSelect", + "HdrEnhancedSelect", + "HdrOff", + "HdrOffSelect", + "HdrOn", + "HdrOnSelect", + "HdrPlus", + "HdrStrong", + "HdrWeak", + "Headphones", + "HeadphonesBattery", + "Headset", + "HeadsetMic", + "HeadsetOff", + "Healing", + "HealthAndSafety", + "Hearing", + "HearingDisabled", + "HeartBroken", + "HeatPump", + "Height", + "Help", + "HelpCenter", + "HelpOutline", + "Hevc", + "Hexagon", + "HideImage", + "HideSource", + "HighQuality", + "Highlight", + "HighlightAlt", + "HighlightOff", + "Hiking", + "History", + "HistoryEdu", + "HistoryToggleOff", + "Hive", + "Hls", + "HlsOff", + "HolidayVillage", + "Home", + "HomeMax", + "HomeMini", + "HomeRepairService", + "HomeWork", + "HorizontalDistribute", + "HorizontalRule", + "HorizontalSplit", + "HotTub", + "Hotel", + "HotelClass", + "HourglassBottom", + "HourglassDisabled", + "HourglassEmpty", + "HourglassFull", + "HourglassTop", + "House", + "HouseSiding", + "Houseboat", + "HowToReg", + "HowToVote", + "Html", + "Http", + "Https", + "Hub", + "Hvac", + "IceSkating", + "Icecream", + "ImageAspectRatio", + "ImageIcon", + "ImageNotSupported", + "ImageSearch", + "ImagesearchRoller", + "ImportContacts", + "ImportExport", + "ImportantDevices", + "Inbox", + "IncompleteCircle", + "IndeterminateCheckBox", + "Info", + "InfoOutline", + "InputIcon", + "InsertChart", + "InsertChartOutlined", + "InsertComment", + "InsertDriveFile", + "InsertEmoticon", + "InsertInvitation", + "InsertLink", + "InsertPageBreak", + "InsertPhoto", + "Insights", + "InstallDesktop", + "InstallMobile", + "Institution", + "IntegrationInstructions", + "Interests", + "InterpreterMode", + "Inventory", + "Inventory2", + "InvertColors", + "InvertColorsOff", + "IosShare", + "Iron", + "Iso", + "Issue", + "Javascript", + "JoinFull", + "JoinInner", + "JoinLeft", + "JoinRight", + "Kayaking", + "KebabDining", + "Key", + "KeyOff", + "Keyboard", + "KeyboardAlt", + "KeyboardArrowDown", + "KeyboardArrowLeft", + "KeyboardArrowRight", + "KeyboardArrowUp", + "KeyboardBackspace", + "KeyboardCapslock", + "KeyboardCommandKey", + "KeyboardControlKey", + "KeyboardDoubleArrowDown", + "KeyboardDoubleArrowLeft", + "KeyboardDoubleArrowRight", + "KeyboardDoubleArrowUp", + "KeyboardHide", + "KeyboardOptionKey", + "KeyboardReturn", + "KeyboardTab", + "KeyboardVoice", + "KingBed", + "Kitchen", + "Kitesurfing", + "Label", + "LabelImportant", + "LabelOff", + "Lan", + "Landscape", + "Landslide", + "Language", + "Laptop", + "LaptopChromebook", + "LaptopMac", + "LaptopWindows", + "LastPage", + "Launch", + "Layers", + "LayersClear", + "Leaderboard", + "LeakAdd", + "LeakRemove", + "LeaveBagsAtHome", + "LegendToggle", + "Lens", + "LensBlur", + "LessThan", + "LessThanEqual", + "LibraryAdd", + "LibraryAddCheck", + "LibraryBooks", + "LibraryMusic", + "Light", + "LightMode", + "Lightbulb", + "LightbulbCircle", + "LineAxis", + "LineStyle", + "LineWeight", + "LinearScale", + "Link", + "LinkOff", + "LinkedCamera", + "Liquor", + "List", + "ListAlt", + "ListView", + "LiveHelp", + "LiveTv", + "Living", + "LmsBook", + "LmsBookComplete", + "LmsCompletionSolid", + "LmsEditSquare", + "LmsEditSquareComplete", + "LmsOutline", + "LmsVideocam", + "LmsVideocamComplete", + "LocalActivity", + "LocalAirport", + "LocalAtm", + "LocalBar", + "LocalCafe", + "LocalCarWash", + "LocalConvenienceStore", + "LocalDining", + "LocalDrink", + "LocalFireDepartment", + "LocalFlorist", + "LocalGasStation", + "LocalGroceryStore", + "LocalHospital", + "LocalHotel", + "LocalLaundryService", + "LocalLibrary", + "LocalMall", + "LocalMovies", + "LocalOffer", + "LocalParking", + "LocalPharmacy", + "LocalPhone", + "LocalPizza", + "LocalPlay", + "LocalPolice", + "LocalPostOffice", + "LocalPrintshop", + "LocalSee", + "LocalShipping", + "LocalTaxi", + "LocationCity", + "LocationDisabled", + "LocationOff", + "LocationOn", + "LocationSearching", + "Lock", + "LockClock", + "LockOpen", + "LockPerson", + "LockReset", + "Locked", + "Login", + "LogoDev", + "Logout", + "Looks", + "Looks3", + "Looks4", + "Looks5", + "Looks6", + "LooksOne", + "LooksTwo", + "Loop", + "Loupe", + "LowPriority", + "Loyalty", + "LteMobiledata", + "LtePlusMobiledata", + "Luggage", + "LunchDining", + "Lyrics", + "MacroOff", + "Mail", + "MailLock", + "MailOutline", + "Male", + "Man", + "Man2", + "Man3", + "Man4", + "ManageAccounts", + "ManageHistory", + "ManageSearch", + "Map", + "MapsHomeWork", + "MapsUgc", + "Margin", + "MarkAsUnread", + "MarkChatRead", + "MarkChatUnread", + "MarkEmailRead", + "MarkEmailUnread", + "MarkUnreadChatAlt", + "Markunread", + "MarkunreadMailbox", + "Masks", + "Maximize", + "MediaBluetoothOff", + "MediaBluetoothOn", + "Mediation", + "MedicalInformation", + "MedicalServices", + "Medication", + "MedicationLiquid", + "MeetingRoom", + "Memory", + "MenuBook", + "MenuIcon", + "MenuOpen", + "Merge", + "MergeType", + "Message", + "Mic", + "MicExternalOff", + "MicExternalOn", + "MicNone", + "MicOff", + "MicrosoftTeams", + "Microwave", + "MilitaryTech", + "Minimize", + "MinorCrash", + "Minus", + "MiscellaneousServices", + "MissedVideoCall", + "Mms", + "MobileFriendly", + "MobileOff", + "MobileScreenShare", + "MobiledataOff", + "Mode", + "ModeComment", + "ModeEdit", + "ModeEditOutline", + "ModeFanOff", + "ModeNight", + "ModeOfTravel", + "ModeStandby", + "ModelTraining", + "MonetizationOn", + "Money", + "MoneyFilled", + "MoneyOff", + "MoneyOffCsred", + "MoneyOutline", + "Monitor", + "MonitorHeart", + "MonitorWeight", + "MonochromePhotos", + "Mood", + "MoodBad", + "Moped", + "More", + "MoreHoriz", + "MoreTime", + "MoreVert", + "Mosque", + "MotionPhotosAuto", + "MotionPhotosOff", + "MotionPhotosOn", + "MotionPhotosPause", + "MotionPhotosPaused", + "Motorcycle", + "Mouse", + "MoveDown", + "MoveToInbox", + "MoveUp", + "Movie", + "MovieCreation", + "MovieFilter", + "Moving", + "Mp", + "MultilineChart", + "MultipleStop", + "Museum", + "MusicNote", + "MusicOff", + "MusicVideo", + "MyLocation", + "Nat", + "Nature", + "NaturePeople", + "NavigateBefore", + "NavigateNext", + "Navigation", + "Nc", + "Nd", + "NearMe", + "NearMeDisabled", + "NearbyError", + "NearbyOff", + "NestCamWiredStand", + "NetworkCell", + "NetworkCheck", + "NetworkLocked", + "NetworkPing", + "NetworkWifi", + "NetworkWifi1Bar", + "NetworkWifi2Bar", + "NetworkWifi3Bar", + "NewLabel", + "NewReleases", + "Newspaper", + "Newsstand", + "NextPlan", + "NextWeek", + "Nfc", + "NightShelter", + "Nightlife", + "Nightlight", + "NightlightRound", + "NightsStay", + "NoAccounts", + "NoAdultContent", + "NoBackpack", + "NoCell", + "NoCrash", + "NoDrinks", + "NoEncryption", + "NoEncryptionGmailerrorred", + "NoFlash", + "NoFood", + "NoLuggage", + "NoMeals", + "NoMeetingRoom", + "NoPhotography", + "NoSim", + "NoStroller", + "NoTransfer", + "NoiseAware", + "NoiseControlOff", + "NordicWalking", + "North", + "NorthEast", + "NorthWest", + "NotAccessible", + "NotEqual", + "NotInterested", + "NotListedLocation", + "NotStarted", + "Note", + "NoteAdd", + "NoteAlt", + "Notes", + "NotificationAdd", + "NotificationImportant", + "Notifications", + "NotificationsActive", + "NotificationsNone", + "NotificationsOff", + "NotificationsPaused", + "Numbers", + "OfflineBolt", + "OfflinePin", + "OfflineShare", + "OilBarrel", + "OnDeviceTraining", + "OndemandVideo", + "OnlinePrediction", + "Opacity", + "OpenInBrowser", + "OpenInFull", + "OpenInNew", + "OpenInNewOff", + "OpenWith", + "OtherHouses", + "Outbond", + "Outbound", + "Outbox", + "OutdoorGrill", + "Outlet", + "OutlinedFlag", + "Output", + "Padding", + "Pages", + "Pageview", + "Paid", + "Palette", + "PanTool", + "PanToolAlt", + "Panorama", + "PanoramaFishEye", + "PanoramaHorizontal", + "PanoramaHorizontalSelect", + "PanoramaPhotosphere", + "PanoramaPhotosphereSelect", + "PanoramaVertical", + "PanoramaVerticalSelect", + "PanoramaWideAngle", + "PanoramaWideAngleSelect", + "Paragliding", + "Park", + "PartyMode", + "Password", + "Pattern", + "Pause", + "PauseCircle", + "PauseCircleFilled", + "PauseCircleOutline", + "PausePresentation", + "Payment", + "Payments", + "Paypal", + "PedalBike", + "Pending", + "PendingActions", + "Pentagon", + "People", + "PeopleAlt", + "PeopleOutline", + "Percent", + "Percentage", + "PermCameraMic", + "PermContactCalendar", + "PermDataSetting", + "PermDeviceInformation", + "PermIdentity", + "PermMedia", + "PermPhoneMsg", + "PermScanWifi", + "Person", + "Person2", + "Person3", + "Person4", + "PersonAdd", + "PersonAddAlt", + "PersonAddAlt1", + "PersonAddDisabled", + "PersonOff", + "PersonOutline", + "PersonPin", + "PersonPinCircle", + "PersonRemove", + "PersonRemoveAlt1", + "PersonSearch", + "PersonalInjury", + "PersonalVideo", + "PestControl", + "PestControlRodent", + "Pets", + "Pgn0", + "Pgn10K", + "Pgn10Mp", + "Pgn11Mp", + "Pgn123", + "Pgn12Mp", + "Pgn13Mp", + "Pgn14Mp", + "Pgn15Mp", + "Pgn16Mp", + "Pgn17Mp", + "Pgn18Mp", + "Pgn18UpRating", + "Pgn19Mp", + "Pgn1K", + "Pgn1KPlus", + "Pgn1XMobiledata", + "Pgn20Mp", + "Pgn21Mp", + "Pgn22Mp", + "Pgn23Mp", + "Pgn24Mp", + "Pgn2K", + "Pgn2KPlus", + "Pgn2Mp", + "Pgn30Fps", + "Pgn30FpsSelect", + "Pgn360", + "Pgn3DRotation", + "Pgn3GMobiledata", + "Pgn3K", + "Pgn3KPlus", + "Pgn3Mp", + "Pgn3P", + "Pgn4GMobiledata", + "Pgn4GPlusMobiledata", + "Pgn4K", + "Pgn4KPlus", + "Pgn4Mp", + "Pgn5G", + "Pgn5K", + "Pgn5KPlus", + "Pgn5Mp", + "Pgn60Fps", + "Pgn60FpsSelect", + "Pgn6FtApart", + "Pgn6K", + "Pgn6KPlus", + "Pgn6Mp", + "Pgn7K", + "Pgn7KPlus", + "Pgn7Mp", + "Pgn8K", + "Pgn8KPlus", + "Pgn8Mp", + "Pgn9K", + "Pgn9KPlus", + "Pgn9Mp", + "Phishing", + "Phone", + "PhoneAndroid", + "PhoneBluetoothSpeaker", + "PhoneCallback", + "PhoneDisabled", + "PhoneEnabled", + "PhoneForwarded", + "PhoneInTalk", + "PhoneIphone", + "PhoneLocked", + "PhoneMissed", + "PhonePaused", + "Phonelink", + "PhonelinkErase", + "PhonelinkLock", + "PhonelinkOff", + "PhonelinkRing", + "PhonelinkSetup", + "Photo", + "PhotoAlbum", + "PhotoCamera", + "PhotoCameraBack", + "PhotoCameraFront", + "PhotoFilter", + "PhotoLibrary", + "PhotoSizeSelectActual", + "PhotoSizeSelectLarge", + "PhotoSizeSelectSmall", + "Php", + "Piano", + "PianoOff", + "PictureAsPdf", + "PictureInPicture", + "PictureInPictureAlt", + "PieChart", + "PieChartOutline", + "Pin", + "PinDrop", + "PinEnd", + "PinInvoke", + "PinOff", + "Pinch", + "PivotTableChart", + "Pix", + "Place", + "Plagiarism", + "PlayArrow", + "PlayCircle", + "PlayCircleFilled", + "PlayCircleFilledWhite", + "PlayCircleOutline", + "PlayDisabled", + "PlayForWork", + "PlayLesson", + "PlaylistAdd", + "PlaylistAddCheck", + "PlaylistAddCheckCircle", + "PlaylistAddCircle", + "PlaylistPlay", + "PlaylistRemove", + "Plumbing", + "Plus", + "PlusMinus", + "PlusMinusAlt", + "PlusOne", + "Podcasts", + "PointOfSale", + "Policy", + "Poll", + "Polyline", + "Polymer", + "Pool", + "PortableWifiOff", + "Portrait", + "Post", + "PostAdd", + "PostOutline", + "Power", + "PowerInput", + "PowerOff", + "PowerSettingsNew", + "PrecisionManufacturing", + "PregnantWoman", + "PresentToAll", + "Preview", + "PriceChange", + "PriceCheck", + "Print", + "PrintDisabled", + "PriorityHigh", + "PrivacyTip", + "PrivateConnectivity", + "ProductionQuantityLimits", + "Program", + "Propane", + "PropaneTank", + "Psychology", + "PsychologyAlt", + "Public", + "PublicOff", + "Publish", + "PublishedWithChanges", + "PunchClock", + "PushPin", + "QrCode2", + "QrCodeScanner", + "Qrcode", + "QueryBuilder", + "QueryStats", + "Question", + "QuestionAnswer", + "QuestionAnswerOutline", + "QuestionMark", + "Queue", + "QueueMusic", + "QueuePlayNext", + "Quickreply", + "Quiz", + "Quora", + "RMobiledata", + "Radar", + "Radio", + "RadioButtonChecked", + "RadioButtonUnchecked", + "RailwayAlert", + "RamenDining", + "RampLeft", + "RampRight", + "RateReview", + "RawOff", + "RawOn", + "ReadMore", + "RealEstateAgent", + "Receipt", + "ReceiptLong", + "RecentActors", + "Recommend", + "Record", + "RecordVoiceOver", + "Rectangle", + "Recycling", + "Reddit", + "Redeem", + "Redo", + "ReduceCapacity", + "Refresh", + "RememberMe", + "Remove", + "RemoveCircle", + "RemoveCircleOutline", + "RemoveDone", + "RemoveFromQueue", + "RemoveModerator", + "RemoveRedEye", + "RemoveRoad", + "RemoveShoppingCart", + "Reorder", + "Repartition", + "Repeat", + "RepeatOn", + "RepeatOne", + "RepeatOneOn", + "Replay", + "Replay10", + "Replay30", + "Replay5", + "ReplayCircleFilled", + "Reply", + "ReplyAll", + "Report", + "ReportGmailerrorred", + "ReportOff", + "ReportOutline", + "ReportProblem", + "RequestPage", + "RequestQuote", + "ResetTv", + "RestartAlt", + "Restaurant", + "RestaurantMenu", + "Restore", + "RestoreFromTrash", + "RestorePage", + "Reviews", + "RiceBowl", + "RightSidebarFilled", + "RightSidebarOutlined", + "RingVolume", + "Rocket", + "RocketLaunch", + "RollerShades", + "RollerShadesClosed", + "RollerSkating", + "Roofing", + "Room", + "RoomPreferences", + "RoomService", + "Rotate90DegreesCcw", + "Rotate90DegreesCw", + "RotateLeft", + "RotateRight", + "RoundaboutLeft", + "RoundaboutRight", + "RoundedCorner", + "Route", + "Router", + "Rowing", + "RssFeed", + "Rsvp", + "Rtt", + "Rule", + "RuleFolder", + "RunCircle", + "RunningWithErrors", + "RvHookup", + "Sa", + "SafetyCheck", + "SafetyDivider", + "Sailing", + "Sanitizer", + "Satellite", + "SatelliteAlt", + "Save", + "SaveAll", + "SaveAlt", + "SaveAs", + "SavedSearch", + "Savings", + "Scale", + "Scanner", + "ScatterPlot", + "Schedule", + "ScheduleSend", + "Schema", + "School", + "SchoolOutline", + "Science", + "Score", + "Scoreboard", + "ScreenLockLandscape", + "ScreenLockPortrait", + "ScreenLockRotation", + "ScreenRotation", + "ScreenRotationAlt", + "ScreenSearchDesktop", + "ScreenShare", + "Screenshot", + "ScreenshotMonitor", + "ScubaDiving", + "Sd", + "SdCard", + "SdCardAlert", + "SdStorage", + "Search", + "SearchOff", + "Security", + "SecurityUpdate", + "SecurityUpdateGood", + "SecurityUpdateWarning", + "Segment", + "SelectAll", + "SelfImprovement", + "Sell", + "Send", + "SendAndArchive", + "SendTimeExtension", + "SendToMobile", + "SensorDoor", + "SensorOccupied", + "SensorWindow", + "Sensors", + "SensorsOff", + "SentimentDissatisfied", + "SentimentNeutral", + "SentimentSatisfied", + "SentimentSatisfiedAlt", + "SentimentSlightlyDissatisfied", + "SentimentVeryDissatisfied", + "SentimentVerySatisfied", + "SetMeal", + "Settings", + "SettingsAccessibility", + "SettingsApplications", + "SettingsBackupRestore", + "SettingsBluetooth", + "SettingsBrightness", + "SettingsCell", + "SettingsEthernet", + "SettingsInputAntenna", + "SettingsInputComponent", + "SettingsInputComposite", + "SettingsInputHdmi", + "SettingsInputSvideo", + "SettingsOverscan", + "SettingsPhone", + "SettingsPower", + "SettingsRemote", + "SettingsSuggest", + "SettingsSystemDaydream", + "SettingsVoice", + "SevereCold", + "ShapeLine", + "Share", + "ShareArrivalTime", + "ShareLocation", + "Shield", + "ShieldMoon", + "Shop", + "Shop2", + "ShopTwo", + "Shopify", + "ShoppingBag", + "ShoppingBasket", + "ShoppingCart", + "ShoppingCartCheckout", + "ShortText", + "Shortcut", + "ShowChart", + "Shower", + "Shuffle", + "ShuffleOn", + "ShutterSpeed", + "Sick", + "SignLanguage", + "SignalCellular0Bar", + "SignalCellular1Bar", + "SignalCellular2Bar", + "SignalCellular3Bar", + "SignalCellular4Bar", + "SignalCellularAlt", + "SignalCellularAlt1Bar", + "SignalCellularAlt2Bar", + "SignalCellularConnectedNoInternet0Bar", + "SignalCellularConnectedNoInternet1Bar", + "SignalCellularConnectedNoInternet2Bar", + "SignalCellularConnectedNoInternet3Bar", + "SignalCellularConnectedNoInternet4Bar", + "SignalCellularNoSim", + "SignalCellularNodata", + "SignalCellularNull", + "SignalCellularOff", + "SignalWifi0Bar", + "SignalWifi1Bar", + "SignalWifi1BarLock", + "SignalWifi2Bar", + "SignalWifi2BarLock", + "SignalWifi3Bar", + "SignalWifi3BarLock", + "SignalWifi4Bar", + "SignalWifi4BarLock", + "SignalWifiBad", + "SignalWifiConnectedNoInternet4", + "SignalWifiOff", + "SignalWifiStatusbar4Bar", + "SignalWifiStatusbarConnectedNoInternet4", + "SignalWifiStatusbarNull", + "Signpost", + "SimCard", + "SimCardAlert", + "SimCardDownload", + "SingleBed", + "Sip", + "Skateboarding", + "SkipNext", + "SkipPrevious", + "Sledding", + "Slideshow", + "SlowMotionVideo", + "SmartButton", + "SmartDisplay", + "SmartScreen", + "SmartToy", + "Smartphone", + "SmokeFree", + "SmokingRooms", + "Sms", + "SmsFailed", + "Snapchat", + "SnippetFolder", + "Snooze", + "Snowboarding", + "Snowmobile", + "Snowshoeing", + "Soap", + "SocialDistance", + "SolarPower", + "Sort", + "SortByAlpha", + "Sos", + "SoupKitchen", + "Source", + "South", + "SouthAmerica", + "SouthEast", + "SouthWest", + "Spa", + "SpaceBar", + "SpaceDashboard", + "SpatialAudio", + "SpatialAudioOff", + "SpatialTracking", + "Speaker", + "SpeakerGroup", + "SpeakerNotes", + "SpeakerNotesOff", + "SpeakerPhone", + "Speed", + "Spellcheck", + "SpinnerIcon", + "SpinnerSimple", + "Splitscreen", + "Spoke", + "Sports", + "SportsBar", + "SportsBaseball", + "SportsBasketball", + "SportsCricket", + "SportsEsports", + "SportsFootball", + "SportsGolf", + "SportsGymnastics", + "SportsHandball", + "SportsHockey", + "SportsKabaddi", + "SportsMartialArts", + "SportsMma", + "SportsMotorsports", + "SportsRugby", + "SportsScore", + "SportsSoccer", + "SportsTennis", + "SportsVolleyball", + "Square", + "SquareFoot", + "SsidChart", + "StackedBarChart", + "StackedLineChart", + "Stadium", + "Stairs", + "Star", + "StarBorder", + "StarBorderPurple500", + "StarFilled", + "StarHalf", + "StarOutline", + "StarPurple500", + "StarRate", + "Stars", + "Start", + "StayCurrentLandscape", + "StayCurrentPortrait", + "StayPrimaryLandscape", + "StayPrimaryPortrait", + "StickyNote2", + "Stop", + "StopCircle", + "StopScreenShare", + "Storage", + "Store", + "StoreMallDirectory", + "Storefront", + "Storm", + "Straight", + "Straighten", + "Stream", + "Streetview", + "StrikethroughS", + "Stroller", + "Style", + "SubdirectoryArrowLeft", + "SubdirectoryArrowRight", + "Subject", + "Subscript", + "Subscriptions", + "Subtitles", + "SubtitlesOff", + "Subway", + "Summarize", + "Superscript", + "SupervisedUserCircle", + "SupervisorAccount", + "Support", + "SupportAgent", + "Surfing", + "SurroundSound", + "SwapCalls", + "SwapHoriz", + "SwapHorizontalCircle", + "SwapVert", + "SwapVerticalCircle", + "Swipe", + "SwipeDown", + "SwipeDownAlt", + "SwipeLeft", + "SwipeLeftAlt", + "SwipeRight", + "SwipeRightAlt", + "SwipeUp", + "SwipeUpAlt", + "SwipeVertical", + "SwitchAccessShortcut", + "SwitchAccessShortcutAdd", + "SwitchAccount", + "SwitchCamera", + "SwitchLeft", + "SwitchRight", + "SwitchVideo", + "Synagogue", + "Sync", + "SyncAlt", + "SyncDisabled", + "SyncLock", + "SyncProblem", + "SystemSecurityUpdate", + "SystemSecurityUpdateGood", + "SystemSecurityUpdateWarning", + "SystemUpdate", + "SystemUpdateAlt", + "TabIcon", + "TabUnselected", + "TableBar", + "TableChart", + "TableRestaurant", + "TableRows", + "TableView", + "Tablet", + "TabletAndroid", + "TabletMac", + "Tag", + "TagFaces", + "TakeoutDining", + "TapAndPlay", + "Tapas", + "Task", + "TaskAlt", + "TaxiAlert", + "Telegram", + "TempleBuddhist", + "TempleHindu", + "Terminal", + "Terrain", + "TextDecrease", + "TextFields", + "TextFormat", + "TextIncrease", + "TextRotateUp", + "TextRotateVertical", + "TextRotationAngledown", + "TextRotationAngleup", + "TextRotationDown", + "TextRotationNone", + "TextSnippet", + "TextToSpeech", + "Textsms", + "Texture", + "TheaterComedy", + "Theaters", + "Thermostat", + "ThermostatAuto", + "ThumbDown", + "ThumbDownAlt", + "ThumbDownOffAlt", + "ThumbUp", + "ThumbUpAlt", + "ThumbUpFilled", + "ThumbUpOffAlt", + "ThumbUpOutline", + "ThumbsUpDown", + "Thunderstorm", + "Tiktok", + "TimeToLeave", + "Timelapse", + "Timeline", + "Timer", + "Timer10", + "Timer10Select", + "Timer3", + "Timer3Select", + "TimerOff", + "TipsAndUpdates", + "TireRepair", + "Title", + "Toc", + "Today", + "ToggleOff", + "ToggleOn", + "Token", + "Toll", + "Tonality", + "Topic", + "Tornado", + "TouchApp", + "Tour", + "Toys", + "TrackChanges", + "Traffic", + "Train", + "Tram", + "Transcribe", + "TransferWithinAStation", + "Transform", + "Transgender", + "TransitEnterexit", + "Translate", + "TravelExplore", + "TrendingDown", + "TrendingFlat", + "TrendingUp", + "TripOrigin", + "Troubleshoot", + "Try", + "Tsunami", + "Tty", + "Tune", + "Tungsten", + "TurnLeft", + "TurnRight", + "TurnSharpLeft", + "TurnSharpRight", + "TurnSlightLeft", + "TurnSlightRight", + "TurnedIn", + "TurnedInNot", + "Tv", + "TvOff", + "TwoWheeler", + "TypeSpecimen", + "UTurnLeft", + "UTurnRight", + "Umbrella", + "Unarchive", + "Undo", + "UnfoldLess", + "UnfoldLessDouble", + "UnfoldMore", + "UnfoldMoreDouble", + "Unlocked", + "Unpublished", + "Unread", + "Unsubscribe", + "Upcoming", + "Update", + "UpdateDisabled", + "Upgrade", + "Upload", + "UploadFile", + "Usb", + "UsbOff", + "Vaccines", + "VapeFree", + "VapingRooms", + "Verified", + "VerifiedBadge", + "VerifiedBadgeOutline", + "VerifiedOutline", + "VerifiedUser", + "VerticalAlignBottom", + "VerticalAlignCenter", + "VerticalAlignTop", + "VerticalDistribute", + "VerticalShades", + "VerticalShadesClosed", + "VerticalSplit", + "Vibration", + "VideoCall", + "VideoCamera", + "VideoCameraBack", + "VideoCameraFront", + "VideoChat", + "VideoFile", + "VideoLabel", + "VideoLibrary", + "VideoSettings", + "VideoStable", + "VideoTranscript", + "Videocam", + "VideocamOff", + "VideocamOutlined", + "VideogameAsset", + "VideogameAssetOff", + "ViewAgenda", + "ViewArray", + "ViewCarousel", + "ViewColumn", + "ViewComfy", + "ViewComfyAlt", + "ViewCompact", + "ViewCompactAlt", + "ViewCozy", + "ViewDay", + "ViewHeadline", + "ViewInAr", + "ViewKanban", + "ViewList", + "ViewModule", + "ViewQuilt", + "ViewSidebar", + "ViewStream", + "ViewTimeline", + "ViewWeek", + "Vignette", + "Villa", + "Visibility", + "VisibilityOff", + "VoiceChat", + "VoiceOverOff", + "Voicemail", + "Volcano", + "VolumeDown", + "VolumeMute", + "VolumeOff", + "VolumeUp", + "VolunteerActivism", + "VpnKey", + "VpnKeyOff", + "VpnLock", + "Vrpano", + "Wallet", + "Wallpaper", + "Warehouse", + "Warning", + "WarningAmber", + "WarningFilled", + "Wash", + "Watch", + "WatchFilled", + "WatchLater", + "WatchOff", + "WatchOutline", + "Water", + "WaterDamage", + "WaterDrop", + "WaterfallChart", + "Waves", + "WavingHand", + "WbAuto", + "WbCloudy", + "WbIncandescent", + "WbIridescent", + "WbShade", + "WbSunny", + "WbTwilight", + "Wc", + "Web", + "WebAsset", + "WebAssetOff", + "WebStories", + "Webhook", + "Wechat", + "Weekend", + "West", + "Whatsapp", + "Whatshot", + "WheelchairPickup", + "WhereToVote", + "Widgets", + "WidthFull", + "WidthNormal", + "WidthWide", + "Wifi", + "Wifi1Bar", + "Wifi2Bar", + "WifiCalling", + "WifiCalling3", + "WifiChannel", + "WifiFind", + "WifiLock", + "WifiOff", + "WifiPassword", + "WifiProtectedSetup", + "WifiTethering", + "WifiTetheringError", + "WifiTetheringErrorRounded", + "WifiTetheringOff", + "WindPower", + "Window", + "WineBar", + "Woman", + "Woman2", + "WooCommerce", + "Wordpress", + "Work", + "WorkHistory", + "WorkOff", + "WorkOutline", + "WorkspacePremium", + "Workspaces", + "WrapText", + "WrongLocation", + "Wysiwyg", + "Yard", + "YoutubeSearchedFor", + "Zoom", + "ZoomIn", + "ZoomInMap", + "ZoomOut", + "ZoomOutMap", + } +) diff --git a/src/openedx_authz/engine/schema/validation.py b/src/openedx_authz/engine/schema/validation.py index daf4d222..0ccf69c4 100644 --- a/src/openedx_authz/engine/schema/validation.py +++ b/src/openedx_authz/engine/schema/validation.py @@ -10,6 +10,9 @@ * 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. + * ``icon`` values, where present, are valid ``@openedx/paragon/icons`` names + (ADR 0017 §4). The allowed set is vendored in :mod:`paragon_icons` + (regenerate with ``make paragon_icons``; see ADR 0029). Whole-set checks (after all documents load): * Every permission ``category`` references an existing category. @@ -36,6 +39,7 @@ from enum import StrEnum from openedx_authz.constants import SchemaOriginKind +from openedx_authz.engine.schema.paragon_icons import PARAGON_ICON_NAMES from openedx_authz.engine.schema.types import ( CompiledSchema, SchemaDocument, @@ -49,6 +53,8 @@ PERMISSION_ID_RE = re.compile(rf"^{_IDENTIFIER}\.{_IDENTIFIER}$") # Scope namespaces follow their registered spelling and may contain hyphens. SCOPE_RE = re.compile(r"^[a-z][a-z0-9_-]*$") +# Paragon icon names are PascalCase component exports, e.g. ``RemoveRedEye``. +ICON_RE = re.compile(r"^[A-Z][A-Za-z0-9]*$") # Casbin-internal prefixes that must never appear in a schema identifier. CASBIN_INTERNAL_PREFIXES = ("act^", "role^", "sub^", "scope^", "g^", "p^") @@ -166,21 +172,25 @@ def validate_document(self, document: SchemaDocument) -> list[ValidationIssue]: for category in document.categories: issues.extend(self._check_identifier(category.id, "category id", sid)) issues.extend(self._require(category.id, "category id", sid)) + issues.extend(self._check_icon(category.icon, f"category {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_id, f"category for {permission.identifier}", sid)) issues.extend(self._check_scopes(permission.scopes, f"permission {permission.identifier}", sid)) + issues.extend(self._check_icon(permission.icon, 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)) + issues.extend(self._check_icon(role.icon, 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_id, "role_extension target", sid)) + issues.extend(self._check_icon(extension.icon, f"role_extension {extension.role_id}", sid)) for perm_id in (*extension.add_permissions, *extension.remove_permissions): issues.extend(self._check_permission_id(perm_id, f"role_extension {extension.role_id}", sid)) @@ -388,6 +398,35 @@ def _check_scopes(self, scopes: tuple[str, ...], context: str, sid: str) -> list issues.append(ValidationIssue(IssueLevel.ERROR, f"{context}: invalid scope namespace {scope!r}.", sid)) return issues + @staticmethod + def _check_icon(value: str | None, context: str, sid: str) -> list[ValidationIssue]: + """Validate an optional Paragon icon name (ADR 0017 §4). + + Icons are optional everywhere, so an empty/``None`` value passes. A + present value must be PascalCase and one of the vendored + ``@openedx/paragon/icons`` names (see :mod:`paragon_icons`, + ``make paragon_icons``). Both failures are errors. + """ + if not value: + return [] + if not ICON_RE.match(value): + return [ + ValidationIssue( + IssueLevel.ERROR, + f"{context}: icon {value!r} must be a PascalCase Paragon icon name.", + sid, + ) + ] + if value not in PARAGON_ICON_NAMES: + return [ + ValidationIssue( + IssueLevel.ERROR, + f"{context}: icon {value!r} is not a valid @openedx/paragon/icons name.", + sid, + ) + ] + return [] + @staticmethod def _require(value: str, label: str, sid: str) -> list[ValidationIssue]: if not value: diff --git a/src/openedx_authz/tests/schema/test_validation.py b/src/openedx_authz/tests/schema/test_validation.py index 20f8b8b6..790b06a9 100644 --- a/src/openedx_authz/tests/schema/test_validation.py +++ b/src/openedx_authz/tests/schema/test_validation.py @@ -8,6 +8,7 @@ import pytest from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.engine.schema.paragon_icons import PARAGON_ICON_NAMES from openedx_authz.engine.schema.validation import ( IssueLevel, SchemaValidator, @@ -406,6 +407,105 @@ def test_source_id_is_omitted_when_provenance_is_missing(self): assert [i.source_id for i in issues] == [None] +class TestIconRules: + """ADR 0017 §4: icon names must be valid @openedx/paragon/icons names.""" + + # Pick a known-valid icon name dynamically instead of hardcoding a specific icon. + # This makes the tests resilient if a future paragon upgrade removes the icon we + # might have chosen. We use the first icon in the frozenset (sorted for stability). + _VALID_ICON = sorted(PARAGON_ICON_NAMES)[0] + + def test_valid_category_icon_has_no_errors(self): + """A real Paragon icon name on a category passes validation.""" + doc = make_document(categories=[category("cat", icon=self._VALID_ICON)]) + + assert not _errors(SchemaValidator().validate([doc])) + + def test_valid_permission_icon_has_no_errors(self): + """A real Paragon icon name on a permission passes validation.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat", icon=self._VALID_ICON)], + ) + + assert not _errors(SchemaValidator().validate([doc])) + + def test_valid_role_icon_has_no_errors(self): + """A real Paragon icon name on a role passes validation.""" + doc = make_document(roles=[role(icon=self._VALID_ICON)]) + + assert not _errors(SchemaValidator().validate([doc])) + + def test_valid_role_extension_icon_has_no_errors(self): + """A real Paragon icon name on a role_extension passes validation.""" + base = make_document( + "base", + categories=[category("cat")], + permissions=[permission(cat="cat")], + roles=[role()], + ) + ext = make_document("ext", role_extensions=[extension("course_editor", icon=self._VALID_ICON)]) + + assert not _errors(SchemaValidator().validate([base, ext])) + + @pytest.mark.parametrize("icon", [None, ""]) + def test_missing_icon_is_allowed(self, icon): + """Icons are optional on every definition.""" + doc = make_document(categories=[category("cat", icon=icon)]) + + assert not _errors(SchemaValidator().validate([doc])) + + @pytest.mark.parametrize("icon", ["remove_red_eye", "removeRedEye", "Remove-Red-Eye", "9Mp lowercase"]) + def test_non_pascalcase_icon_is_error(self, icon): + """An icon name that is not PascalCase is rejected before the export check.""" + doc = make_document(categories=[category("cat", icon=icon)]) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("must be a PascalCase Paragon icon name" in m for m in messages) + + def test_unknown_but_wellformed_icon_is_error(self): + """A PascalCase name that Paragon does not export is rejected.""" + doc = make_document(categories=[category("cat", icon="NotARealParagonIcon")]) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("is not a valid @openedx/paragon/icons name" in m for m in messages) + + def test_permission_icon_is_validated(self): + """A permission's icon is validated and the error names the permission.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat", icon="totally_wrong")], + ) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("permission courses.view_course" in m and "PascalCase" in m for m in messages) + + def test_role_icon_is_validated(self): + """A role's icon is validated and the error names the role.""" + doc = make_document(roles=[role(icon="NotARealParagonIcon")]) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("role course_editor" in m and "not a valid @openedx/paragon/icons" in m for m in messages) + + def test_role_extension_icon_is_validated(self): + """A role_extension may set an icon, so its value is checked too.""" + base = make_document( + "base", + categories=[category("cat")], + permissions=[permission(cat="cat")], + roles=[role()], + ) + ext = make_document("ext", role_extensions=[extension("course_editor", icon="not_valid")]) + + messages = [i.message for i in _errors(SchemaValidator().validate([base, ext]))] + + assert any("role_extension course_editor" in m for m in messages) + + class TestHasErrors: """The gate the pipeline uses to decide whether to stop."""