diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 88bfebdf..630f98cb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 ******************* diff --git a/Makefile b/Makefile index 58a20476..50f409a7 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 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 diff --git a/docs/decisions/0026-paragon-icon-list-maintenance.rst b/docs/decisions/0026-paragon-icon-list-maintenance.rst new file mode 100644 index 00000000..97d9776a --- /dev/null +++ b/docs/decisions/0026-paragon-icon-list-maintenance.rst @@ -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 } 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/api/data.py b/src/openedx_authz/api/data.py index 20e3e153..a8724606 100644 --- a/src/openedx_authz/api/data.py +++ b/src/openedx_authz/api/data.py @@ -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() @@ -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.""" diff --git a/src/openedx_authz/api/permissions.py b/src/openedx_authz/api/permissions.py index 6448866a..aaf51d2e 100644 --- a/src/openedx_authz/api/permissions.py +++ b/src/openedx_authz/api/permissions.py @@ -22,14 +22,15 @@ def get_permission_from_policy(policy: list[str]) -> PermissionData: policy: A list representing a Casbin policy. Returns: - PermissionData: The corresponding PermissionData object or an empty PermissionData if the policy is invalid. - """ - if len(policy) < 4: # Do not count ptype - raise ValueError("Invalid policy format. Expected at least 4 elements.") + PermissionData: The corresponding PermissionData object. + Raises: + ValueError: If ``policy`` has fewer than ``PolicyIndex.required_width()`` elements. + """ + _role, action, _scope, effect = PolicyIndex.parse(policy) return PermissionData( - action=ActionData(namespaced_key=policy[PolicyIndex.ACT.value]), - effect=policy[PolicyIndex.EFFECT.value], + action=ActionData(namespaced_key=action), + effect=effect, ) diff --git a/src/openedx_authz/data.py b/src/openedx_authz/data.py index 2df5af37..607e03e7 100644 --- a/src/openedx_authz/data.py +++ b/src/openedx_authz/data.py @@ -5,12 +5,90 @@ circular import between openedx_authz.api.data and openedx_authz.constants.permissions. """ +from enum import Enum from typing import ClassVar, Literal from attrs import define AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^" +# Shared authz vocabulary. These are the single source of truth for the namespace +# prefixes, scope wildcard, policy type, and default effect used across the authz +# data classes and the engine renderer, so every producer and consumer of a Casbin +# row agrees on its exact shape. +ROLE_NAMESPACE = "role" +ACTION_NAMESPACE = "act" +SCOPE_WILDCARD = "*" +POLICY_PTYPE = "p" +EFFECT_ALLOW = "allow" + + +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, ...] + + This is the single source of truth for the ``p`` row field layout, shared by + every producer and consumer of a Casbin row (the engine renderer and the + ``api`` data classes) so the mapping stays in one place. + + 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 + + @classmethod + def required_width(cls) -> int: + """Return the number of leading fields that make up a complete ``p`` row (4).""" + return len(cls) + + @classmethod + def pad(cls, values: list[str]) -> list[str]: + """ + Pad ``values`` with empty strings up to :meth:`required_width`. + + Callers that accept partially populated rows (e.g. the renderer + round-tripping an in-memory row) pad first so the shared, strict + :meth:`parse` does not reject them. + """ + return list(values) + [""] * (cls.required_width() - len(values)) + + @classmethod + def parse(cls, policy: list[str]) -> tuple[str, str, str, str]: + """ + Return ``(role, action, scope, effect)`` from a Casbin ``p`` row. + + The single place a ``p`` row is split into its fields, so every consumer + agrees on both the layout and the minimum shape. Rows shorter than + :meth:`required_width` are rejected; a caller that wants to tolerate a + partial row should :meth:`pad` it first. + + Raises: + ValueError: If ``policy`` has fewer than :meth:`required_width` + elements. + """ + if len(policy) < cls.required_width(): + raise ValueError(f"Invalid policy format. Expected at least {cls.required_width()} elements.") + return ( + policy[cls.ROLE.value], + policy[cls.ACT.value], + policy[cls.SCOPE.value], + policy[cls.EFFECT.value], + ) + class AuthzBaseClass: """Base class for all authz classes.""" @@ -59,7 +137,7 @@ class ActionData(AuthZData): 'Content Libraries > Delete Library' """ - NAMESPACE: ClassVar[str] = "act" + NAMESPACE: ClassVar[str] = ACTION_NAMESPACE @property def name(self) -> str: @@ -93,7 +171,7 @@ class PermissionData: """ action: ActionData = None - effect: Literal["allow", "deny"] = "allow" + effect: Literal["allow", "deny"] = EFFECT_ALLOW @property def identifier(self) -> str: diff --git a/src/openedx_authz/engine/renderer.py b/src/openedx_authz/engine/renderer.py new file mode 100644 index 00000000..87b85dc0 --- /dev/null +++ b/src/openedx_authz/engine/renderer.py @@ -0,0 +1,686 @@ +"""Render compiled definitions to Casbin rows and apply them (ADR 0018 §1, §5). + +This is the only Casbin/Django-aware part of the schema pipeline. It implements +the ``render`` and ``apply`` lifecycle steps: + +* ``render`` builds the Casbin ``p`` rows for a :class:`CompiledSchema` in + memory, without touching the database. +* ``apply`` persists the rows in a single transaction, while preserving data + owned by other services (ADR 0018 §3): dynamic roles, user assignments, and + the legacy ``g2`` action-inheritance rows that still live in ``authz.policy``. + +Key semantics: + * Idempotent (ADR 0018 §2): re-applying identical definitions changes + nothing and creates no duplicates. After a successful apply the stored + policy equals the compiled definition — no stale rows remain. + * Change report before write (ADR 0018 §6): :meth:`SchemaApplier.plan` + reports the ``p`` rows that will be added or removed by comparing rendered + output against the currently stored policy. + * Adoption, not duplication (ADR 0025 §6): a rendered row that already + exists as a ``p`` row gains definition and source records without being + rewritten, while a stored row no schema declares is left in place and + enforceable but unattributed. + * Removal is force-gated (ADR 0018 §6): a role slated for removal that still + has user assignments requires an explicit force option. Without force the + apply aborts before any write; with force the role's ``p`` rows and its + ``g`` assignment rows are removed together. + +:meth:`SchemaApplier.apply` reconciles the stored policy to the rendered set: +it adds missing rows, removes stale rows it owns, and prunes definition/source +records that the compiled schema no longer contains, all in one transaction. + +The definition/source model (ADR 0018 §3, ADR 0025) is the ownership record +that makes precise pruning safe, and it is consulted rather than assumed: +removal candidates are intersected with the stored role-permission grants (see +:meth:`SchemaApplier._managed_rows`), so unmanaged ``p`` rows, dynamic roles, +user assignments, and legacy ``g2`` action inheritance are all preserved. + +``render`` is pure in behavior — it touches no database and holds no Casbin +state. ``plan``/``apply`` resolve the enforcer only when called (not at import), +so enforcer initialization still happens after Django settings are configured. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from crum import get_current_user +from django.db import transaction +from openedx_events.authz.data import RoleAssignmentData as RoleAssignmentEventData +from openedx_events.authz.signals import ROLE_ASSIGNMENT_DELETED + +from openedx_authz.data import ( + ACTION_NAMESPACE, + EFFECT_ALLOW, + POLICY_PTYPE, + ROLE_NAMESPACE, + SCOPE_WILDCARD, + PolicyIndex, +) +from openedx_authz.data import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR as SEP +from openedx_authz.engine.enforcer import AuthzEnforcer +from openedx_authz.engine.schema.exceptions import SchemaApplyError +from openedx_authz.engine.schema.types import CompiledSchema, RoleDefinition +from openedx_authz.models import schema as schema_models +from openedx_authz.models.core import RoleAssignmentAudit + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PolicyRow: + """A single Casbin ``p`` row rendered from a role-permission pair. + + Fields follow the ``p`` shape: subject (role), action (permission), scope + pattern, effect. Namespacing to the internal Casbin form (``role^``, + ``act^``, ``^*``) happens here, at the boundary — schema objects + never carry those prefixes. + """ + + ptype: str # always "p" for rendered definition rows + subject: str + action: str + scope: str + effect: str + + def as_policy(self) -> list[str]: + """Return the enforcer arg form: ``[subject, action, scope, effect]``.""" + return [self.subject, self.action, self.scope, self.effect] + + @classmethod + def from_policy(cls, values: list[str]) -> "PolicyRow": + """Build from a stored ``p`` row (``[subject, action, scope, effect]``). + + Parsing is delegated to the shared, strict + :meth:`~openedx_authz.data.PolicyIndex.parse`, so this stays in step + with the ``api`` layer. A partially populated row is padded first, so an + in-memory row round-trips instead of being rejected. + """ + subject, action, scope, effect = PolicyIndex.parse(PolicyIndex.pad(values)) + return cls(POLICY_PTYPE, subject, action, scope, effect) + + @classmethod + def from_grant(cls, role_id: str, permission_id: str, scope: str) -> "PolicyRow": + """Build the Casbin ``p`` row for one ``(role, permission, scope)`` grant. + + The single place the internal namespacing is applied, so every producer + and consumer of a rendered row agrees on its exact shape. A drift + between two such places would silently stop a later comparison against + the stored policy from matching anything. + """ + return cls( + ptype=POLICY_PTYPE, + subject=f"{ROLE_NAMESPACE}{SEP}{role_id}", + action=f"{ACTION_NAMESPACE}{SEP}{permission_id}", + scope=f"{scope}{SEP}{SCOPE_WILDCARD}", + effect=EFFECT_ALLOW, + ) + + +@dataclass +class RenderedPolicy: + """The full set of ``p`` rows for a compiled schema (no DB access).""" + + rows: list[PolicyRow] = field(default_factory=list) + + +@dataclass +class DefinitionDiff: + """What would change for one kind of definition (ADR 0018 §6). + + ``updated`` covers metadata-only edits — a new display name or icon — which + change no policy row at all and would otherwise be invisible in the report. + """ + + added: list[str] = field(default_factory=list) + updated: list[str] = field(default_factory=list) + removed: list[str] = field(default_factory=list) + + @property + def is_empty(self) -> bool: + """True when this kind of definition is untouched.""" + return not (self.added or self.updated or self.removed) + + def __len__(self) -> int: + return len(self.added) + len(self.updated) + len(self.removed) + + +@dataclass +class ChangePlan: + """Diff between rendered definitions and what is currently stored. + + Presented to the operator before any write (ADR 0018 §6). Covers both the + Casbin ``p`` rows and the definition tables, because the apply step syncs + definitions even when no policy row changes. + """ + + added_rows: list[PolicyRow] = field(default_factory=list) + removed_rows: list[PolicyRow] = field(default_factory=list) + unchanged: bool = False + # (role_subject, assignment_subject) pairs: roles being removed that still + # have user assignments; block removal unless force is set. + blocking_assignments: list[tuple[str, str]] = field(default_factory=list) + # Definition-level changes, keyed by kind for reporting. + categories: DefinitionDiff = field(default_factory=DefinitionDiff) + permissions: DefinitionDiff = field(default_factory=DefinitionDiff) + roles: DefinitionDiff = field(default_factory=DefinitionDiff) + grants: DefinitionDiff = field(default_factory=DefinitionDiff) + + @property + def definition_diffs(self) -> list[tuple[str, DefinitionDiff]]: + """The definition diffs paired with their display label.""" + return [ + ("category", self.categories), + ("permission", self.permissions), + ("role", self.roles), + ("role-permission", self.grants), + ] + + @property + def definitions_unchanged(self) -> bool: + """True when no definition of any kind would change.""" + return all(diff.is_empty for _, diff in self.definition_diffs) + + +@dataclass +class ApplyResult: + """Outcome of an apply operation, for reporting.""" + + added: int = 0 + removed: int = 0 + unchanged: bool = False + # The change plan that was applied, so callers can report the same detailed + # policy-row and definition breakdown they would print for a dry run. + plan: ChangePlan | None = None + + +class PolicyRenderer: + """Turns a :class:`CompiledSchema` into Casbin ``p`` rows in memory.""" + + def render(self, schema: CompiledSchema) -> RenderedPolicy: + """Produce one ``p`` row per (role, permission, supported scope). + + Emits definition (``p``) rows only — never ``g`` (assignments) or ``g2`` + (action inheritance). Applies the internal Casbin namespacing here. + Performs no database access. Output order is deterministic. + """ + rows: list[PolicyRow] = [] + for role_id in sorted(schema.roles): + role: RoleDefinition = schema.roles[role_id].definition + for scope in sorted(role.scopes): + for permission in sorted(role.permissions): + rows.append(PolicyRow.from_grant(role.id, permission, scope)) + return RenderedPolicy(rows=rows) + + +class SchemaApplier: + """Compares, then transactionally applies rendered policy to the database.""" + + def __init__(self, enforcer=None): + """Args: + enforcer: Casbin enforcer; defaults to ``AuthzEnforcer.get_enforcer()``. + + The default is resolved lazily inside methods (not at import) to respect + the plugin/settings timing constraint. + """ + self._enforcer = enforcer + + def plan(self, rendered: RenderedPolicy, schema: CompiledSchema | None = None) -> ChangePlan: + """Compute the change report without writing (ADR 0018 §6). + + Compares ``rendered`` against the currently stored ``p`` rows. Flags + roles that would be removed (their subject no longer appears in the + rendered set) that still have user assignments as blocking. + + When ``schema`` is given, the report also covers the definition tables. + Apply syncs definitions even when no policy row changes, so a + metadata-only edit is a real change the operator has to see — without it + the report would say "unchanged" and then rewrite display metadata. + """ + enforcer = self._resolve_enforcer() + + rendered_set = set(rendered.rows) + stored_set = {PolicyRow.from_policy(row) for row in enforcer.get_policy()} + managed_set = self._managed_rows() + + added = sorted(rendered_set - stored_set, key=self._row_sort_key) + # Only rows the loader recorded as its own may be pruned (ADR 0025 §6): + # a stored row that no schema declares stays in place and enforceable, + # unattributed. Intersecting with the managed set is what keeps the + # ownership boundary of ADR 0018 §3 real rather than aspirational. + removed = sorted((stored_set & managed_set) - rendered_set, key=self._row_sort_key) + + rendered_subjects = {row.subject for row in rendered_set} + removed_subjects = {row.subject for row in removed} - rendered_subjects + + blocking = self._find_blocking_assignments(enforcer, removed_subjects) + + definitions = self._diff_definitions(schema) if schema is not None else {} + + plan = ChangePlan( + added_rows=added, + removed_rows=removed, + blocking_assignments=blocking, + **definitions, + ) + plan.unchanged = not added and not removed and plan.definitions_unchanged + return plan + + def apply( + self, + rendered: RenderedPolicy, + schema: CompiledSchema, + *, + force: bool = False, + ) -> ApplyResult: + """Reconcile the stored policy to the rendered set in one transaction. + + Adds rendered rows not already present, removes stale schema-owned rows + no longer rendered, prunes definition/source records the compiled schema + no longer contains, and invalidates the policy cache so the enforcer + reloads. Preserves dynamic roles, user assignments, and ``g2`` rows. + + A role slated for removal that still has user assignments is blocking: + without ``force`` the apply aborts before any write; with ``force`` the + role's stale ``p`` rows and its ``g`` assignment rows are removed + together (ADR 0018 §6). + + If the write fails, the transaction rolls back and the policy cache is + invalidated so the enforcer reloads the last committed state rather than + keeping the uncommitted in-memory rows (ADR 0018 §5). + + Raises: + SchemaApplyError: If the plan has blocking assignments and ``force`` + is False. + """ + plan = self.plan(rendered, schema) + + if plan.blocking_assignments and not force: + details = ", ".join(f"{role} (assigned to {subject})" for role, subject in plan.blocking_assignments) + raise SchemaApplyError( + "Refusing to proceed: static roles with existing assignments would be removed: " + f"{details}. Re-run with force to remove them together with their assignments." + ) + + enforcer = self._resolve_enforcer() + + # Reconcile p rows and sync definition/source records atomically. + # Definitions are synced even when p rows are unchanged so metadata-only + # edits land and pre-existing p rows get adopted on first run. + # + # add_policy/remove_policy mutate the enforcer's in-memory model as well + # as the database, so a rollback would otherwise leave this process + # enforcing rows the database no longer has. Bumping the policy cache + # version on the failure path forces a reload from the committed state, + # keeping Casbin on the last working version (ADR 0018 §5). + try: + with transaction.atomic(): + for row in plan.added_rows: + enforcer.add_policy(*row.as_policy()) + for row in plan.removed_rows: + enforcer.remove_policy(*row.as_policy()) + removed_assignments: list[tuple[str, str, str]] = [] + if force and plan.blocking_assignments: + removed_assignments = self._remove_assignments(enforcer, plan.blocking_assignments) + self._store_sources(schema) + # Emit the audit events only if the transaction commits, mirroring + # unassign_role_from_subject_in_scope, so no audit row is written for + # an assignment removal that gets rolled back. + if removed_assignments: + transaction.on_commit(lambda: self._emit_assignment_deleted(removed_assignments)) + except Exception: + # Runs outside the rolled-back block, so the new version commits. + AuthzEnforcer.invalidate_policy_cache() + logger.exception("Authz schema apply failed; policy cache invalidated to force a reload.") + raise + + changed = bool(plan.added_rows or plan.removed_rows) + if changed: + AuthzEnforcer.invalidate_policy_cache() + logger.info( + "Authz schema apply: added %d p row(s), removed %d p row(s).", + len(plan.added_rows), + len(plan.removed_rows), + ) + else: + logger.info("Authz schema apply: policy rows unchanged; definitions synced.") + + return ApplyResult( + added=len(plan.added_rows), + removed=len(plan.removed_rows), + unchanged=plan.unchanged, + plan=plan, + ) + + # ---- helpers ---------------------------------------------------------- + + def _resolve_enforcer(self): + """Resolve the enforcer, deferring instantiation to honor plugin/settings timing. + + ``AuthzEnforcer.get_enforcer()`` is called (not merely imported) lazily: + it reads ``CASBIN_MODEL``/``CASBIN_DB_ALIAS`` and initializes Casbin, so + it must run after Django settings are configured. Importing the class at + module top is inert — it instantiates nothing. + """ + if self._enforcer is None: + self._enforcer = AuthzEnforcer.get_enforcer() + return self._enforcer + + @staticmethod + def _remove_assignments(enforcer, blocking_assignments: list[tuple[str, str]]) -> list[tuple[str, str, str]]: + """Remove the ``g`` assignment rows for force-removed roles (ADR 0018 §6). + + ``blocking_assignments`` are ``(role_subject, assignment_subject)`` pairs + produced by :meth:`plan`. Each corresponds to a grouping row of the shape + ``[assignment_subject, role_subject, scope]``; the scope segment is + preserved by matching against the live grouping policy so we remove the + exact stored row rather than a reconstructed one. + + Returns the ``(subject, role, scope)`` triples that were removed so the + caller can emit a ``ROLE_ASSIGNMENT_DELETED`` audit event per removal. + """ + targets = set(blocking_assignments) + removed: list[tuple[str, str, str]] = [] + for grouping in list(enforcer.get_grouping_policy()): + if len(grouping) >= 2 and (grouping[1], grouping[0]) in targets: + enforcer.remove_grouping_policy(*grouping) + subject, role = grouping[0], grouping[1] + scope = grouping[2] if len(grouping) >= 3 else "" + removed.append((subject, role, scope)) + return removed + + @staticmethod + def _emit_assignment_deleted(removed_assignments: list[tuple[str, str, str]]) -> None: + """Emit ``ROLE_ASSIGNMENT_DELETED`` for each force-removed assignment. + + Every assignment change must leave an audit trail: the + ``create_audit_record_on_role_assignment_change`` handler turns each event + into a :class:`RoleAssignmentAudit` row, matching the audit behavior of + ``unassign_role_from_subject_in_scope``. + """ + if not removed_assignments: + return + + actor_id = getattr(get_current_user(), "id", None) + for subject, role, scope in removed_assignments: + ROLE_ASSIGNMENT_DELETED.send_event( + role_assignment=RoleAssignmentEventData( + operation=RoleAssignmentAudit.OPERATIONS.deleted, + subject=subject, + role=role, + scope=scope, + actor_id=actor_id, + ) + ) + + @staticmethod + def _find_blocking_assignments(enforcer, removed_subjects: set[str]) -> list[tuple[str, str]]: + """Return (role_subject, assignment_subject) for removed roles still assigned. + + Grouping (``g``) rows have the shape ``[subject, role, scope]``; a role + being removed is blocking if any ``g`` row references it at index 1. + """ + if not removed_subjects: + return [] + blocking: list[tuple[str, str]] = [] + for grouping in enforcer.get_grouping_policy(): + if len(grouping) >= 2 and grouping[1] in removed_subjects: + blocking.append((grouping[1], grouping[0])) + return sorted(set(blocking)) + + @staticmethod + def _row_sort_key(row: PolicyRow) -> tuple[str, str, str, str]: + return (row.subject, row.action, row.scope, row.effect) + + def _diff_definitions(self, schema: CompiledSchema) -> dict[str, DefinitionDiff]: + """Diff the compiled definitions against the stored ones (ADR 0018 §6). + + Returns one :class:`DefinitionDiff` per kind, keyed by the + :class:`ChangePlan` field name. Grants carry no updatable fields, so + they only ever appear as added or removed. + """ + return { + "categories": self._diff_kind( + {cid: compiled.definition for cid, compiled in schema.categories.items()}, + {obj.category_id: obj for obj in schema_models.AuthzPermissionCategory.objects.all()}, + lambda definition, obj: ( + definition.display_name == obj.display_name + and (definition.description or "") == obj.description + and definition.icon == obj.icon + ), + ), + "permissions": self._diff_kind( + {pid: compiled.definition for pid, compiled in schema.permissions.items()}, + { + f"{obj.namespace}.{obj.name}": obj + for obj in schema_models.AuthzPermissionDefinition.objects.select_related("category") + }, + lambda definition, obj: ( + definition.display_name == obj.display_name + and (definition.description or "") == obj.description + and definition.icon == obj.icon + and list(definition.scopes) == list(obj.scopes or []) + and definition.category_id == (obj.category.category_id if obj.category_id else "") + ), + ), + "roles": self._diff_kind( + {rid: compiled.definition for rid, compiled in schema.roles.items()}, + {obj.role_id: obj for obj in schema_models.AuthzRoleDefinition.objects.all()}, + lambda definition, obj: ( + definition.display_name == obj.display_name + and (definition.description or "") == obj.description + and definition.icon == obj.icon + and list(definition.scopes) == list(obj.scopes or []) + and definition.hidden == obj.hidden + ), + ), + "grants": self._diff_kind( + {self._grant_key(rid, perm, scope): None for rid, perm, scope in self._compiled_grants(schema)}, + { + self._grant_key( + grant.role.role_id, f"{grant.permission.namespace}.{grant.permission.name}", grant.scope + ): None + for grant in schema_models.AuthzRolePermission.objects.select_related("role", "permission") + }, + lambda _compiled, _stored: True, + ), + } + + @staticmethod + def _compiled_grants(schema: CompiledSchema): + """Yield every ``(role_id, permission_id, scope)`` the schema grants.""" + for role_id, compiled in schema.roles.items(): + definition = compiled.definition + for scope in definition.scopes: + for permission_id in definition.permissions: + yield role_id, permission_id, scope + + @staticmethod + def _grant_key(role_id: str, permission_id: str, scope: str) -> str: + return f"{role_id} -> {permission_id} @ {scope}" + + @staticmethod + def _diff_kind(compiled: dict, stored: dict, matches) -> DefinitionDiff: + """Split keys into added/updated/removed using ``matches`` for equality.""" + compiled_keys, stored_keys = set(compiled), set(stored) + return DefinitionDiff( + added=sorted(compiled_keys - stored_keys), + updated=sorted(key for key in compiled_keys & stored_keys if not matches(compiled[key], stored[key])), + removed=sorted(stored_keys - compiled_keys), + ) + + @staticmethod + def _managed_rows() -> set[PolicyRow]: + """Return the ``p`` rows the loader previously recorded as schema-owned. + + Ownership lives in the definition tables (ADR 0025 §1): every stored + role-permission grant corresponds one-to-one with a rendered ``p`` row. + Anything outside this set was not produced by the loader — a row from + the legacy policy file, an administrative fix (ADR 0018 §7), or a role + owned by another service — and is therefore not ours to remove. + + Empty on a first deployment, which is what makes adoption safe: nothing + is pruned before the loader has recorded what it owns. + """ + return { + PolicyRow.from_grant( + grant.role.role_id, + f"{grant.permission.namespace}.{grant.permission.name}", + grant.scope, + ) + for grant in schema_models.AuthzRolePermission.objects.select_related("role", "permission") + } + + def _store_sources(self, schema: CompiledSchema) -> None: + """Persist compiled definitions and their sources (ADR 0025). + + Upserts categories, permissions, roles, and each ``(role, permission, + scope)`` grant, linking every definition and grant to its contributing + sources, then prunes any definition rows the compiled schema no longer + contains (see :meth:`_prune_definitions`). Idempotent: re-applying an + identical schema is a no-op. Pre-existing ``p`` rows are adopted because + grants are upserted for every rendered triple regardless of prior + ``p``-row existence. + + Called inside the ``apply`` transaction. + """ + source_cache: dict[tuple[str, str], object] = {} + + def source_obj(record): + key = (record.distribution, record.module) + cached = source_cache.get(key) + if cached is not None: + return cached + obj, _ = schema_models.AuthzSchemaSource.objects.update_or_create( + distribution=record.distribution, + module=record.module, + defaults={ + "distribution_version": record.distribution_version, + "resource_path": record.resource_path, + "content_digest": record.content_digest, + "schema_version": record.schema_version, + }, + ) + source_cache[key] = obj + return obj + + # Categories. + category_objs: dict[str, object] = {} + for cid, compiled in schema.categories.items(): + definition = compiled.definition + obj, _ = schema_models.AuthzPermissionCategory.objects.update_or_create( + category_id=definition.id, + defaults={ + "display_name": definition.display_name, + "description": definition.description or "", + "icon": definition.icon, + }, + ) + category_objs[cid] = obj + for record in compiled.sources: + schema_models.AuthzCategorySource.objects.update_or_create( + category=obj, source=source_obj(record), defaults={"origin_kind": schema_models.OriginKind.BASE} + ) + + # Permissions. + permission_objs: dict[str, object] = {} + for pid, compiled in schema.permissions.items(): + definition = compiled.definition + obj, _ = schema_models.AuthzPermissionDefinition.objects.update_or_create( + namespace=definition.namespace, + name=definition.name, + defaults={ + "display_name": definition.display_name, + "description": definition.description or "", + "category": category_objs.get(definition.category_id), + "scopes": list(definition.scopes), + "icon": definition.icon, + }, + ) + permission_objs[pid] = obj + for record in compiled.sources: + schema_models.AuthzPermissionSource.objects.update_or_create( + permission=obj, source=source_obj(record), defaults={"origin_kind": schema_models.OriginKind.BASE} + ) + + # Roles. + role_objs: dict[str, object] = {} + for rid, compiled in schema.roles.items(): + definition = compiled.definition + obj, _ = schema_models.AuthzRoleDefinition.objects.update_or_create( + role_id=definition.id, + defaults={ + "display_name": definition.display_name, + "description": definition.description or "", + "scopes": list(definition.scopes), + "icon": definition.icon, + "hidden": definition.hidden, + }, + ) + role_objs[rid] = obj + for record in compiled.sources: + schema_models.AuthzRoleSource.objects.update_or_create( + role=obj, source=source_obj(record), defaults={"origin_kind": schema_models.OriginKind.BASE} + ) + + # Role-permission grants (one per rendered role/permission/scope triple). + # Track the grant keys the schema still contains so stale grants can be + # pruned below. + live_grant_ids: set[int] = set() + for rid, compiled in schema.roles.items(): + role_obj = role_objs[rid] + definition = compiled.definition + for scope in definition.scopes: + for perm_id in definition.permissions: + permission_obj = permission_objs.get(perm_id) + if permission_obj is None: + continue # validated away in practice; skip defensively + grant, _ = schema_models.AuthzRolePermission.objects.update_or_create( + role=role_obj, permission=permission_obj, scope=scope + ) + live_grant_ids.add(grant.pk) + for rel in schema.role_permission_sources.get((rid, perm_id), []): + schema_models.AuthzRolePermissionSource.objects.update_or_create( + role_permission=grant, + source=source_obj(rel.source), + defaults={"origin_kind": rel.origin_kind, "priority": rel.priority}, + ) + + self._prune_definitions(schema, live_grant_ids) + + logger.info( + "Authz schema apply: persisted %d role(s), %d permission(s), %d category(ies).", + len(schema.roles), + len(schema.permissions), + len(schema.categories), + ) + + @staticmethod + def _prune_definitions(schema: CompiledSchema, live_grant_ids: set[int]) -> None: + """Delete definition/source rows the compiled schema no longer contains. + + Removes stale role-permission grants, roles, permissions, and categories + so the definition tables match the compiled schema (ADR 0018 §2). Source + link rows and per-source records cascade via their foreign keys; the + shared :class:`AuthzSchemaSource` rows are left in place because they may + still back other definitions and carry no access on their own. + + Ordering matters: grants first (they reference roles and permissions), + then roles and permissions, then categories. + """ + # Stale role-permission grants: any grant not re-created this run. + schema_models.AuthzRolePermission.objects.exclude(pk__in=live_grant_ids).delete() + + live_role_ids = {compiled.definition.id for compiled in schema.roles.values()} + schema_models.AuthzRoleDefinition.objects.exclude(role_id__in=live_role_ids).delete() + + live_permission_keys = { + (compiled.definition.namespace, compiled.definition.name) for compiled in schema.permissions.values() + } + for permission_obj in schema_models.AuthzPermissionDefinition.objects.all(): + if (permission_obj.namespace, permission_obj.name) not in live_permission_keys: + permission_obj.delete() + + live_category_ids = {compiled.definition.id for compiled in schema.categories.values()} + schema_models.AuthzPermissionCategory.objects.exclude(category_id__in=live_category_ids).delete() diff --git a/src/openedx_authz/engine/schema/compilation.py b/src/openedx_authz/engine/schema/compilation.py new file mode 100644 index 00000000..7b2d41fa --- /dev/null +++ b/src/openedx_authz/engine/schema/compilation.py @@ -0,0 +1,530 @@ +"""Resolve documents into one set of static definitions (the ``compile`` step). + +Compilation (ADR 0018 §1) merges base definitions across all documents and +applies ``role_extensions`` per ADR 0023: + + * Extensions resolve only after every role and permission is loaded. + * An extension changes only the fields it includes; absent fields keep + their current value; it cannot change a role ID. + * Different fields from different contributions combine. + * ``priority`` resolves conflicts on the same metadata field or the same + permission (higher wins). Equal priority with disagreeing values raises + :class:`SchemaCompileError` so deployment stops before the database + changes. + * Adding a permission the role already has, or removing one it lacks, is a + no-op logged as a warning. + +Every resulting :class:`CompiledDefinition` retains all contributing +:class:`SourceRecord` values, and each role-permission grant is attributed at +the (role, permission) grain with its origin (base vs extension) for ADR 0025 +source tracking. Output is deterministic regardless of discovery order. No +Casbin/Django imports. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, replace +from enum import Enum + +from openedx_authz.constants import SchemaOriginKind +from openedx_authz.engine.schema.exceptions import SchemaCompileError +from openedx_authz.engine.schema.types import ( + CompiledDefinition, + CompiledSchema, + RolePermissionSource, + SchemaDocument, + SourceRecord, +) + +logger = logging.getLogger(__name__) + + +class RoleMetadataField(str, Enum): + """Metadata fields a ``RoleExtension`` may replace on a role (ADR 0023).""" + + DISPLAY_NAME = "display_name" + DESCRIPTION = "description" + ICON = "icon" + HIDDEN = "hidden" + + +class DefinitionKind(Enum): + """A family of base definitions compiled from schema documents. + + ``attr`` is the :class:`SchemaDocument` field holding this family's + definitions (populated by the loader from the YAML blocks). ``label`` is the + singular noun used for this family in operator-facing error and warning + messages. + """ + + CATEGORY = ("categories", "category") + PERMISSION = ("permissions", "permission") + ROLE = ("roles", "role") + + def __init__(self, attr: str, label: str) -> None: + self.attr = attr + self.label = label + + +@dataclass(frozen=True) +class _Tracked: + """A base definition plus the sources and priority that produced it. + + Immutable: resolution steps return a new ``_Tracked`` via + :func:`dataclasses.replace` rather than mutating one in place. + """ + + definition: object + sources: tuple[SourceRecord, ...] = () + priority: int = 0 + + +class SchemaCompiler: + """Merges validated documents into a :class:`CompiledSchema`.""" + + def compile(self, documents: list[SchemaDocument]) -> CompiledSchema: + """Resolve categories, permissions, roles, and extensions. + + Assumes ``documents`` already passed validation. + + Raises: + SchemaCompileError: On an unresolvable equal-priority conflict. + """ + categories = self._collect(documents, DefinitionKind.CATEGORY, key=lambda c: c.id) + permissions = self._collect(documents, DefinitionKind.PERMISSION, key=lambda p: p.identifier) + roles = self._collect(documents, DefinitionKind.ROLE, key=lambda r: r.id) + + resolved_roles, role_permission_sources = self._resolve_roles_and_provenance(roles, documents) + + return CompiledSchema( + categories=self._finalize(categories), + permissions=self._finalize(permissions), + roles=self._finalize(resolved_roles), + role_permission_sources=role_permission_sources, + ) + + def _collect(self, documents: list[SchemaDocument], kind: DefinitionKind, key) -> dict[str, _Tracked]: + """Gather base definitions keyed by identifier, resolving by priority. + + Higher priority wins on conflict; equal priority with differing content + raises; identical duplicates merge their sources. + """ + tracked: dict[str, _Tracked] = {} + for document in documents: + for definition in getattr(document, kind.attr): + identifier = key(definition) + existing = tracked.get(identifier) + if existing is None: + tracked[identifier] = _Tracked( + definition=definition, + sources=(document.source,), + priority=document.priority, + ) + continue + + tracked[identifier] = self._resolve_priority( + kind.label, + identifier, + existing=existing, + incoming=_Tracked( + definition=definition, + sources=(document.source,), + priority=document.priority, + ), + ) + return tracked + + def _resolve_priority( + self, + kind: str, + identifier: str, + *, + existing: _Tracked, + incoming: _Tracked, + ) -> _Tracked: + """Resolve two competing definitions for one identifier by priority. + + Single responsibility: decide which of two :class:`_Tracked` entries for + the same identifier survives. + + * Identical definitions merge their sources (both files contributed the + same thing). + * Otherwise higher priority wins; the loser is warned about (ADR 0017 + §4) so a valid-but-ineffective file does not look like it took effect. + * Equal priority with disagreeing definitions is unresolvable and raises + :class:`SchemaCompileError` so deployment stops before the database + changes. + + Each ``_Tracked`` carries a single source; ``existing`` and ``incoming`` + are symmetric, so this also serves conflicts between extension + contributions where only priority (not load order) decides the winner. + """ + if existing.definition == incoming.definition: + return replace(existing, sources=existing.sources + incoming.sources) + if incoming.priority == existing.priority: + raise SchemaCompileError( + f"Conflicting {kind} definition for {identifier!r} at equal priority " + f"{incoming.priority} " + f"({existing.sources[0].source_id} vs {incoming.sources[0].source_id})." + ) + winner, loser = (incoming, existing) if incoming.priority > existing.priority else (existing, incoming) + self._warn_discarded( + kind, + identifier, + loser=loser.sources[0], + loser_priority=loser.priority, + winner=winner.sources[0], + winner_priority=winner.priority, + ) + return winner + + @staticmethod + def _warn_discarded( + kind: str, + identifier: str, + *, + loser: SourceRecord, + loser_priority: int, + winner: SourceRecord, + winner_priority: int, + ) -> None: + """Report a contribution that lost to a higher-priority one. + + Priority silently picking a winner is the behavior operators find hardest + to debug: the losing file is valid, was loaded, and simply has no effect. + ADR 0017 §4 requires warning about exactly this. + """ + logger.warning( + "authz schema: %s %r from %s (priority %s) has no effect; %s (priority %s) takes precedence.", + kind, + identifier, + loser.source_id, + loser_priority, + winner.source_id, + winner_priority, + ) + + def _resolve_roles_and_provenance( + self, roles: dict[str, _Tracked], documents: list[SchemaDocument] + ) -> tuple[dict[str, _Tracked], dict[tuple[str, str], list[RolePermissionSource]]]: + """Apply extensions and build per-(role, permission) provenance. + + Seeds base provenance from each role's own definition, then folds in + ``role_extensions`` (metadata replacement + permission add/remove), + honoring priority. Returns the resolved roles (a new mapping; inputs are + left untouched) alongside the relationship provenance map. + """ + metadata_changes, permission_changes = self._gather_extension_changes(roles, documents) + resolved_roles: dict[str, _Tracked] = {} + role_permission_sources: dict[tuple[str, str], list[RolePermissionSource]] = {} + + for role_id, tracked in roles.items(): + base_sources = tracked.sources + base_priority = tracked.priority + + tracked = self._apply_metadata_changes(role_id, tracked, metadata_changes.get(role_id, {})) + + tracked, provenance = self._apply_permission_changes( + role_id, + tracked, + base_sources, + base_priority, + permission_changes.get(role_id), + ) + + resolved_roles[role_id] = tracked + for perm, sources in provenance.items(): + role_permission_sources[(role_id, perm)] = sources + + return resolved_roles, role_permission_sources + + def _apply_metadata_changes( + self, + role_id: str, + tracked: _Tracked, + metadata_changes_for_role: dict[str, list[tuple[object, int, SourceRecord]]], + ) -> _Tracked: + """Return the role with winning metadata applied and its sources recorded. + + Single responsibility: resolve the winning metadata values for this role + and produce a new :class:`_Tracked` carrying the updated definition and + the sources that contributed them. Returns ``tracked`` unchanged when the + role has no metadata extensions. + """ + if not metadata_changes_for_role: + return tracked + new_values, contributing_sources = self._resolve_metadata(role_id, metadata_changes_for_role) + merged_sources = tracked.sources + tuple(src for src in contributing_sources if src not in tracked.sources) + return replace( + tracked, + definition=replace(tracked.definition, **new_values), + sources=merged_sources, + ) + + @staticmethod + def _seed_base_provenance( + permissions: tuple[str, ...], + base_sources: tuple[SourceRecord, ...], + base_priority: int, + ) -> dict[str, list[RolePermissionSource]]: + """Attribute each of a role's declared permissions to its base sources. + + Every permission the role declares in its own definition is a ``BASE`` + grant from each contributing source. Extension-driven add/remove layers + on top of this seed. + """ + return { + perm: [RolePermissionSource(src, SchemaOriginKind.BASE, base_priority) for src in base_sources] + for perm in permissions + } + + def _apply_permission_changes( + self, + role_id: str, + tracked: _Tracked, + base_sources: tuple[SourceRecord, ...], + base_priority: int, + permission_changes_for_role: dict[str, list[tuple[str, int, SourceRecord]]] | None, + ) -> tuple[_Tracked, dict[str, list[RolePermissionSource]]]: + """Return the role with permission changes applied and its provenance. + + Single responsibility: own the per-permission provenance for this role. + Seeds base provenance from the role's declared permissions, then, if the + role has permission extensions, resolves the final permission set, + produces a new :class:`_Tracked`, and reflects the add/remove in + provenance. Returns ``(tracked, base_provenance)`` unchanged when the + role has no permission extensions. + """ + base_provenance = self._seed_base_provenance(tracked.definition.permissions, base_sources, base_priority) + if not permission_changes_for_role or not ( + permission_changes_for_role["add"] or permission_changes_for_role["remove"] + ): + return tracked, base_provenance + final_perms, provenance = self._resolve_permissions( + role_id, + tracked.definition.permissions, + base_sources, + base_priority, + permission_changes_for_role, + ) + tracked = replace(tracked, definition=replace(tracked.definition, permissions=final_perms)) + return tracked, provenance + + def _gather_extension_changes(self, roles: dict[str, _Tracked], documents: list[SchemaDocument]): + """Collect per-role metadata and permission changes from all extensions. + + Thin orchestrator over the two independent concerns; see + :meth:`_gather_metadata_changes` and :meth:`_gather_permission_changes`. + """ + return ( + self._gather_metadata_changes(roles, documents), + self._gather_permission_changes(roles, documents), + ) + + @staticmethod + def _extensions_for_known_roles(roles: dict[str, _Tracked], documents: list[SchemaDocument]): + """Yield ``(document, extension)`` for extensions targeting a known role. + + Extensions naming an unknown role are skipped defensively; validation is + expected to have already errored on them. + """ + for document in documents: + for extension in document.role_extensions: + if extension.role_id in roles: + yield document, extension + + def _gather_metadata_changes( + self, roles: dict[str, _Tracked], documents: list[SchemaDocument] + ) -> dict[str, dict[str, list[tuple[object, int, SourceRecord]]]]: + """Collect per-role metadata field contributions from all extensions. + + Entries carry the full :class:`SourceRecord` and priority so provenance + and conflict resolution have everything they need. + """ + metadata_changes: dict[str, dict[str, list[tuple[object, int, SourceRecord]]]] = {} + for document, extension in self._extensions_for_known_roles(roles, documents): + metadata_changes_for_role = metadata_changes.setdefault(extension.role_id, {}) + for field in RoleMetadataField: + field_name = field.value + value = getattr(extension, field_name) + if value is not None: + metadata_changes_for_role.setdefault(field_name, []).append( + (value, document.priority, document.source) + ) + return metadata_changes + + def _gather_permission_changes( + self, roles: dict[str, _Tracked], documents: list[SchemaDocument] + ) -> dict[str, dict[str, list[tuple[str, int, SourceRecord]]]]: + """Collect per-role permission add/remove contributions from all extensions. + + Entries carry the full :class:`SourceRecord` and priority so provenance + and conflict resolution have everything they need. + """ + permission_changes: dict[str, dict[str, list[tuple[str, int, SourceRecord]]]] = {} + for document, extension in self._extensions_for_known_roles(roles, documents): + permission_changes_for_role = permission_changes.setdefault(extension.role_id, {"add": [], "remove": []}) + for perm in extension.add_permissions: + permission_changes_for_role["add"].append((perm, document.priority, document.source)) + for perm in extension.remove_permissions: + permission_changes_for_role["remove"].append((perm, document.priority, document.source)) + return permission_changes + + def _resolve_contributions( + self, + identifier: str, + entries: list[tuple[object, int, SourceRecord]], + *, + loser_kind, + on_tie, + ) -> tuple[object, int, list[SourceRecord]]: + """Pick the winning value among competing extension contributions. + + The batch counterpart to :meth:`_resolve_priority`: where that method + decides between two base definitions pairwise, this decides among any + number of ``(value, priority, source)`` contributions to the same + extension field or permission. + + The highest priority wins. Every contribution in the top-priority group + must agree; if they do not, ``on_tie(top_values)`` builds the message + for a :class:`SchemaCompileError` (the caller knows how to describe its + own conflict). Lower-priority contributions are warned about so a + valid-but-ineffective file is not mistaken for one that took effect + (ADR 0017 §4); ``loser_kind(value)`` labels each loser so the warning + can name what that contribution tried to do. + + Returns the winning value, the winning priority, and every source in the + winning group (for provenance). + """ + max_priority = max(priority for _, priority, _ in entries) + top_values = {value for value, priority, _ in entries if priority == max_priority} + if len(top_values) > 1: + raise SchemaCompileError(on_tie(top_values)) + + winner_value = next(iter(top_values)) + winning_sources = [src for value, priority, src in entries if priority == max_priority] + for value, priority, src in entries: + if priority < max_priority: + self._warn_discarded( + loser_kind(value), + identifier, + loser=src, + loser_priority=priority, + winner=winning_sources[0], + winner_priority=max_priority, + ) + return winner_value, max_priority, winning_sources + + def _resolve_metadata(self, role_id: str, metadata_changes: dict[str, list[tuple[object, int, SourceRecord]]]): + """Pick winning metadata values by priority; error on equal-priority ties.""" + new_values: dict[str, object] = {} + contributing: set[SourceRecord] = set() + for field_name, entries in metadata_changes.items(): + value, _, winning_sources = self._resolve_contributions( + role_id, + entries, + loser_kind=lambda _value, _field=field_name: f"role_extension {_field}", + on_tie=lambda top, _field=field_name, _entries=entries: ( + f"Conflicting {_field!r} for role {role_id!r} at equal priority " + f"{max(p for _, p, _ in _entries)}: {sorted(map(str, top))}." + ), + ) + new_values[field_name] = value + contributing.update(winning_sources) + return new_values, contributing + + def _resolve_permissions( + self, + role_id: str, + base: tuple[str, ...], + base_sources: tuple[SourceRecord, ...], + base_priority: int, + permission_changes: dict[str, list[tuple[str, int, SourceRecord]]], + ): + """Apply add/remove per permission, returning (final_perms, provenance). + + Add-vs-remove conflicts resolve by priority; equal priority raises. + Provenance keeps base attribution and appends extension attribution for + added permissions. + """ + current = set(base) + provenance = self._seed_base_provenance(base, base_sources, base_priority) + + # Regroup from action-keyed to permission-keyed so every contribution + # touching one permission (both adds and removes, across extensions) is + # resolved together: priority can pick a winner and an equal-priority + # add-vs-remove is caught as a conflict. + actions: dict[str, list[tuple[str, int, SourceRecord]]] = {} + for perm, priority, src in permission_changes["add"]: + actions.setdefault(perm, []).append(("add", priority, src)) + for perm, priority, src in permission_changes["remove"]: + actions.setdefault(perm, []).append(("remove", priority, src)) + + for perm, entries in actions.items(): + action, max_priority, winning_sources = self._resolve_contributions( + role_id, + entries, + loser_kind=lambda act, _perm=perm: f"role_extension {act} of {_perm!r} on role", + on_tie=lambda _top, _perm=perm, _entries=entries: ( + f"Conflicting add/remove for permission {_perm!r} on role {role_id!r} " + f"at equal priority {max(p for _, p, _ in _entries)}." + ), + ) + + if action == "add": + extension_sources = [ + RolePermissionSource(src, SchemaOriginKind.EXTENSION, max_priority) for src in winning_sources + ] + self._apply_added_permission(role_id, perm, current, provenance, extension_sources) + else: # remove + self._apply_removed_permission(role_id, perm, current, provenance) + + return tuple(sorted(current)), provenance + + @staticmethod + def _apply_added_permission( + role_id: str, + perm: str, + current: set[str], + provenance: dict[str, list[RolePermissionSource]], + extension_sources: list[RolePermissionSource], + ) -> None: + """Grant ``perm`` to the role, attributing it to ``extension_sources``. + + Mutates ``current`` and ``provenance`` in place. Adding a permission the + role already has is a no-op, logged as a warning (ADR 0023 §3). + """ + if perm in current: + logger.warning("role_extension adds %r already on role %r; no-op.", perm, role_id) + current.add(perm) + provenance.setdefault(perm, []) + provenance[perm].extend(extension_sources) + + @staticmethod + def _apply_removed_permission( + role_id: str, + perm: str, + current: set[str], + provenance: dict[str, list[RolePermissionSource]], + ) -> None: + """Revoke ``perm`` from the role and drop its provenance. + + Mutates ``current`` and ``provenance`` in place. Removing a permission + the role does not have is a no-op, logged as a warning (ADR 0023 §3). + """ + if perm not in current: + logger.warning("role_extension removes %r not on role %r; no-op.", perm, role_id) + current.discard(perm) + provenance.pop(perm, None) + + def _finalize(self, tracked: dict[str, _Tracked]) -> dict[str, CompiledDefinition]: + """Turn tracked definitions into CompiledDefinition entries.""" + return { + identifier: CompiledDefinition( + key=identifier, + definition=entry.definition, + sources=entry.sources, + ) + for identifier, entry in tracked.items() + } 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/pipeline.py b/src/openedx_authz/engine/schema/pipeline.py new file mode 100644 index 00000000..5d437d8e --- /dev/null +++ b/src/openedx_authz/engine/schema/pipeline.py @@ -0,0 +1,110 @@ +"""End-to-end orchestration of the authz schema lifecycle (ADR 0018). + +:class:`SchemaPipeline` wires the steps together: + + discover -> load -> validate -> compile -> render -> (plan) -> apply + +The Casbin-free steps (discover..compile) live in :mod:`openedx_authz.engine.schema`; +render/apply live in :mod:`openedx_authz.engine.renderer`. This orchestrator is +the single entry point used by the deployment management command and by tests. + +Deployment runs discover-through-apply before the application serves traffic +(ADR 0018 §2). CI/local runs may stop after ``plan`` for a dry run, or pass +explicit resources. +""" + +from __future__ import annotations + +import logging + +from openedx_authz.engine.renderer import ( + ApplyResult, + ChangePlan, + PolicyRenderer, + SchemaApplier, +) +from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.engine.schema.discovery import SchemaDiscovery +from openedx_authz.engine.schema.exceptions import SchemaValidationError +from openedx_authz.engine.schema.loading import SchemaLoader +from openedx_authz.engine.schema.types import CompiledSchema +from openedx_authz.engine.schema.validation import SchemaValidator, ValidationIssue + +logger = logging.getLogger(__name__) + + +class SchemaPipeline: + """Runs the schema lifecycle from discovery through apply. + + Components are injected for testability; each defaults to its standard + implementation. + """ + + def __init__( + self, + *, + discovery: SchemaDiscovery | None = None, + loader: SchemaLoader | None = None, + validator: SchemaValidator | None = None, + compiler: SchemaCompiler | None = None, + renderer: PolicyRenderer | None = None, + applier: SchemaApplier | None = None, + ): + self._discovery = discovery or SchemaDiscovery() + self._loader = loader or SchemaLoader() + self._validator = validator or SchemaValidator() + self._compiler = compiler or SchemaCompiler() + self._renderer = renderer or PolicyRenderer() + self._applier = applier or SchemaApplier() + + def compile(self) -> CompiledSchema: + """Run discover -> load -> validate -> compile and return the result. + + Validation gates twice: once on the loaded documents, then again on the + compiled schema, because extensions and priority resolution can only be + checked after they are applied (ADR 0017 §4). + + Raises: + SchemaValidationError: If either validation pass finds error-level + issues. + SchemaCompileError: On an unresolvable conflict. + """ + resources = self._discovery.discover() + documents = self._loader.load(resources) + + self._gate(self._validator.validate(documents)) + schema = self._compiler.compile(documents) + self._gate(self._validator.validate_compiled(schema)) + + return schema + + def _gate(self, issues: list[ValidationIssue]) -> None: + """Report every issue, then stop the run if any is error-level. + + Warnings are logged and the run continues; errors are logged and raised + together so the deployment report lists all of them at once. + """ + for issue in issues: + log = logger.error if issue.is_error else logger.warning + log("authz schema %s: %s [%s]", issue.level, issue.message, issue.source_id or "-") + if self._validator.has_errors(issues): + raise SchemaValidationError([i for i in issues if i.is_error]) + + def plan(self) -> ChangePlan: + """Run through render and produce the change report without writing. + + Used for dry-run / CI review (ADR 0018 §6). + """ + schema = self.compile() + rendered = self._renderer.render(schema) + return self._applier.plan(rendered, schema) + + def apply(self, *, force: bool = False) -> ApplyResult: + """Run the full lifecycle and persist the result transactionally. + + Args: + force: Allow removal of roles that still have assignments (ADR 0018). + """ + schema = self.compile() + rendered = self._renderer.render(schema) + return self._applier.apply(rendered, schema, force=force) diff --git a/src/openedx_authz/engine/schema/types/__init__.py b/src/openedx_authz/engine/schema/types/__init__.py index 22c4456e..6f3bfc59 100644 --- a/src/openedx_authz/engine/schema/types/__init__.py +++ b/src/openedx_authz/engine/schema/types/__init__.py @@ -26,7 +26,7 @@ from openedx_authz.engine.schema.types.compilation import ( CompiledDefinition, CompiledSchema, - RelationshipSource, + RolePermissionSource, ) from openedx_authz.engine.schema.types.definitions import ( PermissionCategory, @@ -49,7 +49,7 @@ "SourceRecord", "SchemaDocument", # compilation - "RelationshipSource", + "RolePermissionSource", "CompiledDefinition", "CompiledSchema", ] diff --git a/src/openedx_authz/engine/schema/types/compilation.py b/src/openedx_authz/engine/schema/types/compilation.py index 11902ff4..e0f23708 100644 --- a/src/openedx_authz/engine/schema/types/compilation.py +++ b/src/openedx_authz/engine/schema/types/compilation.py @@ -21,7 +21,7 @@ @dataclass(frozen=True) -class RelationshipSource: +class RolePermissionSource: """Provenance of a single role-permission grant (ADR 0025). Attributes: @@ -69,7 +69,7 @@ class CompiledSchema: roles: dict[str, CompiledDefinition] = field(default_factory=dict) # Provenance of each role-permission grant, keyed by (role_id, permission_id). # Populated by the compiler; consumed when persisting sources (ADR 0025). - role_permission_sources: dict[tuple[str, str], list[RelationshipSource]] = field(default_factory=dict) + role_permission_sources: dict[tuple[str, str], list[RolePermissionSource]] = field(default_factory=dict) def role_permission_pairs(self) -> list[tuple[str, str]]: """Return ``(role_id, permission_identifier)`` pairs for every role. diff --git a/src/openedx_authz/engine/schema/validation.py b/src/openedx_authz/engine/schema/validation.py new file mode 100644 index 00000000..6a36c056 --- /dev/null +++ b/src/openedx_authz/engine/schema/validation.py @@ -0,0 +1,457 @@ +"""Validate schema documents individually and as a whole (the ``validate`` step). + +Rules come from ADR 0017 §4 and the field reference: + +Per-document checks: + * ``schema_version`` is a supported, quoted ``major.minor`` value. + * ``namespace``, ``name``, category ``id``, role ``id`` match + :data:`IDENTIFIER_RE` (lowercase snake_case, begins with a letter). + * Casbin forms (``act^...``, ``role^...``) are rejected as identifiers. + * Required fields are present. + * ``scopes`` are non-empty and look like scope namespaces (hyphens allowed, + e.g. ``course-v1``); they are exempt from the identifier regex. + * ``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 0026). + +Whole-set checks (after all documents load): + * Every permission ``category`` references an existing category. + * Every role/extension permission references an existing permission. + * A role's ``scopes`` are supported by each of its permissions. + * ``role_extensions`` target an existing role (ADR 0023). + * Conflicting duplicate base definitions fail; identical duplicates warn. + +Post-compile checks (after extensions and priority are resolved): + * A role's ``scopes`` are supported by every permission it *ends up* with, + including permissions contributed by ``role_extensions``. + * The resolved permissions and categories still exist. + +Validation collects issues rather than raising on the first problem, so the +deployment report can list every error and warning at once. No Casbin/Django +imports. +""" + +from __future__ import annotations + +import re +from collections.abc import Container +from dataclasses import dataclass +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, +) + +_IDENTIFIER = r"[a-z][a-z0-9_]*" +IDENTIFIER_RE = re.compile(rf"^{_IDENTIFIER}$") +# A complete permission ID joins ``namespace`` and ``name`` with a single +# period, each part being a bare identifier. Mirrors the ``permission_id`` +# ``$def`` in ``schema/authz-schema-v1.json``. +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^") + + +class IssueLevel(StrEnum): + """Severity of a :class:`ValidationIssue`. + + Members: + ERROR: Blocks deployment. + WARNING: Reported only, does not block deployment. + """ + + ERROR = "error" + WARNING = "warning" + + +@dataclass(frozen=True) +class ValidationIssue: + """A single validation finding. + + Attributes: + level: :attr:`IssueLevel.ERROR` (blocks deployment) or + :attr:`IssueLevel.WARNING` (reported only). + message: Human-readable description. + source_id: The contributing source, when the issue is file-specific. + """ + + level: IssueLevel + message: str + source_id: str | None = None + + @property + def is_error(self) -> bool: + return self.level == IssueLevel.ERROR + + +class SchemaValidator: + """Runs per-document and whole-set validation.""" + + SUPPORTED_SCHEMA_VERSIONS = frozenset({"1.0"}) + + def validate(self, documents: list[SchemaDocument]) -> list[ValidationIssue]: + """Run per-document then whole-set validation, returning all issues.""" + issues: list[ValidationIssue] = [] + for document in documents: + issues.extend(self.validate_document(document)) + issues.extend(self.validate_set(documents)) + return issues + + def validate_compiled(self, schema: CompiledSchema) -> list[ValidationIssue]: + """Re-check the resolved schema after extensions and priority are applied. + + Document-level validation only sees base declarations, so a + ``role_extension`` that adds a permission is checked for existence but + never for scope compatibility. Without this pass an extension can grant + a permission in a scope the permission does not support, and the + renderer still emits an enforceable ``p`` row for it (ADR 0017 §4, + ADR 0023 §3). + + The compiled role's permission set is the one that becomes Casbin ``p`` + rows, so it is the set that has to satisfy the scope rule. + """ + issues: list[ValidationIssue] = [] + + for role_id, compiled_role in schema.roles.items(): + role = compiled_role.definition + for perm_id in role.permissions: + compiled_permission = schema.permissions.get(perm_id) + permission_scopes = compiled_permission.definition.scopes if compiled_permission else None + issues.extend( + self._check_role_permission_scope( + role_id, + perm_id, + role.scopes, + permission_scopes, + self._role_permission_source_id(schema, role_id, perm_id), + missing_verb="resolves to", + ) + ) + + for perm_id, compiled_permission in schema.permissions.items(): + sources = compiled_permission.sources + issues.extend( + self._check_permission_category( + perm_id, + compiled_permission.definition.category_id, + schema.categories, + sources[0].source_id if sources else None, + ) + ) + + return issues + + @staticmethod + def has_errors(issues: list[ValidationIssue]) -> bool: + """True if any issue is error-level.""" + return any(issue.is_error for issue in issues) + + def validate_document(self, document: SchemaDocument) -> list[ValidationIssue]: + """Per-file checks that need no cross-file context.""" + issues: list[ValidationIssue] = [] + sid = document.source.source_id + + if document.source.schema_version not in self.SUPPORTED_SCHEMA_VERSIONS: + issues.append( + ValidationIssue( + IssueLevel.ERROR, + f"Unsupported schema_version {document.source.schema_version!r}; " + f"supported: {sorted(self.SUPPORTED_SCHEMA_VERSIONS)}.", + sid, + ) + ) + + for category in document.categories: + issues.extend(self._check_identifier(category.id, "category id", sid)) + issues.extend(self._require(category.id, "category id", sid)) + 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)) + + return issues + + def validate_set(self, documents: list[SchemaDocument]) -> list[ValidationIssue]: + """Whole-set checks across all loaded documents.""" + issues: list[ValidationIssue] = [] + + category_ids: set[str] = set() + permission_index: dict[str, tuple[str, ...]] = {} # id -> scopes + role_ids: set[str] = set() + + issues.extend(self._collect_and_check_duplicates(documents, category_ids, permission_index, role_ids)) + + # Reference integrity: permission categories exist. + for document in documents: + sid = document.source.source_id + for permission in document.permissions: + issues.extend( + self._check_permission_category(permission.identifier, permission.category_id, category_ids, sid) + ) + + # Role permissions exist, and role scopes are supported by each permission. + for role in document.roles: + for perm_id in role.permissions: + issues.extend( + self._check_role_permission_scope( + role.id, + perm_id, + role.scopes, + permission_index.get(perm_id), + sid, + missing_verb="references", + ) + ) + + # Extensions target existing roles and reference existing permissions. + for extension in document.role_extensions: + if extension.role_id not in role_ids: + issues.append( + ValidationIssue( + IssueLevel.ERROR, + f"role_extension targets unknown role {extension.role_id!r}.", + sid, + ) + ) + for perm_id in (*extension.add_permissions, *extension.remove_permissions): + if perm_id not in permission_index: + issues.append( + ValidationIssue( + IssueLevel.ERROR, + f"role_extension {extension.role_id} references unknown permission {perm_id!r}.", + sid, + ) + ) + + return issues + + def _collect_and_check_duplicates( + self, + documents: list[SchemaDocument], + category_ids: set[str], + permission_index: dict[str, tuple[str, ...]], + role_ids: set[str], + ) -> list[ValidationIssue]: + """Populate the id indexes and flag conflicting/identical duplicates.""" + issues: list[ValidationIssue] = [] + categories: dict[str, object] = {} + permissions: dict[str, object] = {} + roles: dict[str, object] = {} + + for document in documents: + sid = document.source.source_id + for category in document.categories: + issues.extend(self._register(categories, category.id, category, "category", sid)) + category_ids.add(category.id) + for permission in document.permissions: + issues.extend(self._register(permissions, permission.identifier, permission, "permission", sid)) + permission_index[permission.identifier] = permission.scopes + for role in document.roles: + issues.extend(self._register(roles, role.id, role, "role", sid)) + role_ids.add(role.id) + return issues + + @staticmethod + def _register(index: dict, key: str, value, kind: str, sid: str) -> list[ValidationIssue]: + """Record a base definition, flagging duplicates. + + Identical duplicate → warning; conflicting duplicate → error. + """ + if key not in index: + index[key] = value + return [] + if index[key] == value: + return [ValidationIssue(IssueLevel.WARNING, f"Duplicate identical {kind} {key!r}.", sid)] + return [ValidationIssue(IssueLevel.ERROR, f"Conflicting {kind} definition for {key!r}.", sid)] + + def _check_identifier(self, value: str, label: str, sid: str) -> list[ValidationIssue]: + """Validate a single identifier is lowercase snake_case and not a Casbin form.""" + if not value: + return [] # emptiness handled by _require where relevant + if any(value.startswith(prefix) for prefix in CASBIN_INTERNAL_PREFIXES): + return [ValidationIssue(IssueLevel.ERROR, f"{label} {value!r} uses an internal Casbin form.", sid)] + if not IDENTIFIER_RE.match(value): + return [ + ValidationIssue( + IssueLevel.ERROR, + f"{label} {value!r} must match {IDENTIFIER_RE.pattern} (lowercase snake_case).", + sid, + ) + ] + return [] + + def _check_permission_id(self, value: str, context: str, sid: str) -> list[ValidationIssue]: + """A complete permission id is ``namespace.name`` with both parts valid. + + The shape is checked against :data:`PERMISSION_ID_RE`, which mirrors the + ``permission_id`` definition in ``schema/authz-schema-v1.json``: a single + period joining two lowercase snake_case identifiers, each beginning with + a letter. Internal Casbin forms are rejected with a clearer message. + """ + if any(value.startswith(prefix) for prefix in CASBIN_INTERNAL_PREFIXES): + return [ + ValidationIssue( + IssueLevel.ERROR, + f"{context}: permission id {value!r} uses an internal Casbin form.", + sid, + ) + ] + if not PERMISSION_ID_RE.match(value): + return [ + ValidationIssue( + IssueLevel.ERROR, + f"{context}: permission id {value!r} must match {PERMISSION_ID_RE.pattern} " + "(two lowercase snake_case identifiers joined by a period, e.g. 'courses.view_course').", + sid, + ) + ] + return [] + + @staticmethod + def _check_role_permission_scope( + role_id: str, + perm_id: str, + role_scopes: tuple[str, ...], + permission_scopes: tuple[str, ...] | None, + sid: str | None, + *, + missing_verb: str, + ) -> list[ValidationIssue]: + """Check one role-permission pairing: the permission exists and supports the role's scopes. + + ``permission_scopes`` is the permission's supported scopes, or ``None`` + when the permission does not exist. ``missing_verb`` is the phrase used + for the unknown-permission message, so the pre-compile pass can say the + role *references* a permission while the post-compile pass says it + *resolves to* one. + """ + if permission_scopes is None: + return [ + ValidationIssue( + IssueLevel.ERROR, + f"Role {role_id} {missing_verb} unknown permission {perm_id!r}.", + sid, + ) + ] + unsupported = set(role_scopes) - set(permission_scopes) + if unsupported: + return [ + ValidationIssue( + IssueLevel.ERROR, + f"Role {role_id} is defined for scope(s) {sorted(unsupported)} " + f"that permission {perm_id!r} does not support.", + sid, + ) + ] + return [] + + @staticmethod + def _check_permission_category( + perm_id: str, + category_id: str, + known_category_ids: Container[str], + sid: str | None, + ) -> list[ValidationIssue]: + """A permission's category, when set, must reference an existing category.""" + if category_id and category_id not in known_category_ids: + return [ + ValidationIssue( + IssueLevel.ERROR, + f"Permission {perm_id} references unknown category {category_id!r}.", + sid, + ) + ] + return [] + + def _check_scopes(self, scopes: tuple[str, ...], context: str, sid: str) -> list[ValidationIssue]: + """Validate that at least one scope is declared and each scope namespace is well-formed.""" + if not scopes: + return [ValidationIssue(IssueLevel.ERROR, f"{context} must declare at least one scope.", sid)] + issues: list[ValidationIssue] = [] + for scope in scopes: + if not SCOPE_RE.match(scope): + issues.append(ValidationIssue(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: + return [ValidationIssue(IssueLevel.ERROR, f"Missing required field: {label}.", sid)] + return [] + + @staticmethod + def _role_permission_source_id(schema: CompiledSchema, role_id: str, perm_id: str) -> str | None: + """Name the source(s) responsible for one role-permission grant. + + A role-permission grant is the link that assigns a permission to a role: + a single ``(role_id, permission_id)`` pairing in the compiled schema. ADR + 0025 defines it as the atomic unit of attribution (one grant renders to + one Casbin ``p`` row). It is distinct from a *role assignment*, which + links a subject to a role (the ``g`` policies of ADR 0012). The grant's + provenance is tracked on ``schema.role_permission_sources``; this returns + the source id(s) to attribute a validation issue to. + + Prefers extension contributions: when an extension introduces the + offending permission, the operator needs the extending file's id, not + the file that declared the role. + """ + sources = schema.role_permission_sources.get((role_id, perm_id), []) + extensions = [src for src in sources if src.origin_kind == SchemaOriginKind.EXTENSION] + chosen = extensions or sources + if not chosen: + return None + return ", ".join(sorted({src.source.source_id for src in chosen})) diff --git a/src/openedx_authz/management/commands/load_authz_schema.py b/src/openedx_authz/management/commands/load_authz_schema.py new file mode 100644 index 00000000..ff44350d --- /dev/null +++ b/src/openedx_authz/management/commands/load_authz_schema.py @@ -0,0 +1,164 @@ +"""Discover, validate, compile, report, and apply the static authz schema. + +This is the single non-interactive deployment command described in ADR 0019 §3. +Tutor (via a plugin init task) and other deployment systems invoke it before the +application serves traffic; all integrations share this one compiler/pipeline. + +Usage:: + + python manage.py load_authz_schema # full apply + python manage.py load_authz_schema --dry-run # report only, no writes + python manage.py load_authz_schema --force # allow role removals + python manage.py load_authz_schema \\ + --dir openedx_authz/authz/schema # explicit directory (CI/local) + +The command must run at a point where all contributing packages are installed +and Django settings/DB are available (ADR 0018 / plugin timing constraint). +""" + +from __future__ import annotations + +from django.core.management.base import BaseCommand, CommandError + +from openedx_authz.engine.schema.discovery import SchemaDiscovery, SchemaDiscoveryError +from openedx_authz.engine.schema.exceptions import SchemaError +from openedx_authz.engine.schema.pipeline import SchemaPipeline + + +class Command(BaseCommand): + """Management command wrapper around :class:`SchemaPipeline`.""" + + help = "Discover, validate, compile, and apply the static authorization schema." + + def add_arguments(self, parser) -> None: + """Register command-line options.""" + parser.add_argument( + "--dry-run", + action="store_true", + help="Run discover through render and print the change report without writing to the database.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Allow removing static roles that still have user assignments (ADR 0018).", + ) + parser.add_argument( + "--dir", + action="append", + default=None, + dest="directories", + metavar="DIRECTORY", + help=( + "Explicitly include a schema directory (repeatable), in addition to discovered " + "entry points and settings. The loader reads every .yaml file in it. " + "Path format is 'top_level_package/sub/dir' (e.g. 'openedx_authz/authz/schema'). " + "Intended for CI and local development." + ), + ) + + def handle(self, *args, **options) -> None: + """Build the pipeline and run the requested operation. + + Validation/compile/apply errors surface as CommandError so deployment + stops before (or without partially applying) any database change. + """ + directories = options.get("directories") or [] + discovery = SchemaDiscovery(passed_in_directories=directories) if directories else SchemaDiscovery() + pipeline = SchemaPipeline(discovery=discovery) + + try: + if options.get("dry_run"): + plan = pipeline.plan() + self._report_plan(plan, applied=False) + return + + result = pipeline.apply(force=options.get("force", False)) + except (SchemaError, SchemaDiscoveryError) as exc: + raise CommandError(str(exc)) from exc + + if result.unchanged: + self.stdout.write(self.style.SUCCESS("Authz schema unchanged; no rows written.")) + return + + # Print the same detailed breakdown a dry run would, so an operator can + # see exactly which Casbin policy rows and definition records changed, + # then close with the applied summary. + if result.plan is not None: + self._report_plan(result.plan, applied=True) + self.stdout.write(self.style.SUCCESS(self._apply_summary(result))) + + def _apply_summary(self, result) -> str: + """One-line recap of what apply wrote, across both layers. + + Reports the Casbin ``p`` row counts and the definition-metadata counts + (roles/permissions/categories/grants) together, since either layer can + change on its own — a metadata-only edit writes 0 policy rows but is + still a real change the operator should see reflected here. + """ + summary = f"Authz schema applied: {result.added} Casbin policy row(s) added, {result.removed} removed" + + plan = result.plan + if plan is not None and not plan.definitions_unchanged: + parts = [f"{len(diff)} {label}" for label, diff in plan.definition_diffs if not diff.is_empty] + summary += f"; definition changes: {', '.join(parts)}" + + return f"{summary}." + + def _report_plan(self, plan, *, applied: bool) -> None: + """Print the change report (ADR 0018 §6). + + Covers the definition tables (roles, permissions, categories, and + role-permission grants) as well as the Casbin ``p`` policy rows. The two + are reported separately because they are distinct layers: apply syncs the + definition metadata even when no ``p`` row changes, so a metadata-only + edit is a real change the operator needs to see. ``applied`` only changes + the verb tense in the section headers (past tense once written). + """ + if plan.unchanged: + self.stdout.write(self.style.SUCCESS("Authz schema unchanged; nothing would be written.")) + return + + added_label = "added" if applied else "to add" + removed_label = "removed" if applied else "to remove" + + self.stdout.write(f"Casbin policy rows {added_label} ({len(plan.added_rows)}):") + for row in plan.added_rows: + self.stdout.write(f" + {row.as_policy()}") + + self.stdout.write(f"Casbin policy rows {removed_label} ({len(plan.removed_rows)}):") + for row in plan.removed_rows: + self.stdout.write(f" - {row.as_policy()}") + + self._report_definitions(plan) + + if plan.blocking_assignments: + self.stdout.write( + self.style.WARNING( + f"{len(plan.blocking_assignments)} role(s) with existing assignments would be " + "removed; apply requires --force:" + ) + ) + for role, subject in plan.blocking_assignments: + self.stdout.write(f" ! {role} assigned to {subject}") + + def _report_definitions(self, plan) -> None: + """Print the definition-metadata changes, one section per kind. + + These are the role/permission/category/grant records, a separate layer + from the Casbin policy rows above: they can change on their own (e.g. a + display-name edit) without adding or removing any ``p`` row. + """ + if plan.definitions_unchanged: + self.stdout.write("Role/permission/category definitions unchanged.") + return + + for label, diff in plan.definition_diffs: + if diff.is_empty: + continue + self.stdout.write(f"Definition changes - {label} ({len(diff)}):") + for key in diff.added: + self.stdout.write(f" + {key}") + for key in diff.updated: + self.stdout.write(f" ~ {key}") + for key in diff.removed: + self.stdout.write(f" - {key}") diff --git a/src/openedx_authz/migrations/0011_authz_schema_definitions.py b/src/openedx_authz/migrations/0011_authz_schema_definitions.py new file mode 100644 index 00000000..edd84044 --- /dev/null +++ b/src/openedx_authz/migrations/0011_authz_schema_definitions.py @@ -0,0 +1,338 @@ +"""Compiled authorization definitions and source-tracking tables (ADR 0025).""" + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Create compiled authz definition and source-tracking tables (ADR 0025).""" + + dependencies = [ + ("openedx_authz", "0010_scope_external_key"), + ] + + operations = [ + migrations.CreateModel( + name="AuthzSchemaSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "distribution", + models.CharField( + help_text="Installed distribution that shipped the contribution (e.g. 'openedx-authz').", + max_length=255, + ), + ), + ( + "module", + models.CharField( + help_text="Python module that owns the schema resource (e.g. 'openedx_authz.authz').", + max_length=255, + ), + ), + ("distribution_version", models.CharField(blank=True, default="", max_length=64)), + ( + "resource_path", + models.CharField( + blank=True, + default="", + help_text="Latest-seen resource path within the module. Non-identifying.", + max_length=255, + ), + ), + ("content_digest", models.CharField(blank=True, default="", max_length=64)), + ("schema_version", models.CharField(blank=True, default="", max_length=16)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Authz Schema Source", + "verbose_name_plural": "Authz Schema Sources", + }, + ), + migrations.CreateModel( + name="AuthzPermissionCategory", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("category_id", models.CharField(max_length=255, unique=True)), + ("display_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("icon", models.CharField(blank=True, max_length=128, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Authz Permission Category", + "verbose_name_plural": "Authz Permission Categories", + }, + ), + migrations.CreateModel( + name="AuthzPermissionDefinition", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("namespace", models.CharField(max_length=255)), + ("name", models.CharField(max_length=255)), + ("display_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("scopes", models.JSONField(default=list)), + ("icon", models.CharField(blank=True, max_length=128, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "category", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="permissions", + to="openedx_authz.authzpermissioncategory", + ), + ), + ], + options={ + "verbose_name": "Authz Permission Definition", + "verbose_name_plural": "Authz Permission Definitions", + }, + ), + migrations.CreateModel( + name="AuthzRoleDefinition", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("role_id", models.CharField(max_length=255, unique=True)), + ("display_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("scopes", models.JSONField(default=list)), + ("icon", models.CharField(blank=True, max_length=128, null=True)), + ("hidden", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Authz Role Definition", + "verbose_name_plural": "Authz Role Definitions", + }, + ), + migrations.CreateModel( + name="AuthzRolePermission", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "scope", + models.CharField( + help_text="Scope namespace where the grant applies (e.g. 'course-v1', 'lib').", + max_length=255, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="role_permissions", + to="openedx_authz.authzpermissiondefinition", + ), + ), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="role_permissions", + to="openedx_authz.authzroledefinition", + ), + ), + ], + options={ + "verbose_name": "Authz Role Permission", + "verbose_name_plural": "Authz Role Permissions", + }, + ), + migrations.CreateModel( + name="AuthzCategorySource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzpermissioncategory" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Category Source", + "verbose_name_plural": "Authz Category Sources", + }, + ), + migrations.CreateModel( + name="AuthzPermissionSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzpermissiondefinition" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Permission Source", + "verbose_name_plural": "Authz Permission Sources", + }, + ), + migrations.CreateModel( + name="AuthzRoleSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzroledefinition" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Role Source", + "verbose_name_plural": "Authz Role Sources", + }, + ), + migrations.CreateModel( + name="AuthzRolePermissionSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "role_permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzrolepermission" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Role Permission Source", + "verbose_name_plural": "Authz Role Permission Sources", + }, + ), + migrations.AddField( + model_name="authzpermissioncategory", + name="sources", + field=models.ManyToManyField( + related_name="categories", + through="openedx_authz.AuthzCategorySource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddField( + model_name="authzpermissiondefinition", + name="sources", + field=models.ManyToManyField( + related_name="permissions", + through="openedx_authz.AuthzPermissionSource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddField( + model_name="authzroledefinition", + name="sources", + field=models.ManyToManyField( + related_name="roles", + through="openedx_authz.AuthzRoleSource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddField( + model_name="authzrolepermission", + name="sources", + field=models.ManyToManyField( + related_name="role_permissions", + through="openedx_authz.AuthzRolePermissionSource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddConstraint( + model_name="authzschemasource", + constraint=models.UniqueConstraint( + fields=["distribution", "module"], name="authz_source_dist_module_uniq" + ), + ), + migrations.AddConstraint( + model_name="authzpermissiondefinition", + constraint=models.UniqueConstraint(fields=["namespace", "name"], name="authz_permission_ns_name_uniq"), + ), + migrations.AddConstraint( + model_name="authzrolepermission", + constraint=models.UniqueConstraint( + fields=["role", "permission", "scope"], name="authz_role_permission_uniq" + ), + ), + migrations.AddConstraint( + model_name="authzcategorysource", + constraint=models.UniqueConstraint(fields=["category", "source"], name="authz_category_source_uniq"), + ), + migrations.AddConstraint( + model_name="authzpermissionsource", + constraint=models.UniqueConstraint( + fields=["permission", "source"], name="authz_permission_source_uniq" + ), + ), + migrations.AddConstraint( + model_name="authzrolesource", + constraint=models.UniqueConstraint(fields=["role", "source"], name="authz_role_source_uniq"), + ), + migrations.AddConstraint( + model_name="authzrolepermissionsource", + constraint=models.UniqueConstraint( + fields=["role_permission", "source"], name="authz_role_permission_source_uniq" + ), + ), + ] diff --git a/src/openedx_authz/models/__init__.py b/src/openedx_authz/models/__init__.py index 06b5d003..6f3a3b22 100644 --- a/src/openedx_authz/models/__init__.py +++ b/src/openedx_authz/models/__init__.py @@ -17,5 +17,6 @@ from openedx_authz.models.authz_migration import * from openedx_authz.models.core import * +from openedx_authz.models.schema import * from openedx_authz.models.scopes import * from openedx_authz.models.subjects import * diff --git a/src/openedx_authz/models/authz_migration.py b/src/openedx_authz/models/authz_migration.py index 1be9f8ce..8141a2fb 100644 --- a/src/openedx_authz/models/authz_migration.py +++ b/src/openedx_authz/models/authz_migration.py @@ -109,7 +109,6 @@ def save(self, *args, **kwargs) -> "AuthzCourseAuthoringMigrationRun": super().save(*args, **kwargs) return self - # pylint: disable=too-many-positional-arguments @classmethod def _create( cls, migration_type, scope_type, scope_key, status, metadata=None diff --git a/src/openedx_authz/models/base.py b/src/openedx_authz/models/base.py new file mode 100644 index 00000000..bf6569f2 --- /dev/null +++ b/src/openedx_authz/models/base.py @@ -0,0 +1,37 @@ +"""Shared abstract base models for the authorization framework. + +These are reusable building blocks meant to remove field-level duplication +across the models package. They carry no table of their own (``abstract = +True``) and add no PII. +""" + +from __future__ import annotations + +from django.db import models + +__all__ = [ + "TimeStampedModel", +] + + +class TimeStampedModel(models.Model): + """Abstract base adding self-managed ``created_at`` / ``updated_at`` timestamps. + + .. no_pii: + + Mirrors the ``created_at`` / ``updated_at`` convention already used across + this repo (see :mod:`openedx_authz.models.core` and + :mod:`openedx_authz.models.authz_migration`) rather than the ``created`` / + ``modified`` names of :class:`model_utils.models.TimeStampedModel`, so + adopting it needs no column renames. New models should inherit this instead + of repeating the two fields; existing models can migrate to it over time. + + ``created_at`` is set once on insert (``auto_now_add``); ``updated_at`` is + refreshed on every save (``auto_now``). + """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True diff --git a/src/openedx_authz/models/schema.py b/src/openedx_authz/models/schema.py new file mode 100644 index 00000000..f9e51bee --- /dev/null +++ b/src/openedx_authz/models/schema.py @@ -0,0 +1,380 @@ +"""Models for compiled authorization definitions and their sources (ADR 0025). + +These tables are the authoritative store of the compiled static schema: +permission categories, permission definitions, role definitions, and the +role-permission grants rendered into Casbin ``p`` rows. Each definition and each +role-permission grant is attributed to one or more contributing sources so the +origin of any role or permission can be queried, so a built-in role and a +module-added grant on that role stay distinguishable, and so a future +application removal can prune only what that application uniquely provided. + +Casbin ``p`` rows remain the enforcement representation; these tables are the +definition/provenance record written alongside them in the same transaction. + +By convention, all models in this package are prefixed with ``Authz`` (e.g. +:class:`AuthzSchemaSource`, :class:`AuthzRoleDefinition`) so the project's +tables are easy to identify at a glance in the shared database and admin, and +to avoid name collisions with models from other installed apps. +""" + +from __future__ import annotations + +from django.db import models + +from openedx_authz.constants import SchemaOriginKind +from openedx_authz.models.base import TimeStampedModel + +__all__ = [ + "OriginKind", + "AuthzSchemaSource", + "AuthzPermissionCategory", + "AuthzPermissionDefinition", + "AuthzRoleDefinition", + "AuthzRolePermission", + "AuthzCategorySource", + "AuthzPermissionSource", + "AuthzRoleSource", + "AuthzRolePermissionSource", + "origins_for_role", + "origins_for_permission", + "origins_for_category", + "origin_for_role_permission", +] + + +class OriginKind(models.TextChoices): + """Whether a contribution is a base definition or an extension (ADR 0023/0025). + + Values are sourced from + :class:`openedx_authz.constants.SchemaOriginKind` so the persistence layer + and the (Django-free) engine schema types share a single source of truth; + the second tuple element is the human-readable label. + """ + + BASE = SchemaOriginKind.BASE.value, "Base" + EXTENSION = SchemaOriginKind.EXTENSION.value, "Extension" + + +class AuthzSchemaSourceQuerySet(models.QuerySet): + """QuerySet for AuthzSchemaSource with provenance lookups. + + The filter methods narrow to the sources that contribute to a given role, + permission, category, or single role-permission grant; they return a + queryset so callers can refine further. :meth:`distributions` is the + terminal that renders the sorted, de-duplicated distribution names the + ``origins_*`` helpers expose. + """ + + def contributing_to_role(self, role_id: str) -> "AuthzSchemaSourceQuerySet": + """Return sources that contribute to a role (base definition + extensions).""" + return self.filter(roles__role_id=role_id) + + def defining_permission(self, identifier: str) -> "AuthzSchemaSourceQuerySet": + """Return sources that define a permission, matched on its complete ``namespace.name`` id.""" + namespace, _, name = identifier.partition(".") + return self.filter(permissions__namespace=namespace, permissions__name=name) + + def defining_category(self, category_id: str) -> "AuthzSchemaSourceQuerySet": + """Return sources that define a category.""" + return self.filter(categories__category_id=category_id) + + def contributing_role_permission(self, role_id: str, permission_identifier: str) -> "AuthzSchemaSourceQuerySet": + """Return sources that contribute one specific role-permission grant. + + This isolates a single grant on a role, so grants provided by different + modules to the same role stay distinguishable from one another. + """ + namespace, _, name = permission_identifier.partition(".") + return self.filter( + role_permissions__role__role_id=role_id, + role_permissions__permission__namespace=namespace, + role_permissions__permission__name=name, + ) + + def distributions(self) -> list[str]: + """Return the sorted, de-duplicated distribution names for the current filter.""" + return sorted(self.values_list("distribution", flat=True).distinct()) + + +class AuthzSchemaSource(TimeStampedModel): + """A distinct schema contribution, identified by distribution and module. + + .. no_pii: + + Identity is ``(distribution, module)`` — moving a definition between files + within the same module does not change its source. ``resource_path`` and + ``content_digest`` are non-identifying and advisory (kept latest-seen for + diagnostics); change detection relies on diffing compiled definitions. + """ + + objects = AuthzSchemaSourceQuerySet.as_manager() + + distribution = models.CharField( + max_length=255, + help_text="Installed distribution that shipped the contribution (e.g. 'openedx-authz').", + ) + module = models.CharField( + max_length=255, + help_text="Python module that owns the schema resource (e.g. 'openedx_authz.authz').", + ) + distribution_version = models.CharField(max_length=64, blank=True, default="") + resource_path = models.CharField( + max_length=255, + blank=True, + default="", + help_text="Latest-seen resource path within the module. Non-identifying.", + ) + content_digest = models.CharField(max_length=64, blank=True, default="") + schema_version = models.CharField(max_length=16, blank=True, default="") + + class Meta: + verbose_name = "Authz Schema Source" + verbose_name_plural = "Authz Schema Sources" + constraints = [ + models.UniqueConstraint(fields=["distribution", "module"], name="authz_source_dist_module_uniq"), + ] + + @property + def source_id(self) -> str: + """Stable identifier, e.g. ``'openedx-authz:openedx_authz/authz'``.""" + return f"{self.distribution}:{self.module.replace('.', '/')}" + + def __str__(self): + return self.source_id + + +class AuthzPermissionCategory(TimeStampedModel): + """A display/grouping category for permissions (grants no access). + + .. no_pii: + """ + + category_id = models.CharField(max_length=255, unique=True) + display_name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + icon = models.CharField(max_length=128, blank=True, null=True) + sources = models.ManyToManyField(AuthzSchemaSource, through="AuthzCategorySource", related_name="categories") + + class Meta: + verbose_name = "Authz Permission Category" + verbose_name_plural = "Authz Permission Categories" + + def __str__(self): + return self.category_id + + +class AuthzPermissionDefinition(TimeStampedModel): + """A compiled permission definition. + + .. no_pii: + + The complete permission id is ``namespace.name`` (see :attr:`identifier`). + """ + + namespace = models.CharField(max_length=255) + name = models.CharField(max_length=255) + display_name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + category = models.ForeignKey( + AuthzPermissionCategory, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="permissions", + ) + scopes = models.JSONField(default=list) + icon = models.CharField(max_length=128, blank=True, null=True) + sources = models.ManyToManyField(AuthzSchemaSource, through="AuthzPermissionSource", related_name="permissions") + + class Meta: + verbose_name = "Authz Permission Definition" + verbose_name_plural = "Authz Permission Definitions" + constraints = [ + models.UniqueConstraint(fields=["namespace", "name"], name="authz_permission_ns_name_uniq"), + ] + + @property + def identifier(self) -> str: + """Complete permission id, e.g. ``'courses.view_course'``.""" + return f"{self.namespace}.{self.name}" + + def __str__(self): + return self.identifier + + +class AuthzRoleDefinition(TimeStampedModel): + """A compiled role definition. + + .. no_pii: + + ``hidden`` mirrors ADR 0023: a hidden role is excluded from normal role + discovery/selection but keeps its assignments, permission checks, and + reserved id. + """ + + role_id = models.CharField(max_length=255, unique=True) + display_name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + scopes = models.JSONField(default=list) + icon = models.CharField(max_length=128, blank=True, null=True) + hidden = models.BooleanField(default=False) + sources = models.ManyToManyField(AuthzSchemaSource, through="AuthzRoleSource", related_name="roles") + + class Meta: + verbose_name = "Authz Role Definition" + verbose_name_plural = "Authz Role Definitions" + + def __str__(self): + return self.role_id + + +class AuthzRolePermission(TimeStampedModel): + """A single role-permission-scope association (one per rendered Casbin ``p`` row). + + .. no_pii: + + This records the association declared by the schema; Casbin still manages + the actual permission check. It is the atomic unit of attribution: a base + association and an extension association on the same role are distinct rows + with distinct sources. + """ + + role = models.ForeignKey(AuthzRoleDefinition, on_delete=models.CASCADE, related_name="role_permissions") + permission = models.ForeignKey(AuthzPermissionDefinition, on_delete=models.CASCADE, related_name="role_permissions") + scope = models.CharField( + max_length=255, + help_text="Scope namespace where the grant applies (e.g. 'course-v1', 'lib').", + ) + sources = models.ManyToManyField( + AuthzSchemaSource, through="AuthzRolePermissionSource", related_name="role_permissions" + ) + + class Meta: + verbose_name = "Authz Role Permission" + verbose_name_plural = "Authz Role Permissions" + constraints = [ + models.UniqueConstraint(fields=["role", "permission", "scope"], name="authz_role_permission_uniq"), + ] + + def __str__(self): + # ``self.role_id``/``self.permission_id`` are the FK columns (integers), + # not the stable schema identifiers, so traverse to the definitions. + return f"{self.role.role_id} -> {self.permission.identifier} @ {self.scope}" + + +# --------------------------------------------------------------------------- +# Source link (through) models. Each carries origin and priority so the winning +# metadata source is derivable and shared ownership is representable. +# --------------------------------------------------------------------------- + + +class _BaseSourceLink(models.Model): + """Common fields for source links. + + .. no_pii: + """ + + source = models.ForeignKey(AuthzSchemaSource, on_delete=models.CASCADE) + origin_kind = models.CharField(max_length=16, choices=OriginKind.choices, default=OriginKind.BASE) + priority = models.IntegerField(default=0) + + class Meta: + abstract = True + + +class AuthzCategorySource(_BaseSourceLink): + """Links a category to a contributing source. + + .. no_pii: + """ + + category = models.ForeignKey(AuthzPermissionCategory, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Authz Category Source" + verbose_name_plural = "Authz Category Sources" + constraints = [ + models.UniqueConstraint(fields=["category", "source"], name="authz_category_source_uniq"), + ] + + +class AuthzPermissionSource(_BaseSourceLink): + """Links a permission definition to a contributing source. + + .. no_pii: + """ + + permission = models.ForeignKey(AuthzPermissionDefinition, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Authz Permission Source" + verbose_name_plural = "Authz Permission Sources" + constraints = [ + models.UniqueConstraint(fields=["permission", "source"], name="authz_permission_source_uniq"), + ] + + +class AuthzRoleSource(_BaseSourceLink): + """Links a role definition to a contributing source. + + .. no_pii: + """ + + role = models.ForeignKey(AuthzRoleDefinition, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Authz Role Source" + verbose_name_plural = "Authz Role Sources" + constraints = [ + models.UniqueConstraint(fields=["role", "source"], name="authz_role_source_uniq"), + ] + + +class AuthzRolePermissionSource(_BaseSourceLink): + """Links a role-permission grant to a contributing source. + + .. no_pii: + + This is where the extension case is recorded: a grant defined together with + the role links to that role's source (``origin_kind=base``), while a grant + added via a ``role_extension`` links to the extending source + (``origin_kind=extension``). + """ + + role_permission = models.ForeignKey(AuthzRolePermission, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Authz Role Permission Source" + verbose_name_plural = "Authz Role Permission Sources" + constraints = [ + models.UniqueConstraint(fields=["role_permission", "source"], name="authz_role_permission_source_uniq"), + ] + + +# --------------------------------------------------------------------------- +# Query helpers: given any role or permission, get its origin(s). +# --------------------------------------------------------------------------- + + +def origins_for_role(role_id: str) -> list[str]: + """Return the distributions that contribute to a role (base + extensions).""" + return AuthzSchemaSource.objects.contributing_to_role(role_id).distributions() + + +def origins_for_permission(identifier: str) -> list[str]: + """Return the distributions that define a permission, by complete id.""" + return AuthzSchemaSource.objects.defining_permission(identifier).distributions() + + +def origins_for_category(category_id: str) -> list[str]: + """Return the distributions that define a category.""" + return AuthzSchemaSource.objects.defining_category(category_id).distributions() + + +def origin_for_role_permission(role_id: str, permission_identifier: str) -> list[str]: + """Return the distributions that contribute a specific role-permission grant. + + This distinguishes, for one role, the grants provided by one module vs another, + even though both live in the same role. + """ + return AuthzSchemaSource.objects.contributing_role_permission(role_id, permission_identifier).distributions() diff --git a/src/openedx_authz/tests/integration/test_schema_apply.py b/src/openedx_authz/tests/integration/test_schema_apply.py new file mode 100644 index 00000000..b06c607e --- /dev/null +++ b/src/openedx_authz/tests/integration/test_schema_apply.py @@ -0,0 +1,446 @@ +"""End-to-end integration tests for schema apply pruning (ADR 0018 §2, §5, §6). + +Unlike the unit tests in ``tests/schema/test_apply.py`` (which use a fake +enforcer and stub persistence), these exercise the *real* stack: + +* the shared Casbin :class:`~openedx_authz.engine.enforcer.AuthzEnforcer` + (DB-backed adapter, production matcher), and +* the real definition/source ORM tables. + +They prove the reconciliation contract from end to end: after a schema removes a +permission or a role, re-applying prunes the stale Casbin ``p`` rows and the +definition rows so the stored schema follows the compiled definition, and +force-removal of an assigned role removes its ``g`` assignment rows too. + +The tests assert at the behavioral level with ``enforce()`` (a permission +removal flips a real allow into a deny; force-removal revokes access) and back +that up with the stored ``p``/``g`` rows and the definition tables. They still +require no populated platform data: the staff/superuser matcher returns ``False`` +for an unknown user (so no ``auth_user`` row is needed and access comes purely +from the role assignment), and scope matching is in-memory (no +``course_overviews_courseoverview`` lookup). Assignments are seeded with the +low-level grouping API to avoid the assignment audit/signal machinery. + +Database setup: the integration ``conftest`` makes ``django_db_setup`` a no-op so +its other tests reuse an externally provisioned database. This module instead +restores a real setup that builds tables **directly from the models +(``run_syncdb``) with migrations disabled**. Running edx-platform migrations on +the sqlite test DB fails (some platform migrations introspect tables at import +time, e.g. ``course_overviews.0009_readd_facebook_url``), which is why the +platform itself runs tests with ``--nomigrations``. Building from models +sidesteps that and still creates every table these tests touch. + +Run these in an edx-platform environment (e.g. tutor):: + + pytest -p no:randomly --create-db --ds=cms.envs.test \\ + /mnt/openedx-authz/openedx_authz/tests/integration/test_schema_apply.py +""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from django.db import IntegrityError +from django.test import TestCase + +from openedx_authz.engine.enforcer import AuthzEnforcer +from openedx_authz.engine.renderer import PolicyRenderer, SchemaApplier +from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.engine.schema.exceptions import SchemaApplyError +from openedx_authz.engine.schema.types import ( + PermissionCategory, + PermissionDefinition, + RoleDefinition, + RoleExtension, + SchemaDocument, + SourceRecord, +) +from openedx_authz.models.core import RoleAssignmentAudit +from openedx_authz.models.schema import AuthzRoleDefinition, AuthzRolePermission + + +@pytest.fixture(scope="session") +def django_db_setup(request, django_test_environment, django_db_blocker): # pylint: disable=unused-argument + """Build the test database from models, with migrations disabled. + + Overrides both pytest-django's default (which would run migrations) and the + integration ``conftest`` no-op (which would build nothing). Migrations are + disabled because some edx-platform migrations fail on the sqlite test DB by + introspecting tables at import time; ``run_syncdb`` creates the tables from + the installed models instead, which is enough for these tests. + """ + from django.test.utils import setup_databases, teardown_databases # pylint: disable=import-outside-toplevel + from pytest_django.fixtures import _disable_migrations # pylint: disable=import-outside-toplevel + + _disable_migrations() + with django_db_blocker.unblock(): + db_cfg = setup_databases(verbosity=request.config.option.verbose, interactive=False) + yield + with django_db_blocker.unblock(): + teardown_databases(db_cfg, verbosity=request.config.option.verbose) + + +SCOPE_NAMESPACE = "course-v1" +COURSE_SCOPE = "course-v1^course-v1:OpenedX+DemoX+DemoCourse" +USER_SUBJECT = "user^schema_apply_alice" +ROLE_SUBJECT = "role^schema_apply_editor" +VIEW_ACTION = "act^courses.view_course" +TAGS_ACTION = "act^courses.manage_tags" + + +def _source(name: str) -> SourceRecord: + return SourceRecord( + distribution="openedx-authz", + distribution_version="0.0.0", + module=f"openedx_authz.tests.{name}", + resource_path=f"{name}.authz.yaml", + schema_version="1.0", + content_digest=f"digest-{name}", + ) + + +def _document(name="core", *, priority=100, roles=(), extensions=()): + return SchemaDocument( + source=_source(name), + priority=priority, + categories=[PermissionCategory(id="cat", display_name="Cat", description="d")], + permissions=[ + PermissionDefinition( + namespace="courses", + name="view_course", + display_name="View", + description="d", + category_id="cat", + scopes=(SCOPE_NAMESPACE,), + ), + PermissionDefinition( + namespace="courses", + name="manage_tags", + display_name="Tags", + description="d", + category_id="cat", + scopes=(SCOPE_NAMESPACE,), + ), + ], + roles=list(roles), + role_extensions=list(extensions), + ) + + +def _editor_role(permissions): + return RoleDefinition( + id="schema_apply_editor", + display_name="Editor", + description="d", + scopes=(SCOPE_NAMESPACE,), + permissions=tuple(permissions), + ) + + +class SchemaApplyIntegrationBase(TestCase): + """Shared setup and helpers for the real-stack apply tests. + + Holds no test methods; the concrete cases below inherit the clean-policy + setup and the ``p``/``g`` inspection helpers. + """ + + def setUp(self): + """Start each test from a clean policy and a known enforcer instance.""" + self.enforcer = AuthzEnforcer.get_enforcer() + self.enforcer.clear_policy() + self.applier = SchemaApplier() + + def tearDown(self): + """Leave no policy behind for other integration tests.""" + self.enforcer.clear_policy() + + # -- helpers ------------------------------------------------------------ + + def _apply(self, *documents, force=False): + """Compile, render, and apply ``documents``, then reload the enforcer.""" + schema = SchemaCompiler().compile(list(documents)) + rendered = PolicyRenderer().render(schema) + result = self.applier.apply(rendered, schema, force=force) + self.enforcer.load_policy() + return result + + def _p_rows_for_role(self): + """Return the stored ``p`` rows whose subject is the test role.""" + return [row for row in self.enforcer.get_policy() if row[0] == ROLE_SUBJECT] + + def _grouping_for_role(self): + """Return the stored ``g`` (assignment) rows referencing the test role.""" + return [g for g in self.enforcer.get_grouping_policy() if len(g) >= 2 and g[1] == ROLE_SUBJECT] + + def _assign_user_to_role(self): + """Add a raw ``g`` assignment row for the test role. + + Uses the low-level grouping API rather than the public role-assignment + API so the test doesn't depend on the assignment audit/signal machinery. + The staff/superuser matcher returns ``False`` for an unknown user (no + ``User`` row required), so enforcement decisions come purely from this + role assignment. + """ + self.enforcer.add_grouping_policy(USER_SUBJECT, ROLE_SUBJECT, COURSE_SCOPE) + self.enforcer.load_policy() + + +class TestSchemaApplyPruningIntegration(SchemaApplyIntegrationBase): + """Real enforcer + real DB reconciliation across successive applies.""" + + def test_first_apply_persists_rows_and_definitions(self): + """A first apply writes p rows and definition tables together.""" + result = self._apply(_document(roles=[_editor_role(("courses.view_course", "courses.manage_tags"))])) + + self.assertEqual(result.added, 2) + self.assertEqual(result.removed, 0) + self.assertEqual(len(self._p_rows_for_role()), 2) + + editor = AuthzRoleDefinition.objects.get(role_id="schema_apply_editor") + self.assertEqual(editor.role_permissions.count(), 2) + + def test_reapply_identical_schema_is_idempotent(self): + """Re-applying the same schema changes nothing (ADR 0018 §2).""" + doc = _document(roles=[_editor_role(("courses.view_course", "courses.manage_tags"))]) + self._apply(doc) + + result = self._apply(doc) + + self.assertEqual(result.added, 0) + self.assertEqual(result.removed, 0) + self.assertTrue(result.unchanged) + self.assertEqual(len(self._p_rows_for_role()), 2) + + def test_removed_permission_prunes_p_row_and_flips_enforcement(self): + """Dropping a permission via extension flips the live enforcement result. + + This is the core §2 guarantee, checked at the behavioral level: a user + assigned the role is *allowed* ``manage_tags`` before the removal and + *denied* it afterwards, while the untouched ``view_course`` stays + allowed. Stored ``p`` rows and the definition tables are checked too, so + a regression that left enforcement drifting on a stale row would fail + here. + """ + base = _document(roles=[_editor_role(("courses.view_course", "courses.manage_tags"))]) + self._apply(base) + self._assign_user_to_role() + + # Before removal: both actions enforce as allowed via the role. + self.assertTrue(self.enforcer.enforce(USER_SUBJECT, TAGS_ACTION, COURSE_SCOPE)) + self.assertTrue(self.enforcer.enforce(USER_SUBJECT, VIEW_ACTION, COURSE_SCOPE)) + + # Remove manage_tags from the role via a higher-priority extension. + extension_doc = _document( + "modx", + priority=200, + roles=[], + extensions=[RoleExtension(role_id="schema_apply_editor", remove_permissions=("courses.manage_tags",))], + ) + result = self._apply(base, extension_doc) + + self.assertEqual(result.removed, 1) + + # After removal: manage_tags is denied, view_course still allowed. + self.assertFalse(self.enforcer.enforce(USER_SUBJECT, TAGS_ACTION, COURSE_SCOPE)) + self.assertTrue(self.enforcer.enforce(USER_SUBJECT, VIEW_ACTION, COURSE_SCOPE)) + + # The stale p row is gone from the stored policy... + stored = self.enforcer.get_policy() + self.assertNotIn([ROLE_SUBJECT, TAGS_ACTION, "course-v1^*", "allow"], stored) + self.assertIn([ROLE_SUBJECT, VIEW_ACTION, "course-v1^*", "allow"], stored) + + # ...and from the definition tables. + editor = AuthzRoleDefinition.objects.get(role_id="schema_apply_editor") + self.assertEqual(editor.role_permissions.count(), 1) + self.assertFalse( + AuthzRolePermission.objects.filter( + role=editor, permission__namespace="courses", permission__name="manage_tags" + ).exists() + ) + + def test_removed_role_without_assignments_is_pruned(self): + """A role no longer in the schema is removed when nothing is assigned.""" + self._apply(_document(roles=[_editor_role(("courses.view_course",))])) + self.assertTrue(AuthzRoleDefinition.objects.filter(role_id="schema_apply_editor").exists()) + + # Apply a schema without the role at all. + result = self._apply(_document(roles=[])) + + self.assertEqual(result.removed, 1) + self.assertEqual(self._p_rows_for_role(), []) + self.assertFalse(AuthzRoleDefinition.objects.filter(role_id="schema_apply_editor").exists()) + + def test_removing_assigned_role_requires_force(self): + """Removing a role with a live assignment aborts without force (§6).""" + self._apply(_document(roles=[_editor_role(("courses.view_course",))])) + self._assign_user_to_role() + + with self.assertRaises(SchemaApplyError): + self._apply(_document(roles=[]), force=False) + + # Nothing was pruned: the p row, the assignment, and the definition are + # intact, and the user still enforces as allowed. + self.assertEqual(len(self._p_rows_for_role()), 1) + self.assertEqual(len(self._grouping_for_role()), 1) + self.assertTrue(AuthzRoleDefinition.objects.filter(role_id="schema_apply_editor").exists()) + self.assertTrue(self.enforcer.enforce(USER_SUBJECT, VIEW_ACTION, COURSE_SCOPE)) + + def test_force_removes_assigned_role_and_its_assignment(self): + """With force, a removed role loses its p rows, g assignment, and access (§6).""" + self._apply(_document(roles=[_editor_role(("courses.view_course",))])) + self._assign_user_to_role() + self.assertEqual(len(self._p_rows_for_role()), 1) + self.assertEqual(len(self._grouping_for_role()), 1) + self.assertTrue(self.enforcer.enforce(USER_SUBJECT, VIEW_ACTION, COURSE_SCOPE)) + + # Run on_commit hooks so the ROLE_ASSIGNMENT_DELETED audit event fires. + with self.captureOnCommitCallbacks(execute=True): + result = self._apply(_document(roles=[]), force=True) + + self.assertEqual(result.removed, 1) + # Access is revoked, and both the p rows and the g assignment are gone. + self.assertFalse(self.enforcer.enforce(USER_SUBJECT, VIEW_ACTION, COURSE_SCOPE)) + self.assertEqual(self._p_rows_for_role(), []) + self.assertEqual(self._grouping_for_role(), []) + self.assertFalse(AuthzRoleDefinition.objects.filter(role_id="schema_apply_editor").exists()) + + # Every assignment change leaves an audit trail: the force removal emits + # ROLE_ASSIGNMENT_DELETED, which is recorded as a 'deleted' audit row. + audit = RoleAssignmentAudit.objects.filter(subject=USER_SUBJECT, role=ROLE_SUBJECT, scope=COURSE_SCOPE) + self.assertEqual(audit.count(), 1) + self.assertEqual(audit.get().operation, RoleAssignmentAudit.OPERATIONS.deleted) + + +class TestSchemaApplyAdoptionIntegration(SchemaApplyIntegrationBase): + """Pre-existing policy rows are adopted, unmanaged rows are left alone. + + ADR 0025 §6: a rendered ``(role, permission, scope)`` that already exists as + a ``p`` row gains definition and source records instead of being rewritten, + while a stored row no schema declares stays in place and enforceable but + unattributed. This is the realistic first deployment, where ``load_policies`` + has already written the ``p`` rows and the definition tables are empty. + """ + + UNMANAGED_ROLE = "role^schema_apply_legacy" + + def _seed_rendered_rows(self, *documents): + """Write the rendered rows straight to the policy, bypassing apply.""" + schema = SchemaCompiler().compile(list(documents)) + for row in PolicyRenderer().render(schema).rows: + self.enforcer.add_policy(*row.as_policy()) + self.enforcer.load_policy() + + def test_preexisting_rows_are_adopted_not_duplicated(self): + """Pre-existing rendered rows are adopted (gain definitions), not duplicated.""" + document = _document(roles=[_editor_role(("courses.view_course", "courses.manage_tags"))]) + self._seed_rendered_rows(document) + self.assertEqual(len(self._p_rows_for_role()), 2) + self.assertFalse(AuthzRoleDefinition.objects.filter(role_id="schema_apply_editor").exists()) + + result = self._apply(document) + + # Nothing to write to the policy, yet the definitions now exist — so the + # run is reported as a change even though no p row moved. + self.assertEqual(result.added, 0) + self.assertEqual(result.removed, 0) + self.assertFalse(result.unchanged) + self.assertEqual(len(self._p_rows_for_role()), 2) + editor = AuthzRoleDefinition.objects.get(role_id="schema_apply_editor") + self.assertEqual(editor.role_permissions.count(), 2) + + def test_adopted_rows_keep_enforcing(self): + """Adoption must not interrupt access that already worked.""" + document = _document(roles=[_editor_role(("courses.view_course",))]) + self._seed_rendered_rows(document) + self._assign_user_to_role() + self.assertTrue(self.enforcer.enforce(USER_SUBJECT, VIEW_ACTION, COURSE_SCOPE)) + + self._apply(document) + + self.assertTrue(self.enforcer.enforce(USER_SUBJECT, VIEW_ACTION, COURSE_SCOPE)) + + def test_adopted_grant_records_the_contributing_source(self): + """An adopted grant records its contributing source module.""" + document = _document(roles=[_editor_role(("courses.view_course",))]) + self._seed_rendered_rows(document) + + self._apply(document) + + grant = AuthzRolePermission.objects.get(role__role_id="schema_apply_editor") + self.assertEqual([source.module for source in grant.sources.all()], ["openedx_authz.tests.core"]) + + def test_unmanaged_row_is_preserved_and_still_enforces(self): + """A row no schema declares is not the loader's to remove.""" + self.enforcer.add_policy(self.UNMANAGED_ROLE, VIEW_ACTION, "course-v1^*", "allow") + self.enforcer.add_grouping_policy(USER_SUBJECT, self.UNMANAGED_ROLE, COURSE_SCOPE) + self.enforcer.load_policy() + + result = self._apply(_document(roles=[_editor_role(("courses.view_course",))])) + + self.assertEqual(result.removed, 0) + self.assertIn([self.UNMANAGED_ROLE, VIEW_ACTION, "course-v1^*", "allow"], self.enforcer.get_policy()) + self.assertTrue(self.enforcer.enforce(USER_SUBJECT, VIEW_ACTION, COURSE_SCOPE)) + # ...and it is not attributed to any schema source. + self.assertFalse(AuthzRoleDefinition.objects.filter(role_id="schema_apply_legacy").exists()) + + def test_unmanaged_row_survives_an_empty_schema(self): + """An unmanaged row is preserved even when the applied schema is empty.""" + self.enforcer.add_policy(self.UNMANAGED_ROLE, VIEW_ACTION, "course-v1^*", "allow") + self.enforcer.load_policy() + + result = self._apply(_document(roles=[])) + + self.assertEqual(result.removed, 0) + self.assertIn([self.UNMANAGED_ROLE, VIEW_ACTION, "course-v1^*", "allow"], self.enforcer.get_policy()) + + +class TestSchemaApplyFailureIntegration(SchemaApplyIntegrationBase): + """A failed apply leaves Casbin on the last committed state (ADR 0018 §5). + + ``add_policy`` writes through to the adapter *and* mutates the enforcer's + in-memory model, so a rollback would otherwise leave this process enforcing + rows the database never committed. + """ + + def _fail_during_store(self): + """Make ``_store_sources`` write a row and then fail, like a DB error would.""" + + def _store_then_fail(_self, _schema): + AuthzRoleDefinition.objects.create( + role_id="schema_apply_half_written", + display_name="Half written", + description="", + scopes=[SCOPE_NAMESPACE], + hidden=False, + ) + raise IntegrityError("simulated write failure") + + patcher = mock.patch.object(SchemaApplier, "_store_sources", _store_then_fail) + patcher.start() + self.addCleanup(patcher.stop) + + def test_failed_apply_rolls_back_every_definition_write(self): + """A failed apply rolls back every definition row it had started writing.""" + self._fail_during_store() + + with self.assertRaises(IntegrityError): + self._apply(_document(roles=[_editor_role(("courses.view_course",))])) + + self.assertFalse(AuthzRoleDefinition.objects.filter(role_id="schema_apply_half_written").exists()) + self.assertFalse(AuthzRoleDefinition.objects.filter(role_id="schema_apply_editor").exists()) + + def test_failed_apply_leaves_enforcement_on_the_committed_state(self): + """The reload triggered by cache invalidation drops the uncommitted rows.""" + self._assign_user_to_role() + self.assertFalse(self.enforcer.enforce(USER_SUBJECT, VIEW_ACTION, COURSE_SCOPE)) + self._fail_during_store() + + with self.assertRaises(IntegrityError): + self._apply(_document(roles=[_editor_role(("courses.view_course",))])) + + # The apply invalidated the policy cache, so acquiring the enforcer + # reloads from the database rather than trusting the in-memory model. + reloaded = AuthzEnforcer.get_enforcer() + self.assertEqual([row for row in reloaded.get_policy() if row[0] == ROLE_SUBJECT], []) + self.assertFalse(reloaded.enforce(USER_SUBJECT, VIEW_ACTION, COURSE_SCOPE)) diff --git a/src/openedx_authz/tests/schema/test_apply.py b/src/openedx_authz/tests/schema/test_apply.py new file mode 100644 index 00000000..f9117536 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_apply.py @@ -0,0 +1,582 @@ +"""Tests for the apply/plan reconciliation path (ADR 0018 §2, §5, §6). + +Three layers are exercised: + +* A fake in-memory enforcer drives the ``p``/``g`` row reconciliation logic + (add, remove, force-gated assignment removal, idempotency) against real + definition tables, because pruning is driven by the recorded ownership rather + than by a raw policy diff (ADR 0025 §6). +* Failure handling: a rolled-back apply must not leave the enforcer's in-memory + model ahead of the database (ADR 0018 §5). +* A Django ``TestCase`` covers definition/source pruning through + :meth:`SchemaApplier._store_sources`, confirming the definition tables track + the compiled schema across successive applies. +""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from django.db import IntegrityError +from django.test import TestCase + +from openedx_authz.engine.renderer import PolicyRenderer, SchemaApplier +from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.engine.schema.exceptions import SchemaApplyError +from openedx_authz.models.schema import ( + AuthzPermissionCategory, + AuthzPermissionDefinition, + AuthzRoleDefinition, + AuthzRolePermission, +) + +from .factories import category, extension, make_document, permission, role + +PERMS = [ + permission(name="view_course", cat="cat"), + permission(name="manage_tags", cat="cat"), + permission(name="export_course", cat="cat"), +] + + +def _doc(*, name="core", priority=100, roles=None, permissions=None, categories=None, role_extensions=None): + return make_document( + name, + priority=priority, + categories=categories if categories is not None else [category("cat")], + permissions=permissions if permissions is not None else PERMS, + roles=roles if roles is not None else [], + role_extensions=role_extensions or [], + ) + + +class FakeEnforcer: + """Minimal in-memory stand-in for the Casbin enforcer used by apply/plan. + + Stores ``p`` rows and ``g`` (grouping) rows as lists of string lists, which + is the shape the real enforcer returns. + """ + + def __init__(self, policies=None, grouping=None): + self._policies = [list(row) for row in (policies or [])] + self._grouping = [list(row) for row in (grouping or [])] + + def get_policy(self): + """Return a copy of the stored ``p`` rows.""" + return [list(row) for row in self._policies] + + def get_grouping_policy(self): + """Return a copy of the stored ``g`` (grouping) rows.""" + return [list(row) for row in self._grouping] + + def add_policy(self, *args): + """Add a ``p`` row, ignoring exact duplicates. Returns True if added.""" + row = list(args) + if row not in self._policies: + self._policies.append(row) + return True + return False + + def remove_policy(self, *args): + """Remove a ``p`` row if present. Returns True if removed.""" + row = list(args) + if row in self._policies: + self._policies.remove(row) + return True + return False + + def add_grouping_policy(self, *args): + """Add a ``g`` row, ignoring exact duplicates. Returns True if added.""" + row = list(args) + if row not in self._grouping: + self._grouping.append(row) + return True + return False + + def remove_grouping_policy(self, *args): + """Remove a ``g`` row if present. Returns True if removed.""" + row = list(args) + if row in self._grouping: + self._grouping.remove(row) + return True + return False + + +def _compile(*documents): + return SchemaCompiler().compile(list(documents)) + + +def _render(*documents): + return PolicyRenderer().render(_compile(*documents)) + + +def _editor(perms): + return _doc(roles=[role(rid="course_editor", scopes=("course-v1",), permissions=perms)]) + + +def _without_manage_tags(): + """An extension that removes ``courses.manage_tags`` from ``course_editor``.""" + return _doc( + name="modx", + priority=200, + roles=[], + categories=[], + permissions=[], + role_extensions=[extension("course_editor", remove_permissions=("courses.manage_tags",))], + ) + + +@pytest.fixture(name="cache_invalidation") +def cache_invalidation_fixture(monkeypatch): + """Capture policy-cache invalidation rather than writing a version row. + + Returns the mock so tests can assert *whether* the cache was invalidated, + which is the observable contract on both the success and failure paths. + """ + invalidate = mock.Mock(name="invalidate_policy_cache") + monkeypatch.setattr( + "openedx_authz.engine.enforcer.AuthzEnforcer.invalidate_policy_cache", + staticmethod(invalidate), + raising=False, + ) + return invalidate + + +def _apply(enforcer, *documents, force=False): + """Compile, render and apply one coherent schema. + + Rendering and persistence must come from the *same* compiled schema: + pruning is driven by the ownership recorded in the definition tables, so a + render that disagrees with what was stored would leave rows unattributed + and unprunable. + """ + schema = _compile(*documents) + rendered = PolicyRenderer().render(schema) + return SchemaApplier(enforcer=enforcer).apply(rendered, schema, force=force) + + +@pytest.mark.django_db +@pytest.mark.usefixtures("cache_invalidation") +class TestApplyReconciliation: + """Enforcer-level add/remove/idempotency behavior.""" + + def test_first_apply_adds_all_rows(self): + """A first apply adds one policy row per rendered grant.""" + enforcer = FakeEnforcer() + + result = _apply(enforcer, _editor(("courses.view_course", "courses.manage_tags"))) + + assert result.added == 2 + assert result.removed == 0 + assert len(enforcer.get_policy()) == 2 + + def test_reapply_is_idempotent(self): + """Re-applying the same schema adds and removes nothing.""" + enforcer = FakeEnforcer() + document = _editor(("courses.view_course", "courses.manage_tags")) + + _apply(enforcer, document) + result = _apply(enforcer, document) + + assert result.added == 0 + assert result.removed == 0 + assert result.unchanged is True + assert len(enforcer.get_policy()) == 2 + + def test_removed_permission_prunes_stale_p_row(self): + """Removing a permission prunes its stale policy row, keeping the others.""" + # Start with two permissions on the role, then drop one via extension. + enforcer = FakeEnforcer() + base = _editor(("courses.view_course", "courses.manage_tags")) + _apply(enforcer, base) + assert len(enforcer.get_policy()) == 2 + + result = _apply(enforcer, base, _without_manage_tags()) + + assert result.removed == 1 + remaining = {tuple(row) for row in enforcer.get_policy()} + assert ("role^course_editor", "act^courses.manage_tags", "course-v1^*", "allow") not in remaining + assert ("role^course_editor", "act^courses.view_course", "course-v1^*", "allow") in remaining + + def test_removed_role_without_assignments_is_pruned(self): + """A role dropped from the schema is pruned when it has no assignments.""" + enforcer = FakeEnforcer() + _apply(enforcer, _editor(("courses.view_course",))) + + # Nothing rendered now -> the role's p row is stale and removed. + result = _apply(enforcer) + + assert result.removed == 1 + assert enforcer.get_policy() == [] + + +@pytest.mark.django_db +@pytest.mark.usefixtures("cache_invalidation") +class TestOwnershipBoundary: + """Only rows the loader recorded as its own may be pruned (ADR 0025 §6). + + A stored ``p`` row that no schema declares — a legacy policy-file row, an + administrative fix (ADR 0018 §7), or a row owned by another service — stays + in place and enforceable, and is never attributed to a schema source. + """ + + UNMANAGED = ("role^legacy_thing", "act^courses.view_course", "course-v1^*", "allow") + + def test_unmanaged_policy_row_is_preserved(self): + """A policy row no schema owns is left in place by apply.""" + enforcer = FakeEnforcer(policies=[self.UNMANAGED]) + + _apply(enforcer, _editor(("courses.view_course",))) + + assert list(self.UNMANAGED) in enforcer.get_policy() + + def test_unmanaged_row_is_not_reported_as_removed(self): + """An unmanaged row is never counted among the removed rows.""" + enforcer = FakeEnforcer(policies=[self.UNMANAGED]) + + result = _apply(enforcer, _editor(("courses.view_course",))) + + assert result.removed == 0 + + def test_unmanaged_row_survives_an_empty_schema(self): + """Even with nothing to render, an unowned row is not ours to delete.""" + enforcer = FakeEnforcer(policies=[self.UNMANAGED]) + + result = _apply(enforcer) + + assert result.removed == 0 + assert enforcer.get_policy() == [list(self.UNMANAGED)] + + def test_unmanaged_row_is_not_attributed(self): + """An unmanaged row gains no definition/source attribution.""" + enforcer = FakeEnforcer(policies=[self.UNMANAGED]) + + _apply(enforcer, _editor(("courses.view_course",))) + + assert not AuthzRoleDefinition.objects.filter(role_id="legacy_thing").exists() + + def test_adopts_preexisting_rows_without_definitions(self): + """ADR 0025 §6: an existing row gains definitions instead of being rewritten. + + This is the realistic first deployment: ``load_policies`` already wrote + the ``p`` rows and the definition tables are empty. No policy row moves, + but the definitions are new, so the run is *not* reported as unchanged. + """ + document = _editor(("courses.view_course",)) + preexisting = [row.as_policy() for row in _render(document).rows] + enforcer = FakeEnforcer(policies=preexisting) + + result = _apply(enforcer, document) + + assert result.added == 0 + assert result.removed == 0 + assert result.unchanged is False + assert enforcer.get_policy() == preexisting + grant = AuthzRolePermission.objects.get() + assert grant.role.role_id == "course_editor" + assert grant.sources.count() == 1 + + def test_pruning_follows_the_recorded_grant(self): + """The prune is driven by the grant row, not by the raw policy diff.""" + enforcer = FakeEnforcer() + base = _editor(("courses.view_course", "courses.manage_tags")) + _apply(enforcer, base) + assert AuthzRolePermission.objects.count() == 2 + + _apply(enforcer, base, _without_manage_tags()) + + assert AuthzRolePermission.objects.count() == 1 + assert len(enforcer.get_policy()) == 1 + + +@pytest.mark.django_db +@pytest.mark.usefixtures("cache_invalidation") +class TestForceGate: + """Removal of a role that still has user assignments is force-gated.""" + + def _assigned_enforcer(self): + """Build an enforcer holding a stored role plus one user assignment to it.""" + enforcer = FakeEnforcer() + _apply(enforcer, _editor(("courses.view_course",))) + # A user is assigned the role (g row: [subject, role, scope]). + enforcer.add_grouping_policy("user^alice", "role^course_editor", "course-v1:OpenedX+DemoX+Demo") + return enforcer + + def test_blocking_assignment_aborts_without_force(self): + """Removing a role with a live assignment aborts without ``force``.""" + enforcer = self._assigned_enforcer() + + with pytest.raises(SchemaApplyError): + _apply(enforcer, force=False) + + # No write happened: the p row is still there. + assert len(enforcer.get_policy()) == 1 + + def test_force_removes_role_rows_and_assignments(self): + """With ``force``, the removed role's policy and assignment rows are pruned.""" + enforcer = self._assigned_enforcer() + + result = _apply(enforcer, force=True) + + assert result.removed == 1 + assert enforcer.get_policy() == [] + assert enforcer.get_grouping_policy() == [] + + +@pytest.mark.django_db +class TestApplyFailure: + """A failed write must not leave Casbin ahead of the database (ADR 0018 §5). + + ``add_policy``/``remove_policy`` mutate the enforcer's in-memory model as + well as the database, so a rollback would otherwise leave the process + enforcing rows that were never committed. + """ + + @staticmethod + def _failing_store(monkeypatch): + """Write a definition row, then fail, so rollback is observable.""" + + def _store_then_fail(self, schema): # pylint: disable=unused-argument + AuthzRoleDefinition.objects.create( + role_id="half_written", + display_name="Half written", + description="", + scopes=["course-v1"], + hidden=False, + ) + raise IntegrityError("simulated write failure") + + monkeypatch.setattr(SchemaApplier, "_store_sources", _store_then_fail) + + def test_failure_propagates(self, monkeypatch, cache_invalidation): # pylint: disable=unused-argument + """A write failure during apply propagates to the caller.""" + self._failing_store(monkeypatch) + + with pytest.raises(IntegrityError): + _apply(FakeEnforcer(), _editor(("courses.view_course",))) + + def test_failure_rolls_back_definition_writes(self, monkeypatch, cache_invalidation): # pylint: disable=unused-argument + """A failed apply rolls back any definition rows it had written.""" + self._failing_store(monkeypatch) + + with pytest.raises(IntegrityError): + _apply(FakeEnforcer(), _editor(("courses.view_course",))) + + assert not AuthzRoleDefinition.objects.filter(role_id="half_written").exists() + + def test_failure_invalidates_the_policy_cache(self, monkeypatch, cache_invalidation): + """The in-memory model kept the rolled-back rows, so force a reload.""" + self._failing_store(monkeypatch) + enforcer = FakeEnforcer() + + with pytest.raises(IntegrityError): + _apply(enforcer, _editor(("courses.view_course",))) + + # The fake enforcer models the real divergence: it still holds the row + # the database rolled back. Invalidating the cache is what makes the + # next enforcer access reload the committed state. + assert len(enforcer.get_policy()) == 1 + cache_invalidation.assert_called_once_with() + + def test_successful_apply_invalidates_once_when_rows_change(self, cache_invalidation): + """A successful apply that changes rows invalidates the policy cache once.""" + _apply(FakeEnforcer(), _editor(("courses.view_course",))) + + cache_invalidation.assert_called_once_with() + + def test_successful_apply_skips_invalidation_when_unchanged(self, cache_invalidation): + """An apply that changes nothing does not invalidate the policy cache.""" + enforcer = FakeEnforcer() + document = _editor(("courses.view_course",)) + _apply(enforcer, document) + cache_invalidation.reset_mock() + + _apply(enforcer, document) + + cache_invalidation.assert_not_called() + + +class TestDefinitionPruning(TestCase): + """Definition/source tables track the compiled schema across applies.""" + + def test_removed_permission_prunes_grant_and_definition(self): + """Dropping a permission prunes both its grant and its definition row.""" + applier = SchemaApplier() + + first = SchemaCompiler().compile([_editor(("courses.view_course", "courses.manage_tags"))]) + applier._store_sources(first) # pylint: disable=protected-access + editor = AuthzRoleDefinition.objects.get(role_id="course_editor") + assert editor.role_permissions.count() == 2 + + # Drop manage_tags via an extension and remove the permission definition. + second = SchemaCompiler().compile( + [ + _doc( + roles=[role(rid="course_editor", permissions=("courses.view_course",))], + permissions=[permission(name="view_course", cat="cat")], + ) + ] + ) + applier._store_sources(second) # pylint: disable=protected-access + + editor.refresh_from_db() + assert editor.role_permissions.count() == 1 + assert not AuthzPermissionDefinition.objects.filter(name="manage_tags").exists() + assert not AuthzPermissionDefinition.objects.filter(name="export_course").exists() + + def test_removed_role_and_category_are_pruned(self): + """Applying an empty schema prunes every role, grant, permission, and category.""" + applier = SchemaApplier() + applier._store_sources( # pylint: disable=protected-access + SchemaCompiler().compile([_editor(("courses.view_course",))]) + ) + assert AuthzRoleDefinition.objects.filter(role_id="course_editor").exists() + + # Apply an empty schema: everything the previous schema owned is pruned. + applier._store_sources(SchemaCompiler().compile([])) # pylint: disable=protected-access + + assert AuthzRoleDefinition.objects.count() == 0 + assert AuthzRolePermission.objects.count() == 0 + assert AuthzPermissionDefinition.objects.count() == 0 + assert AuthzPermissionCategory.objects.count() == 0 + + +@pytest.mark.django_db +@pytest.mark.usefixtures("cache_invalidation") +class TestDefinitionChangeReport: + """The plan reports definition changes, not just policy rows (ADR 0018 §6). + + Apply syncs the definition tables unconditionally, so a metadata-only edit + changes stored state while leaving every ``p`` row identical. Reporting only + rows would tell the operator "unchanged" and then rewrite their metadata. + """ + + @staticmethod + def _plan(enforcer, *documents): + schema = _compile(*documents) + return SchemaApplier(enforcer=enforcer).plan(PolicyRenderer().render(schema), schema) + + def test_first_run_reports_every_definition_as_added(self): + """A first run reports every role, category, permission, and grant as added.""" + plan = self._plan(FakeEnforcer(), _editor(("courses.view_course",))) + + assert plan.roles.added == ["course_editor"] + assert plan.categories.added == ["cat"] + assert "courses.view_course" in plan.permissions.added + assert plan.grants.added == ["course_editor -> courses.view_course @ course-v1"] + assert plan.unchanged is False + + def test_identical_reapply_reports_no_definition_changes(self): + """Re-planning an identical schema reports no definition changes.""" + enforcer = FakeEnforcer() + document = _editor(("courses.view_course",)) + _apply(enforcer, document) + + plan = self._plan(enforcer, document) + + assert plan.definitions_unchanged is True + assert plan.unchanged is True + + def test_metadata_only_change_is_reported(self): + """No p row moves, yet the role's display name would be rewritten.""" + enforcer = FakeEnforcer() + before = _doc(roles=[role(rid="course_editor", permissions=("courses.view_course",))]) + _apply(enforcer, before) + + after = _doc( + roles=[ + role( + rid="course_editor", + permissions=("courses.view_course",), + display_name="Course author", + ) + ] + ) + plan = self._plan(enforcer, after) + + assert not plan.added_rows + assert not plan.removed_rows + assert plan.roles.updated == ["course_editor"] + assert plan.unchanged is False + + def test_hidden_flag_change_is_reported(self): + """Flipping a role's ``hidden`` flag is reported as an update.""" + enforcer = FakeEnforcer() + before = _doc(roles=[role(rid="course_editor", permissions=("courses.view_course",))]) + _apply(enforcer, before) + + after = _doc(roles=[role(rid="course_editor", permissions=("courses.view_course",), hidden=True)]) + plan = self._plan(enforcer, after) + + assert plan.roles.updated == ["course_editor"] + + def test_permission_metadata_change_is_reported(self): + """Editing a permission's display name is reported as an update.""" + enforcer = FakeEnforcer() + _apply(enforcer, _editor(("courses.view_course",))) + + renamed = _doc( + roles=[role(rid="course_editor", permissions=("courses.view_course",))], + permissions=[ + permission(name="view_course", cat="cat", display_name="See course"), + permission(name="manage_tags", cat="cat"), + permission(name="export_course", cat="cat"), + ], + ) + plan = self._plan(enforcer, renamed) + + assert plan.permissions.updated == ["courses.view_course"] + + def test_category_metadata_change_is_reported(self): + """Editing a category's display metadata is reported as an update.""" + enforcer = FakeEnforcer() + _apply(enforcer, _editor(("courses.view_course",))) + + recategorized = _doc( + roles=[role(rid="course_editor", permissions=("courses.view_course",))], + categories=[category("cat", display_name="Course content", icon="Article")], + ) + plan = self._plan(enforcer, recategorized) + + assert plan.categories.updated == ["cat"] + + def test_dropped_definitions_are_reported_as_removed(self): + """Definitions absent from the new schema are reported as removed.""" + enforcer = FakeEnforcer() + _apply(enforcer, _editor(("courses.view_course",))) + + plan = self._plan(enforcer) + + assert plan.roles.removed == ["course_editor"] + assert plan.categories.removed == ["cat"] + assert plan.grants.removed == ["course_editor -> courses.view_course @ course-v1"] + + def test_grant_change_is_reported_alongside_the_row(self): + """A removed grant is reported both as a grant change and a removed row.""" + enforcer = FakeEnforcer() + base = _editor(("courses.view_course", "courses.manage_tags")) + _apply(enforcer, base) + + plan = self._plan(enforcer, base, _without_manage_tags()) + + assert plan.grants.removed == ["course_editor -> courses.manage_tags @ course-v1"] + assert len(plan.removed_rows) == 1 + + def test_plan_without_a_schema_reports_rows_only(self): + """``plan`` stays usable for row-only comparisons (schema optional).""" + enforcer = FakeEnforcer() + + plan = SchemaApplier(enforcer=enforcer).plan(_render(_editor(("courses.view_course",)))) + + assert len(plan.added_rows) == 1 + assert plan.definitions_unchanged is True + + def test_plan_does_not_write(self): + """Planning is read-only: it writes no policy rows or definitions.""" + enforcer = FakeEnforcer() + + self._plan(enforcer, _editor(("courses.view_course",))) + + assert enforcer.get_policy() == [] + assert AuthzRoleDefinition.objects.count() == 0 diff --git a/src/openedx_authz/tests/schema/test_compilation.py b/src/openedx_authz/tests/schema/test_compilation.py new file mode 100644 index 00000000..d23ee835 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_compilation.py @@ -0,0 +1,388 @@ +"""Unit tests for the schema compilation step (merge + extensions + priority). + +Grouped by concern: base compilation, extensions, priority resolution, +provenance, discarded-contribution warnings, no-op extension warnings, and the +defensive branches guarding states validation is expected to have rejected. +""" + +import logging +from dataclasses import fields + +import pytest + +from openedx_authz.constants import SchemaOriginKind +from openedx_authz.engine.schema.compilation import RoleMetadataField, SchemaCompiler +from openedx_authz.engine.schema.exceptions import SchemaCompileError +from openedx_authz.engine.schema.types import RoleExtension + +from .factories import category, extension, make_document, make_source, permission, role + +PERMS = [ + permission(name="view_course", cat="cat"), + permission(name="export_course", cat="cat"), + permission(name="manage_tags", cat="cat"), +] + + +def _base(**role_kwargs): + return make_document( + "base", + priority=100, + categories=[category("cat")], + permissions=PERMS, + roles=[role(rid="course_editor", permissions=("courses.view_course", "courses.manage_tags"), **role_kwargs)], + ) + + +class TestRoleMetadataFieldInvariant: + """``RoleMetadataField`` must stay in lockstep with ``RoleExtension``. + + ``_gather_extension_changes`` reads each member via ``getattr`` with no + default, so a member naming a field that ``RoleExtension`` does not declare + would raise at compile time. This guards that coupling at the enum level, + turning a rename drift into a fast, obvious test failure. + """ + + def test_every_member_is_a_role_extension_field(self): + """Each enum value names a real ``RoleExtension`` field.""" + extension_fields = {f.name for f in fields(RoleExtension)} + enum_values = {member.value for member in RoleMetadataField} + assert enum_values <= extension_fields + + def test_members_exclude_non_metadata_fields(self): + """Permission and identity fields are not metadata an extension replaces.""" + enum_values = {member.value for member in RoleMetadataField} + assert enum_values.isdisjoint({"role_id", "add_permissions", "remove_permissions"}) + + +class TestBaseCompilation: + """Compiling base definitions with no extensions applied.""" + + def test_base_definitions_compile(self): + """A single base document yields its roles and permissions verbatim.""" + schema = SchemaCompiler().compile([_base()]) + assert set(schema.roles) == {"course_editor"} + assert len(schema.permissions) == 3 + # Base definitions keep their declared order; rendering sorts later. + assert schema.roles["course_editor"].definition.permissions == ( + "courses.view_course", + "courses.manage_tags", + ) + + +class TestExtensions: + """Applying ``role_extensions`` on top of a base role definition.""" + + def test_extension_adds_and_removes_permissions_and_metadata(self): + """One extension can add, remove, rename, and hide in a single pass.""" + ext = make_document( + "ext", + priority=200, + role_extensions=[ + extension( + "course_editor", + add_permissions=("courses.export_course",), + remove_permissions=("courses.manage_tags",), + display_name="Author", + hidden=True, + ) + ], + ) + definition = SchemaCompiler().compile([_base(), ext]).roles["course_editor"].definition + assert "courses.export_course" in definition.permissions + assert "courses.manage_tags" not in definition.permissions + assert definition.display_name == "Author" + assert definition.hidden is True + + def test_extension_sources_are_retained(self): + """A role touched by an extension keeps both the base and extension sources.""" + ext = make_document("ext", priority=200, role_extensions=[extension("course_editor", display_name="X")]) + compiled = SchemaCompiler().compile([_base(), ext]) + assert len(compiled.roles["course_editor"].sources) == 2 + + +class TestPriorityResolution: + """How priority resolves conflicting contributions (higher wins; ties fail).""" + + def test_equal_priority_metadata_conflict_raises(self): + """Two extensions setting the same field at equal priority is an error.""" + a = make_document("a", priority=200, role_extensions=[extension("course_editor", display_name="A")]) + b = make_document("b", priority=200, role_extensions=[extension("course_editor", display_name="B")]) + with pytest.raises(SchemaCompileError): + SchemaCompiler().compile([_base(), a, b]) + + def test_higher_priority_metadata_wins(self): + """The higher-priority extension's metadata value takes effect.""" + lo = make_document("lo", priority=150, role_extensions=[extension("course_editor", display_name="Lo")]) + hi = make_document("hi", priority=300, role_extensions=[extension("course_editor", display_name="Hi")]) + definition = SchemaCompiler().compile([_base(), lo, hi]).roles["course_editor"].definition + assert definition.display_name == "Hi" + + def test_equal_priority_add_remove_conflict_raises(self): + """An add and a remove of the same permission at equal priority is an error.""" + add = make_document( + "add", + priority=200, + role_extensions=[extension("course_editor", add_permissions=("courses.export_course",))], + ) + rem = make_document( + "rem", + priority=200, + role_extensions=[extension("course_editor", remove_permissions=("courses.export_course",))], + ) + with pytest.raises(SchemaCompileError): + SchemaCompiler().compile([_base(), add, rem]) + + def test_conflicting_base_definition_equal_priority_raises(self): + """Two base definitions of the same role at equal priority is an error.""" + a = make_document("a", priority=100, roles=[role(rid="dup", display_name="A", permissions=())]) + b = make_document("b", priority=100, roles=[role(rid="dup", display_name="B", permissions=())]) + with pytest.raises(SchemaCompileError): + SchemaCompiler().compile([a, b]) + + def test_higher_priority_base_definition_wins(self): + """The higher-priority base definition replaces the lower one.""" + lo = make_document("lo", priority=100, roles=[role(rid="dup", display_name="Lo", permissions=())]) + hi = make_document("hi", priority=200, roles=[role(rid="dup", display_name="Hi", permissions=())]) + compiled = SchemaCompiler().compile([lo, hi]) + assert compiled.roles["dup"].definition.display_name == "Hi" + + +class TestProvenance: + """Each role-permission grant records where it came from (ADR 0025).""" + + def test_base_permissions_get_base_provenance(self): + """Permissions from the role's own definition are tagged ``BASE``.""" + schema = SchemaCompiler().compile([_base()]) + for perm in ("courses.view_course", "courses.manage_tags"): + prov = schema.role_permission_sources[("course_editor", perm)] + assert [(rs.source.distribution, rs.origin_kind) for rs in prov] == [("test-dist", SchemaOriginKind.BASE)] + + def test_extension_grant_is_attributed_to_the_module_not_core(self): + """An extension-added grant is tagged ``EXTENSION``, base grants stay ``BASE``.""" + ext = make_document( + "modx", + priority=200, + role_extensions=[extension("course_editor", add_permissions=("courses.export_course",))], + ) + schema = SchemaCompiler().compile([_base(), ext]) + + core = schema.role_permission_sources[("course_editor", "courses.view_course")] + added = schema.role_permission_sources[("course_editor", "courses.export_course")] + + # Both permissions coexist on the role, but their origins remain distinct. + assert [rs.origin_kind for rs in core] == [SchemaOriginKind.BASE] + assert [rs.origin_kind for rs in added] == [SchemaOriginKind.EXTENSION] + + def test_removed_permission_has_no_provenance(self): + """A permission removed by an extension leaves no provenance entry.""" + ext = make_document( + "modx", + priority=200, + role_extensions=[extension("course_editor", remove_permissions=("courses.manage_tags",))], + ) + schema = SchemaCompiler().compile([_base(), ext]) + assert ("course_editor", "courses.manage_tags") not in schema.role_permission_sources + + +class TestDiscardedContributionWarnings: + """Priority silently picks a winner; the loser must be reported. + + ADR 0017 §4 requires warning about contributions that do not take effect + because another file has a higher priority. A losing file is valid and was + loaded, so without a warning it looks like it applied. + """ + + @staticmethod + def _compile(*documents): + return SchemaCompiler().compile(list(documents)) + + def test_lower_priority_base_definition_warns(self, caplog): + """A base definition that loses on priority is reported as having no effect.""" + low = make_document("low", priority=100, roles=[role(rid="course_editor", display_name="Editor")]) + high = make_document("high", priority=200, roles=[role(rid="course_editor", display_name="Author")]) + + with caplog.at_level(logging.WARNING): + compiled = self._compile(high, low) + + assert compiled.roles["course_editor"].definition.display_name == "Author" + assert "has no effect" in caplog.text + assert make_source("low").source_id in caplog.text + assert make_source("high").source_id in caplog.text + + def test_warning_names_both_priorities(self, caplog): + """The discard warning names both the losing and winning priorities.""" + low = make_document("low", priority=100, roles=[role(rid="course_editor", display_name="Editor")]) + high = make_document("high", priority=200, roles=[role(rid="course_editor", display_name="Author")]) + + with caplog.at_level(logging.WARNING): + self._compile(low, high) + + assert "priority 100" in caplog.text + assert "priority 200" in caplog.text + + def test_warns_regardless_of_document_order(self, caplog): + """Discovery order must not decide whether the operator is told.""" + low = make_document("low", priority=100, roles=[role(rid="course_editor", display_name="Editor")]) + high = make_document("high", priority=200, roles=[role(rid="course_editor", display_name="Author")]) + + with caplog.at_level(logging.WARNING): + self._compile(low, high) + ascending = caplog.text + caplog.clear() + with caplog.at_level(logging.WARNING): + self._compile(high, low) + + assert "has no effect" in ascending + assert "has no effect" in caplog.text + + def test_identical_duplicate_does_not_warn(self): + """An identical definition merges sources; nothing is discarded.""" + first = make_document("first", priority=100, roles=[role(rid="course_editor")]) + second = make_document("second", priority=200, roles=[role(rid="course_editor")]) + + compiled = self._compile(first, second) + + assert len(compiled.roles["course_editor"].sources) == 2 + + def test_uses_singular_kind_label(self, caplog): + """Messages say 'category', not the truncated attribute name.""" + low = make_document("low", priority=100, categories=[category("cat", display_name="Low")]) + high = make_document("high", priority=200, categories=[category("cat", display_name="High")]) + + with caplog.at_level(logging.WARNING): + self._compile(low, high) + + assert "category 'cat'" in caplog.text + assert "categorie" not in caplog.text + + def test_conflict_error_uses_singular_kind_label(self): + """A conflict error uses the singular kind label ('category').""" + left = make_document("left", priority=100, categories=[category("cat", display_name="Left")]) + right = make_document("right", priority=100, categories=[category("cat", display_name="Right")]) + + with pytest.raises(SchemaCompileError, match="Conflicting category definition"): + self._compile(left, right) + + def test_losing_metadata_extension_warns(self, caplog): + """A metadata extension that loses on priority is reported, naming its source.""" + base = make_document("base", priority=100, roles=[role(rid="course_editor")]) + low = make_document("low", priority=100, role_extensions=[extension("course_editor", display_name="Low")]) + high = make_document("high", priority=200, role_extensions=[extension("course_editor", display_name="High")]) + + with caplog.at_level(logging.WARNING): + compiled = self._compile(base, low, high) + + assert compiled.roles["course_editor"].definition.display_name == "High" + assert "role_extension display_name" in caplog.text + assert make_source("low").source_id in caplog.text + + def test_losing_permission_extension_warns(self, caplog): + """A permission-changing extension that loses on priority is reported.""" + base = make_document( + "base", + priority=100, + permissions=[permission(cat="cat")], + roles=[role(rid="course_editor", permissions=("courses.view_course",))], + ) + low = make_document( + "low", + priority=100, + role_extensions=[extension("course_editor", remove_permissions=("courses.view_course",))], + ) + high = make_document( + "high", + priority=200, + role_extensions=[extension("course_editor", add_permissions=("courses.view_course",))], + ) + + with caplog.at_level(logging.WARNING): + compiled = self._compile(base, low, high) + + # The higher-priority add wins, so the permission stays. + assert "courses.view_course" in compiled.roles["course_editor"].definition.permissions + assert "role_extension remove of 'courses.view_course'" in caplog.text + + +class TestNoOpExtensionWarnings: + """ADR 0023 §3: a no-op add/remove warns and leaves the result unchanged.""" + + @staticmethod + def _compile(*documents): + return SchemaCompiler().compile(list(documents)) + + def test_adding_an_existing_permission_warns(self, caplog): + """Adding a permission the role already has warns and is a no-op.""" + base = make_document( + "base", + priority=100, + permissions=[permission(cat="cat")], + roles=[role(rid="course_editor", permissions=("courses.view_course",))], + ) + ext = make_document( + "ext", priority=200, role_extensions=[extension("course_editor", add_permissions=("courses.view_course",))] + ) + + with caplog.at_level(logging.WARNING): + compiled = self._compile(base, ext) + + assert compiled.roles["course_editor"].definition.permissions == ("courses.view_course",) + assert "already on role" in caplog.text + + def test_removing_an_absent_permission_warns(self, caplog): + """Removing a permission the role does not have warns and is a no-op.""" + base = make_document("base", priority=100, roles=[role(rid="course_editor", permissions=())]) + ext = make_document( + "ext", + priority=200, + role_extensions=[extension("course_editor", remove_permissions=("courses.manage_tags",))], + ) + + with caplog.at_level(logging.WARNING): + compiled = self._compile(base, ext) + + assert compiled.roles["course_editor"].definition.permissions == () + assert "not on role" in caplog.text + + +class TestDefensiveBranches: + """Paths guarded against states validation is expected to have rejected.""" + + def test_extension_for_an_unknown_role_is_skipped(self): + """Validation errors on this; compilation must not raise on it.""" + ext = make_document( + "ext", priority=200, role_extensions=[extension("ghost", add_permissions=("courses.view_course",))] + ) + + compiled = SchemaCompiler().compile([ext]) + + assert not compiled.roles + assert not compiled.role_permission_sources + + def test_identical_duplicate_categories_merge_sources(self): + """Two identical category definitions merge into one, keeping both sources.""" + first = make_document("first", categories=[category("cat")]) + second = make_document("second", categories=[category("cat")]) + + compiled = SchemaCompiler().compile([first, second]) + + assert len(compiled.categories["cat"].sources) == 2 + + def test_identical_duplicate_permissions_merge_sources(self): + """Two identical permission definitions merge into one, keeping both sources.""" + first = make_document("first", permissions=[permission(cat="cat")]) + second = make_document("second", permissions=[permission(cat="cat")]) + + compiled = SchemaCompiler().compile([first, second]) + + assert len(compiled.permissions["courses.view_course"].sources) == 2 + + def test_lower_priority_base_definition_is_kept_out(self): + """The 'keep existing' branch: a later, lower-priority file loses.""" + high = make_document("high", priority=200, roles=[role(rid="course_editor", display_name="Author")]) + low = make_document("low", priority=100, roles=[role(rid="course_editor", display_name="Editor")]) + + compiled = SchemaCompiler().compile([high, low]) + + assert compiled.roles["course_editor"].definition.display_name == "Author" + assert [s.source_id for s in compiled.roles["course_editor"].sources] == [make_source("high").source_id] diff --git a/src/openedx_authz/tests/schema/test_load_authz_schema_command.py b/src/openedx_authz/tests/schema/test_load_authz_schema_command.py new file mode 100644 index 00000000..2b2b14f9 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_load_authz_schema_command.py @@ -0,0 +1,333 @@ +"""Unit tests for the ``load_authz_schema`` management command. + +The command is a thin wrapper over :class:`SchemaPipeline`. These tests mock the +pipeline (and, where relevant, discovery) at the command module so the command's +own logic — option handling, apply vs. dry-run branching, report formatting, and +error translation to CommandError — is verified without a database. +""" + +from io import StringIO +from unittest import mock + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError + +from openedx_authz.engine.renderer import ( + ApplyResult, + ChangePlan, + DefinitionDiff, + PolicyRow, +) +from openedx_authz.engine.schema.discovery import SchemaDiscoveryError +from openedx_authz.engine.schema.exceptions import ( + SchemaApplyError, + SchemaCompileError, + SchemaValidationError, +) + +COMMAND = "load_authz_schema" +PIPELINE_PATH = "openedx_authz.management.commands.load_authz_schema.SchemaPipeline" +DISCOVERY_PATH = "openedx_authz.management.commands.load_authz_schema.SchemaDiscovery" + + +def _run(*args): + """Invoke the command, capturing stdout; returns the printed text.""" + out = StringIO() + call_command(COMMAND, *args, stdout=out) + return out.getvalue() + + +class TestApplyMode: + """Cover the default (apply) mode of the command.""" + + def test_apply_reports_changes(self): + """Apply prints the added/removed policy-row summary.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.apply.return_value = ApplyResult(added=3, removed=1, unchanged=False) + output = _run() + + pipeline_cls.return_value.apply.assert_called_once_with(force=False) + assert "3 Casbin policy row(s) added, 1 removed" in output + + def test_apply_prints_detailed_change_report(self): + """Apply prints the same breakdown as a dry run when a plan is attached.""" + plan = ChangePlan( + added_rows=[PolicyRow("p", "role^r", "act^courses.view_course", "course-v1^*", "allow")], + removed_rows=[PolicyRow("p", "role^old", "act^courses.manage_tags", "course-v1^*", "allow")], + unchanged=False, + roles=DefinitionDiff(updated=["course_editor"]), + ) + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.apply.return_value = ApplyResult(added=1, removed=1, unchanged=False, plan=plan) + output = _run() + + # Detailed policy-row and definition sections, using past tense. + assert "Casbin policy rows added (1)" in output + assert "Casbin policy rows removed (1)" in output + assert "role^r" in output + assert "role^old" in output + assert "Definition changes - role (1)" in output + assert "~ course_editor" in output + # And still closes with the applied summary, now including definitions. + assert "1 Casbin policy row(s) added, 1 removed; definition changes: 1 role" in output + + def test_apply_summary_counts_definition_changes_with_zero_policy_rows(self): + """A metadata-only apply writes 0 p rows but still recaps definitions.""" + plan = ChangePlan( + added_rows=[], + removed_rows=[], + unchanged=False, + categories=DefinitionDiff(added=["course_content", "library"]), + roles=DefinitionDiff(updated=["course_editor"]), + ) + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.apply.return_value = ApplyResult(added=0, removed=0, unchanged=False, plan=plan) + output = _run() + + assert "0 Casbin policy row(s) added, 0 removed; definition changes: 2 category, 1 role" in output + + def test_apply_summary_omits_definitions_when_unchanged(self): + """Row-only changes don't tack on an empty definition recap.""" + plan = ChangePlan( + added_rows=[PolicyRow("p", "role^r", "act^courses.view_course", "course-v1^*", "allow")], + unchanged=False, + ) + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.apply.return_value = ApplyResult(added=1, removed=0, unchanged=False, plan=plan) + output = _run() + + assert "1 Casbin policy row(s) added, 0 removed." in output + assert "definition changes:" not in output + + def test_apply_reports_unchanged(self): + """An apply that changes nothing reports 'unchanged'.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.apply.return_value = ApplyResult(added=0, removed=0, unchanged=True) + output = _run() + + assert "unchanged" in output.lower() + + def test_force_flag_is_forwarded(self): + """``--force`` is passed through to ``pipeline.apply``.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.apply.return_value = ApplyResult(unchanged=True) + _run("--force") + + pipeline_cls.return_value.apply.assert_called_once_with(force=True) + + +class TestDryRunMode: + """Cover the --dry-run mode and its change report formatting.""" + + def test_dry_run_calls_plan_not_apply(self): + """``--dry-run`` calls ``plan`` and never ``apply``.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.return_value = ChangePlan(unchanged=True) + _run("--dry-run") + + pipeline_cls.return_value.plan.assert_called_once_with() + pipeline_cls.return_value.apply.assert_not_called() + + def test_dry_run_unchanged_report(self): + """A dry run with no diff reports 'unchanged'.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.return_value = ChangePlan(unchanged=True) + output = _run("--dry-run") + + assert "unchanged" in output.lower() + + def test_dry_run_reports_added_and_removed_rows(self): + """A dry run lists the policy rows it would add and remove.""" + plan = ChangePlan( + added_rows=[PolicyRow("p", "role^r", "act^courses.view_course", "course-v1^*", "allow")], + removed_rows=[PolicyRow("p", "role^old", "act^courses.manage_tags", "course-v1^*", "allow")], + unchanged=False, + ) + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.return_value = plan + output = _run("--dry-run") + + assert "Casbin policy rows to add (1)" in output + assert "Casbin policy rows to remove (1)" in output + assert "role^r" in output + assert "role^old" in output + + def test_dry_run_reports_blocking_assignments(self): + """A dry run flags assignments that would require ``--force`` to remove.""" + plan = ChangePlan( + added_rows=[], + removed_rows=[], + unchanged=False, + blocking_assignments=[("role^course_editor", "user^alice")], + ) + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.return_value = plan + output = _run("--dry-run") + + assert "requires --force" in output + assert "role^course_editor assigned to user^alice" in output + + +class TestDefinitionReport: + """The dry-run report covers definition changes too (ADR 0018 §6). + + Apply syncs the definition tables even when no ``p`` row changes, so a + metadata-only edit has to appear in the report. + """ + + def test_metadata_only_change_is_reported_without_any_rows(self): + """A metadata-only edit is reported even though no policy row changes.""" + plan = ChangePlan( + added_rows=[], + removed_rows=[], + unchanged=False, + roles=DefinitionDiff(updated=["course_editor"]), + ) + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.return_value = plan + output = _run("--dry-run") + + assert "Definition changes - role (1)" in output + assert "~ course_editor" in output + + def test_added_and_removed_definitions_are_reported_per_kind(self): + """Definition changes are grouped and labeled per kind (category/permission/grant).""" + plan = ChangePlan( + unchanged=False, + categories=DefinitionDiff(added=["course_content"]), + permissions=DefinitionDiff(removed=["courses.manage_tags"]), + grants=DefinitionDiff(added=["course_editor -> courses.view_course @ course-v1"]), + ) + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.return_value = plan + output = _run("--dry-run") + + assert "Definition changes - category (1)" in output + assert "+ course_content" in output + assert "Definition changes - permission (1)" in output + assert "- courses.manage_tags" in output + assert "Definition changes - role-permission (1)" in output + + def test_untouched_kinds_are_omitted(self): + """Kinds with no changes are left out of the report.""" + plan = ChangePlan(unchanged=False, roles=DefinitionDiff(added=["course_editor"])) + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.return_value = plan + output = _run("--dry-run") + + assert "Definition changes - role (1)" in output + assert "category" not in output + assert "permission" not in output + + def test_row_only_change_says_definitions_unchanged(self): + """A row-only change states explicitly that definitions are unchanged.""" + plan = ChangePlan( + added_rows=[PolicyRow("p", "role^r", "act^courses.view_course", "course-v1^*", "allow")], + unchanged=False, + ) + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.return_value = plan + output = _run("--dry-run") + + assert "Role/permission/category definitions unchanged." in output + + +class TestDirectoryOption: + """Cover the --dir option wiring into SchemaDiscovery.""" + + def test_dir_builds_discovery_with_passed_in_directories(self): + """Repeated ``--dir`` options are passed to ``SchemaDiscovery`` as directories.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls, mock.patch(DISCOVERY_PATH) as discovery_cls: + pipeline_cls.return_value.apply.return_value = ApplyResult(unchanged=True) + _run("--dir", "pkg_a/authz/schema", "--dir", "pkg_b/authz/schema") + + discovery_cls.assert_called_once_with(passed_in_directories=["pkg_a/authz/schema", "pkg_b/authz/schema"]) + # The pipeline is built with that discovery instance. + pipeline_cls.assert_called_once_with(discovery=discovery_cls.return_value) + + def test_no_dir_uses_default_discovery(self): + """Without ``--dir`` the command builds a default ``SchemaDiscovery``.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls, mock.patch(DISCOVERY_PATH) as discovery_cls: + pipeline_cls.return_value.apply.return_value = ApplyResult(unchanged=True) + _run() + + # Default discovery (no explicit directories) is constructed. + discovery_cls.assert_called_once_with() + + +class TestErrorHandling: + """Cover translation of pipeline errors into CommandError. + + Deployment must stop with a readable message rather than a traceback, and + ``SchemaDiscoveryError`` needs handling separately because it does not + inherit from ``SchemaError``. + """ + + def test_schema_error_becomes_command_error(self): + """A validation error during apply is surfaced as ``CommandError``.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.apply.side_effect = SchemaValidationError([]) + with pytest.raises(CommandError): + _run() + + def test_dry_run_error_becomes_command_error(self): + """A validation error during a dry run is surfaced as ``CommandError``.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.side_effect = SchemaValidationError([]) + with pytest.raises(CommandError): + _run("--dry-run") + + def test_discovery_error_becomes_command_error(self): + """ADR 0019 §1: a failing provider stops deployment, naming the app.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.apply.side_effect = SchemaDiscoveryError( + "authz.schema provider 'broken_app' failed during discovery: boom" + ) + with pytest.raises(CommandError, match="broken_app"): + _run() + + def test_discovery_error_in_dry_run_becomes_command_error(self): + """A discovery error during a dry run is surfaced as ``CommandError``.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.side_effect = SchemaDiscoveryError("bad directory") + with pytest.raises(CommandError, match="bad directory"): + _run("--dry-run") + + def test_compile_error_becomes_command_error(self): + """A compile error is surfaced as ``CommandError`` with its message.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.apply.side_effect = SchemaCompileError("equal priority conflict") + with pytest.raises(CommandError, match="equal priority conflict"): + _run() + + def test_apply_error_becomes_command_error(self): + """The force gate surfaces as a message, not a traceback.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.apply.side_effect = SchemaApplyError("Refusing to proceed") + with pytest.raises(CommandError, match="Refusing to proceed"): + _run() + + +class TestOptionCombinations: + """Options compose: a dry run can also take explicit directories.""" + + def test_dry_run_with_dir_plans_against_that_directory(self): + """``--dry-run`` composes with ``--dir``: it plans against the given directory.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls, mock.patch(DISCOVERY_PATH) as discovery_cls: + pipeline_cls.return_value.plan.return_value = ChangePlan(unchanged=True) + _run("--dry-run", "--dir", "pkg_a/authz/schema") + + discovery_cls.assert_called_once_with(passed_in_directories=["pkg_a/authz/schema"]) + pipeline_cls.assert_called_once_with(discovery=discovery_cls.return_value) + pipeline_cls.return_value.plan.assert_called_once_with() + pipeline_cls.return_value.apply.assert_not_called() + + def test_dry_run_ignores_force(self): + """A dry run writes nothing, so force has nothing to authorize.""" + with mock.patch(PIPELINE_PATH) as pipeline_cls: + pipeline_cls.return_value.plan.return_value = ChangePlan(unchanged=True) + _run("--dry-run", "--force") + + pipeline_cls.return_value.plan.assert_called_once_with() + pipeline_cls.return_value.apply.assert_not_called() diff --git a/src/openedx_authz/tests/schema/test_pipeline.py b/src/openedx_authz/tests/schema/test_pipeline.py new file mode 100644 index 00000000..948a7712 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_pipeline.py @@ -0,0 +1,200 @@ +"""Unit tests for the SchemaPipeline orchestrator. + +The pipeline is pure wiring: it sequences discovery -> load -> validate -> +compile -> render -> plan/apply. These tests inject mocked components so the +orchestration (ordering, error propagation, delegation) is verified without a +database, Casbin, or real schema files. +""" + +from unittest import mock + +import pytest + +from openedx_authz.engine.schema.exceptions import SchemaValidationError +from openedx_authz.engine.schema.pipeline import SchemaPipeline +from openedx_authz.engine.schema.validation import ValidationIssue + + +def _pipeline(*, issues=None, compiled_issues=None): + """Build a SchemaPipeline with every component mocked. + + ``issues`` seeds the document-level validator result and ``compiled_issues`` + the post-compile one (both default to none). + """ + discovery = mock.Mock(name="discovery") + discovery.discover.return_value = ["resource"] + + loader = mock.Mock(name="loader") + loader.load.return_value = ["document"] + + validator = mock.Mock(name="validator") + validator.validate.return_value = issues or [] + validator.validate_compiled.return_value = compiled_issues or [] + validator.has_errors.side_effect = lambda found: any(i.is_error for i in found) + + compiler = mock.Mock(name="compiler") + compiler.compile.return_value = "compiled-schema" + + renderer = mock.Mock(name="renderer") + renderer.render.return_value = "rendered-policy" + + applier = mock.Mock(name="applier") + + pipeline = SchemaPipeline( + discovery=discovery, + loader=loader, + validator=validator, + compiler=compiler, + renderer=renderer, + applier=applier, + ) + return pipeline, { + "discovery": discovery, + "loader": loader, + "validator": validator, + "compiler": compiler, + "renderer": renderer, + "applier": applier, + } + + +class TestCompile: + """Cover SchemaPipeline.compile step ordering and validation gating.""" + + def test_runs_steps_in_order_and_returns_compiled_schema(self): + """Compile runs discover -> load -> validate -> compile -> validate_compiled in order.""" + pipeline, m = _pipeline() + + result = pipeline.compile() + + assert result == "compiled-schema" + m["discovery"].discover.assert_called_once_with() + m["loader"].load.assert_called_once_with(["resource"]) + m["validator"].validate.assert_called_once_with(["document"]) + m["compiler"].compile.assert_called_once_with(["document"]) + m["validator"].validate_compiled.assert_called_once_with("compiled-schema") + + def test_raises_when_compiled_schema_has_errors(self): + """The second gate runs on the compiled schema (ADR 0017 §4). + + Extensions and priority resolution can only be checked after they are + applied, so validation runs again post-compile. + """ + error = ValidationIssue("error", "scope not supported", "src") + pipeline, m = _pipeline(compiled_issues=[error]) + + with pytest.raises(SchemaValidationError) as exc_info: + pipeline.compile() + + assert exc_info.value.issues == [error] + m["compiler"].compile.assert_called_once() + + def test_compiled_errors_stop_before_render_and_apply(self): + """A post-compile error aborts apply before rendering or applying.""" + error = ValidationIssue("error", "scope not supported", "src") + pipeline, m = _pipeline(compiled_issues=[error]) + + with pytest.raises(SchemaValidationError): + pipeline.apply() + + m["renderer"].render.assert_not_called() + m["applier"].apply.assert_not_called() + + def test_compiled_warnings_do_not_stop_compilation(self): + """A post-compile warning is non-fatal; compilation still returns the schema.""" + warning = ValidationIssue("warning", "heads up", "src") + pipeline, _ = _pipeline(compiled_issues=[warning]) + + assert pipeline.compile() == "compiled-schema" + + def test_document_errors_skip_the_compiled_check(self): + """A failed first gate must not reach the second one.""" + error = ValidationIssue("error", "boom", "src") + pipeline, m = _pipeline(issues=[error]) + + with pytest.raises(SchemaValidationError): + pipeline.compile() + + m["validator"].validate_compiled.assert_not_called() + + def test_raises_when_validation_has_errors(self): + """A document-level validation error stops before compilation runs.""" + error = ValidationIssue("error", "boom", "src") + pipeline, m = _pipeline(issues=[error]) + + with pytest.raises(SchemaValidationError) as exc_info: + pipeline.compile() + + # Only error-level issues are carried on the exception. + assert exc_info.value.issues == [error] + # Compilation must not run once validation fails. + m["compiler"].compile.assert_not_called() + + def test_warning_only_issues_do_not_stop_compilation(self): + """A document-level warning is non-fatal; compilation still proceeds.""" + warning = ValidationIssue("warning", "heads up", "src") + pipeline, m = _pipeline(issues=[warning]) + + result = pipeline.compile() + + assert result == "compiled-schema" + m["compiler"].compile.assert_called_once() + + +class TestPlan: + """Cover SchemaPipeline.plan delegation to render + applier.plan.""" + + def test_delegates_to_renderer_and_applier_plan(self): + """Plan renders the compiled schema and delegates to ``applier.plan``.""" + pipeline, m = _pipeline() + + result = pipeline.plan() + + m["renderer"].render.assert_called_once_with("compiled-schema") + # The schema goes along with the rendered rows so the report can cover + # definition changes, not just policy rows (ADR 0018 §6). + m["applier"].plan.assert_called_once_with("rendered-policy", "compiled-schema") + assert result is m["applier"].plan.return_value + + def test_plan_does_not_apply(self): + """Plan is read-only: it never calls ``applier.apply``.""" + pipeline, m = _pipeline() + pipeline.plan() + m["applier"].apply.assert_not_called() + + +class TestApply: + """Cover SchemaPipeline.apply delegation and force forwarding.""" + + def test_delegates_to_applier_apply_without_force(self): + """Apply renders the schema and delegates to ``applier.apply`` with ``force=False``.""" + pipeline, m = _pipeline() + + result = pipeline.apply() + + m["renderer"].render.assert_called_once_with("compiled-schema") + m["applier"].apply.assert_called_once_with("rendered-policy", "compiled-schema", force=False) + assert result is m["applier"].apply.return_value + + def test_forwards_force_flag(self): + """The ``force`` flag is forwarded to ``applier.apply``.""" + pipeline, m = _pipeline() + pipeline.apply(force=True) + m["applier"].apply.assert_called_once_with("rendered-policy", "compiled-schema", force=True) + + +class TestDefaultComponents: + """Constructing a pipeline without injected components wires real defaults.""" + + def test_default_components_are_constructed_when_not_injected(self): + """A bare SchemaPipeline wires real default components (smoke test).""" + pipeline = SchemaPipeline() + # Internal defaults exist; we don't run them here (that needs real data), + # only assert the orchestrator is fully constructed. + # pylint: disable=protected-access + assert pipeline._discovery is not None + assert pipeline._loader is not None + assert pipeline._validator is not None + assert pipeline._compiler is not None + assert pipeline._renderer is not None + assert pipeline._applier is not None diff --git a/src/openedx_authz/tests/schema/test_renderer.py b/src/openedx_authz/tests/schema/test_renderer.py new file mode 100644 index 00000000..f3cbb651 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_renderer.py @@ -0,0 +1,168 @@ +"""Unit tests for the (pure) render step and renderer helper methods. + +Covers turning a compiled schema into Casbin ``p`` rows: one row per +role-permission-scope, the ``role^``/``act^``/``^*`` namespacing convention, +deterministic output, and scope fan-out; plus the ``SchemaApplier`` helpers for +enforcer resolution and role-assignment-deleted event emission. +""" + +import types +from unittest import mock + +from openedx_authz.engine.renderer import PolicyRenderer, PolicyRow, SchemaApplier +from openedx_authz.engine.schema.compilation import SchemaCompiler + +from .factories import category, make_document, permission, role + + +class TestPolicyRow: + """Constructing and round-tripping a single Casbin ``p`` row.""" + + def test_from_policy_round_trips_as_policy(self): + """A row rebuilt from ``as_policy`` output equals the original.""" + row = PolicyRow("p", "role^course_editor", "act^courses.view_course", "course-v1^*", "allow") + assert PolicyRow.from_policy(row.as_policy()) == row + + def test_from_policy_reads_a_stored_row(self): + """A stored ``[subject, action, scope, effect]`` row maps to its fields.""" + row = PolicyRow.from_policy(["role^r", "act^p", "course-v1^*", "allow"]) + assert row.ptype == "p" + assert (row.subject, row.action, row.scope, row.effect) == ("role^r", "act^p", "course-v1^*", "allow") + + def test_from_policy_pads_short_rows_with_empty_strings(self): + """A row with fewer than four values is padded rather than raising.""" + row = PolicyRow.from_policy(["role^r", "act^p"]) + assert (row.subject, row.action, row.scope, row.effect) == ("role^r", "act^p", "", "") + + +class TestPolicyRendering: + """Rendering a compiled schema into Casbin ``p`` policy rows.""" + + @staticmethod + def _schema(): + """Build a compiled schema fixture for renderer tests.""" + doc = make_document( + categories=[category("cat")], + permissions=[ + permission(name="view_course", cat="cat", scopes=("course-v1",)), + permission(name="edit_course_content", cat="cat", scopes=("course-v1",)), + ], + roles=[ + role( + rid="course_editor", + scopes=("course-v1",), + permissions=("courses.view_course", "courses.edit_course_content"), + ) + ], + ) + return SchemaCompiler().compile([doc]) + + def test_render_emits_one_p_row_per_role_permission_scope(self): + """Each role-permission-scope combination becomes one allow ``p`` row.""" + rendered = PolicyRenderer().render(self._schema()) + assert len(rendered.rows) == 2 + assert all(row.ptype == "p" and row.effect == "allow" for row in rendered.rows) + + def test_render_applies_casbin_namespacing(self): + """Subjects, actions, and scopes carry their Casbin namespace prefixes.""" + rendered = PolicyRenderer().render(self._schema()) + row = next(r for r in rendered.rows if r.action == "act^courses.view_course") + assert row.subject == "role^course_editor" + assert row.scope == "course-v1^*" + assert row.as_policy() == ["role^course_editor", "act^courses.view_course", "course-v1^*", "allow"] + + def test_render_is_deterministic(self): + """Rendering the same schema twice yields identical rows.""" + schema = self._schema() + assert PolicyRenderer().render(schema).rows == PolicyRenderer().render(schema).rows + + def test_multiple_scopes_multiply_rows(self): + """A permission spanning multiple scopes fans out into one row per scope.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(name="view_course", cat="cat", scopes=("course-v1", "ccx-v1"))], + roles=[role(rid="r", scopes=("course-v1", "ccx-v1"), permissions=("courses.view_course",))], + ) + rendered = PolicyRenderer().render(SchemaCompiler().compile([doc])) + scopes = {row.scope for row in rendered.rows} + assert scopes == {"course-v1^*", "ccx-v1^*"} + + +class TestResolveEnforcer: + """``SchemaApplier._resolve_enforcer``: injected vs. lazily resolved enforcer.""" + + def test_returns_injected_enforcer_without_importing(self): + """An enforcer passed in is returned as-is (no lazy resolution).""" + sentinel = object() + applier = SchemaApplier(enforcer=sentinel) + + # Patch the enforcer accessor to prove it is never touched. + with mock.patch("openedx_authz.engine.renderer.AuthzEnforcer") as authz_enforcer: + assert applier._resolve_enforcer() is sentinel # pylint: disable=protected-access + authz_enforcer.get_enforcer.assert_not_called() + + def test_lazily_resolves_when_enforcer_is_none(self): + """When no enforcer was injected, it is fetched via AuthzEnforcer and cached.""" + resolved = object() + applier = SchemaApplier() # enforcer defaults to None + + with mock.patch("openedx_authz.engine.renderer.AuthzEnforcer") as authz_enforcer: + authz_enforcer.get_enforcer.return_value = resolved + + first = applier._resolve_enforcer() # pylint: disable=protected-access + second = applier._resolve_enforcer() # pylint: disable=protected-access + + assert first is resolved + # Cached after the first resolution: only one lookup despite two calls. + assert second is resolved + authz_enforcer.get_enforcer.assert_called_once_with() + + +class TestEmitAssignmentDeleted: + """``SchemaApplier._emit_assignment_deleted``: one event per removed assignment.""" + + def test_no_op_when_no_assignments(self): + """Empty input emits nothing: the signal is never sent.""" + with mock.patch("openedx_authz.engine.renderer.ROLE_ASSIGNMENT_DELETED") as signal: + SchemaApplier._emit_assignment_deleted([]) # pylint: disable=protected-access + signal.send_event.assert_not_called() + + def test_emits_one_event_per_removed_assignment(self): + """Each removed (subject, role, scope) triple sends a ROLE_ASSIGNMENT_DELETED.""" + removed = [ + ("user^alice", "role^course_editor", "course-v1^course-v1:Org+C+R"), + ("user^bob", "role^course_auditor", "course-v1^*"), + ] + + with ( + mock.patch( + "openedx_authz.engine.renderer.get_current_user", + return_value=types.SimpleNamespace(id=42), + ), + mock.patch("openedx_authz.engine.renderer.RoleAssignmentEventData") as role_assignment_data, + mock.patch("openedx_authz.engine.renderer.ROLE_ASSIGNMENT_DELETED") as signal, + ): + SchemaApplier._emit_assignment_deleted(removed) # pylint: disable=protected-access + + assert signal.send_event.call_count == 2 + + # Verify field mapping for the first emitted event. + first_event_data = role_assignment_data.call_args_list[0].kwargs + assert first_event_data["operation"] == "deleted" + assert first_event_data["subject"] == "user^alice" + assert first_event_data["role"] == "role^course_editor" + assert first_event_data["scope"] == "course-v1^course-v1:Org+C+R" + assert first_event_data["actor_id"] == 42 + + def test_actor_id_none_when_no_current_user(self): + """A missing current user yields actor_id=None on the event.""" + removed = [("user^alice", "role^course_editor", "course-v1^*")] + + with ( + mock.patch("openedx_authz.engine.renderer.get_current_user", return_value=None), + mock.patch("openedx_authz.engine.renderer.RoleAssignmentEventData") as role_assignment_data, + mock.patch("openedx_authz.engine.renderer.ROLE_ASSIGNMENT_DELETED"), + ): + SchemaApplier._emit_assignment_deleted(removed) # pylint: disable=protected-access + + assert role_assignment_data.call_args_list[0].kwargs["actor_id"] is None diff --git a/src/openedx_authz/tests/schema/test_schema_models.py b/src/openedx_authz/tests/schema/test_schema_models.py new file mode 100644 index 00000000..47480af6 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_schema_models.py @@ -0,0 +1,145 @@ +"""Unit tests for the authz schema definition models (``openedx_authz.models.schema``). + +These cover the model layer in isolation — the string representations, the +derived-identifier properties, and the ``origins_*`` query helpers — by writing +rows directly through the ORM. The applier that populates these tables from a +compiled schema is exercised separately. +""" + +import pytest + +from openedx_authz.constants import SchemaOriginKind +from openedx_authz.models.schema import ( + AuthzCategorySource, + AuthzPermissionCategory, + AuthzPermissionDefinition, + AuthzPermissionSource, + AuthzRoleDefinition, + AuthzRolePermission, + AuthzRolePermissionSource, + AuthzRoleSource, + AuthzSchemaSource, + OriginKind, + origin_for_role_permission, + origins_for_category, + origins_for_permission, + origins_for_role, +) + +pytestmark = pytest.mark.django_db + + +def _source(distribution="openedx-authz", module="openedx_authz.authz"): + return AuthzSchemaSource.objects.create(distribution=distribution, module=module) + + +def _permission(namespace="courses", name="view_course", category=None): + return AuthzPermissionDefinition.objects.create( + namespace=namespace, + name=name, + display_name=name.replace("_", " ").title(), + category=category, + ) + + +class TestOriginKind: + """OriginKind mirrors the Django-free SchemaOriginKind constant.""" + + def test_values_match_the_shared_constant(self): + """The Django enum values mirror the Django-free ``SchemaOriginKind``.""" + assert OriginKind.BASE.value == SchemaOriginKind.BASE.value + assert OriginKind.EXTENSION.value == SchemaOriginKind.EXTENSION.value + + def test_labels_are_human_readable(self): + """Each origin kind exposes a human-readable label.""" + assert OriginKind.BASE.label == "Base" + assert OriginKind.EXTENSION.label == "Extension" + + +class TestStringRepresentations: + """Every model's ``__str__`` and derived-id property.""" + + def test_source_id_replaces_dots_with_slashes(self): + """A source's id renders the dotted module as a slash path.""" + source = _source(module="openedx_authz.authz") + assert source.source_id == "openedx-authz:openedx_authz/authz" + assert str(source) == "openedx-authz:openedx_authz/authz" + + def test_category_str_is_its_id(self): + """A category stringifies to its stable id.""" + category = AuthzPermissionCategory.objects.create(category_id="content", display_name="Content") + assert str(category) == "content" + + def test_permission_identifier_is_namespace_dot_name(self): + """A permission's identifier and str are its ``namespace.name``.""" + permission = _permission(namespace="courses", name="view_course") + assert permission.identifier == "courses.view_course" + assert str(permission) == "courses.view_course" + + def test_role_str_is_its_id(self): + """A role stringifies to its stable role id.""" + role = AuthzRoleDefinition.objects.create(role_id="course_editor", display_name="Course Editor") + assert str(role) == "course_editor" + + def test_role_permission_str_traverses_to_stable_identifiers(self): + """A grant stringifies as ``role -> permission @ scope`` using stable ids.""" + role = AuthzRoleDefinition.objects.create(role_id="course_editor", display_name="Course Editor") + permission = _permission() + grant = AuthzRolePermission.objects.create(role=role, permission=permission, scope="course-v1") + assert str(grant) == "course_editor -> courses.view_course @ course-v1" + + +class TestOriginHelpers: + """The four ``origins_*`` query helpers resolve contributing distributions.""" + + def test_origins_for_role_returns_sorted_distinct_distributions(self): + """``origins_for_role`` returns each contributing distribution once, sorted.""" + role = AuthzRoleDefinition.objects.create(role_id="course_editor", display_name="Course Editor") + core = _source(distribution="openedx-authz") + plugin = _source(distribution="my-plugin", module="my_plugin.authz") + # Two sources, added out of order, to prove sorting and distinctness. + AuthzRoleSource.objects.create(role=role, source=plugin, origin_kind=OriginKind.EXTENSION) + AuthzRoleSource.objects.create(role=role, source=core, origin_kind=OriginKind.BASE) + assert origins_for_role("course_editor") == ["my-plugin", "openedx-authz"] + + def test_origins_for_role_empty_when_unknown(self): + """``origins_for_role`` returns an empty list for an unknown role.""" + assert origins_for_role("does_not_exist") == [] + + def test_origins_for_permission_matches_by_complete_id(self): + """``origins_for_permission`` matches on the full ``namespace.name`` id.""" + permission = _permission(namespace="courses", name="view_course") + source = _source() + AuthzPermissionSource.objects.create(permission=permission, source=source) + assert origins_for_permission("courses.view_course") == ["openedx-authz"] + # A different namespace with the same name must not match. + assert origins_for_permission("libraries.view_course") == [] + + def test_origins_for_category_matches_by_id(self): + """``origins_for_category`` matches on the category id.""" + category = AuthzPermissionCategory.objects.create(category_id="content", display_name="Content") + source = _source() + AuthzCategorySource.objects.create(category=category, source=source) + assert origins_for_category("content") == ["openedx-authz"] + assert origins_for_category("missing") == [] + + def test_origin_for_role_permission_isolates_one_grant(self): + """``origin_for_role_permission`` reports sources for one grant, not sibling grants.""" + role = AuthzRoleDefinition.objects.create(role_id="course_editor", display_name="Course Editor") + granted = _permission(namespace="courses", name="view_course") + other = _permission(namespace="courses", name="edit_course_content") + core = _source(distribution="openedx-authz") + plugin = _source(distribution="my-plugin", module="my_plugin.authz") + + granted_row = AuthzRolePermission.objects.create(role=role, permission=granted, scope="course-v1") + other_row = AuthzRolePermission.objects.create(role=role, permission=other, scope="course-v1") + # The queried grant is contributed by both distributions; the other by core only. + AuthzRolePermissionSource.objects.create(role_permission=granted_row, source=core, origin_kind=OriginKind.BASE) + AuthzRolePermissionSource.objects.create( + role_permission=granted_row, source=plugin, origin_kind=OriginKind.EXTENSION + ) + AuthzRolePermissionSource.objects.create(role_permission=other_row, source=core, origin_kind=OriginKind.BASE) + + assert origin_for_role_permission("course_editor", "courses.view_course") == ["my-plugin", "openedx-authz"] + # The same role, a different permission, is not swept in. + assert origin_for_role_permission("course_editor", "courses.edit_course_content") == ["openedx-authz"] diff --git a/src/openedx_authz/tests/schema/test_source_storage.py b/src/openedx_authz/tests/schema/test_source_storage.py new file mode 100644 index 00000000..f06763c3 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_source_storage.py @@ -0,0 +1,343 @@ +"""Tests for persisting compiled definitions and their sources (ADR 0025). + +These exercise ``SchemaApplier._store_sources`` directly (it performs only ORM +upserts, no enforcer access) plus the origin query helpers. The full ``apply`` +path (enforcer + p rows) is covered by the engine tests. +""" + +from dataclasses import replace + +from django.test import TestCase + +from openedx_authz.engine.renderer import SchemaApplier +from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.models.schema import ( + AuthzPermissionCategory, + AuthzPermissionDefinition, + AuthzRoleDefinition, + AuthzRolePermission, + AuthzRolePermissionSource, + AuthzRoleSource, + AuthzSchemaSource, + OriginKind, + origin_for_role_permission, + origins_for_category, + origins_for_permission, + origins_for_role, +) + +from .factories import category, extension, make_document, permission, role + +CORE_PERMS = [ + permission(name="view_course", cat="cat"), + permission(name="manage_tags", cat="cat"), + permission(name="export_course", cat="cat"), +] + + +def _core_doc(): + return make_document( + "core", + priority=100, + categories=[category("cat")], + permissions=CORE_PERMS, + roles=[role(rid="course_admin", permissions=("courses.view_course", "courses.manage_tags"))], + ) + + +def _module_extension_doc(): + return make_document( + "modx", + priority=200, + role_extensions=[extension("course_admin", add_permissions=("courses.export_course",))], + ) + + +def _store(*documents): + schema = SchemaCompiler().compile(list(documents)) + SchemaApplier()._store_sources(schema) # pylint: disable=protected-access + return schema + + +class TestStoreSources(TestCase): + """Persistence of compiled definitions and their provenance.""" + + def test_definitions_are_persisted(self): + """Storing a document writes its roles, permissions, and grants to the DB.""" + _store(_core_doc()) + self.assertEqual(AuthzRoleDefinition.objects.count(), 1) + self.assertEqual(AuthzPermissionDefinition.objects.count(), 3) + role_obj = AuthzRoleDefinition.objects.get(role_id="course_admin") + # course_admin has 2 permissions x 1 scope = 2 grants. + self.assertEqual(role_obj.role_permissions.count(), 2) + + def test_source_identity_is_distribution_and_module(self): + """A source row is keyed by its distribution and module, not its file path.""" + _store(_core_doc()) + source = AuthzSchemaSource.objects.get() + self.assertEqual(source.distribution, "test-dist") + self.assertEqual(source.module, "pkg.core") + + def test_extension_grant_attributed_to_module_not_core(self): + """A grant added by an extension records its own origin_kind and priority. + + The base grant stays BASE while the extension-contributed grant is + marked EXTENSION with the extending module's priority. + """ + _store(_core_doc(), _module_extension_doc()) + + # Both grants live on course_admin, with distinct origins. + self.assertEqual(origin_for_role_permission("course_admin", "courses.view_course"), ["test-dist"]) + self.assertEqual(origin_for_role_permission("course_admin", "courses.export_course"), ["test-dist"]) + + export_grant = AuthzRolePermission.objects.get( + role__role_id="course_admin", permission__namespace="courses", permission__name="export_course" + ) + link = AuthzRolePermissionSource.objects.get(role_permission=export_grant) + self.assertEqual(link.origin_kind, OriginKind.EXTENSION) + self.assertEqual(link.priority, 200) + + view_grant = AuthzRolePermission.objects.get(role__role_id="course_admin", permission__name="view_course") + view_link = AuthzRolePermissionSource.objects.get(role_permission=view_grant) + self.assertEqual(view_link.origin_kind, OriginKind.BASE) + + def test_origin_query_helpers(self): + """The role- and permission-level origin helpers report the contributing distribution.""" + _store(_core_doc(), _module_extension_doc()) + self.assertEqual(origins_for_role("course_admin"), ["test-dist"]) + self.assertEqual(origins_for_permission("courses.export_course"), ["test-dist"]) + + def test_store_is_idempotent(self): + """Re-storing the same documents leaves all row counts unchanged.""" + _store(_core_doc(), _module_extension_doc()) + counts = ( + AuthzRoleDefinition.objects.count(), + AuthzPermissionDefinition.objects.count(), + AuthzRolePermission.objects.count(), + AuthzRolePermissionSource.objects.count(), + AuthzSchemaSource.objects.count(), + ) + _store(_core_doc(), _module_extension_doc()) + counts_again = ( + AuthzRoleDefinition.objects.count(), + AuthzPermissionDefinition.objects.count(), + AuthzRolePermission.objects.count(), + AuthzRolePermissionSource.objects.count(), + AuthzSchemaSource.objects.count(), + ) + self.assertEqual(counts, counts_again) + + def test_metadata_change_updates_in_place(self): + """Re-storing with changed metadata updates the existing row instead of adding one.""" + _store(_core_doc()) + changed = make_document( + "core", + priority=100, + categories=[category("cat")], + permissions=CORE_PERMS, + roles=[ + role( + rid="course_admin", + display_name="Course Administrator", + permissions=("courses.view_course", "courses.manage_tags"), + ) + ], + ) + _store(changed) + self.assertEqual(AuthzRoleDefinition.objects.count(), 1) + self.assertEqual(AuthzRoleDefinition.objects.get(role_id="course_admin").display_name, "Course Administrator") + + def test_moving_definition_between_files_keeps_single_source(self): + """Moving a definition to another file in the same module reuses its source row.""" + # Same module, different resource_path -> identity unchanged. + doc_a = _core_doc() + doc_b = make_document( + "core", # same module name -> same (distribution, module) + priority=100, + categories=[category("cat")], + permissions=CORE_PERMS, + roles=[role(rid="course_admin", permissions=("courses.view_course", "courses.manage_tags"))], + ) + doc_b.source = doc_b.source.__class__(**{**doc_b.source.__dict__, "resource_path": "moved.authz.yaml"}) + _store(doc_a) + _store(doc_b) + self.assertEqual(AuthzSchemaSource.objects.count(), 1) + + +class TestSourceGranularity(TestCase): + """Source identity is per module, not per file (ADR 0025 §2). + + ``resource_path`` and ``content_digest`` are explicitly non-identifying, so + several files in one module collapse into a single source row. This is what + lets a definition move between files without churn, and it means those two + advisory fields hold whichever file was processed last. + """ + + @staticmethod + def _same_module(name: str, resource_path: str, roles): + """Build a document in module ``pkg.`` with an explicit file path. + + Each file gets its own digest so the per-module collapse is observable. + """ + document = make_document(name, priority=100, categories=[category("cat")], permissions=CORE_PERMS, roles=roles) + document.source = replace( + document.source, resource_path=resource_path, content_digest=f"digest-{resource_path}" + ) + return document + + def test_multiple_files_in_one_module_share_one_source_row(self): + """Several files in one module collapse into a single source row.""" + roles_file = self._same_module("core", "roles.yaml", [role(rid="course_admin")]) + extra_file = self._same_module("core", "more_roles.yaml", [role(rid="course_auditor")]) + + _store(roles_file, extra_file) + + self.assertEqual(AuthzSchemaSource.objects.count(), 1) + self.assertEqual(AuthzRoleDefinition.objects.count(), 2) + + def test_advisory_fields_come_from_the_first_file_of_the_module(self): + """Why the digest is advisory, not a change-detection signal. + + One source row covers the whole module, and the per-apply cache fills it + from whichever of the module's files is processed first. So the stored + ``resource_path``/``content_digest`` describe one file out of several and + cannot represent the module's contents — change detection diffs compiled + definitions instead (ADR 0025 §2). + """ + roles_file = self._same_module("core", "roles.yaml", [role(rid="course_admin")]) + extra_file = self._same_module("core", "more_roles.yaml", [role(rid="course_auditor")]) + + _store(roles_file, extra_file) + + source = AuthzSchemaSource.objects.get() + self.assertEqual(source.resource_path, "roles.yaml") + self.assertNotEqual(source.content_digest, extra_file.source.content_digest) + + def test_distinct_modules_get_distinct_source_rows(self): + """Definitions from different modules produce separate source rows.""" + first = self._same_module("core", "roles.yaml", [role(rid="course_admin")]) + second = self._same_module("other", "roles.yaml", [role(rid="course_auditor")]) + + _store(first, second) + + self.assertEqual(AuthzSchemaSource.objects.count(), 2) + self.assertEqual(sorted(AuthzSchemaSource.objects.values_list("module", flat=True)), ["pkg.core", "pkg.other"]) + + def test_shared_definition_gains_a_link_per_contributing_module(self): + """ADR 0025 §2: the many-to-many exists to represent shared ownership.""" + first = self._same_module("core", "roles.yaml", [role(rid="course_admin")]) + second = self._same_module("other", "roles.yaml", [role(rid="course_admin")]) + + _store(first, second) + + role_obj = AuthzRoleDefinition.objects.get(role_id="course_admin") + self.assertEqual(AuthzRoleSource.objects.filter(role=role_obj).count(), 2) + + def test_shared_grant_gains_a_source_link_per_module(self): + """A grant defined by two modules gets one source link per contributing module.""" + admin = [role(rid="course_admin", permissions=("courses.view_course",))] + first = self._same_module("core", "roles.yaml", admin) + second = self._same_module("other", "roles.yaml", admin) + + _store(first, second) + + grant = AuthzRolePermission.objects.get(role__role_id="course_admin", permission__name="view_course") + self.assertEqual(AuthzRolePermissionSource.objects.filter(role_permission=grant).count(), 2) + self.assertEqual(sorted(origin_for_role_permission("course_admin", "courses.view_course")), ["test-dist"]) + + def test_category_origins_are_queryable(self): + """The category origin helper reports the contributing distribution.""" + _store(_core_doc()) + + self.assertEqual(origins_for_category("cat"), ["test-dist"]) + + def test_source_rows_survive_definition_pruning(self): + """Sources are shared and carry no access, so they are never pruned.""" + _store(_core_doc()) + self.assertEqual(AuthzSchemaSource.objects.count(), 1) + + _store() + + self.assertEqual(AuthzRoleDefinition.objects.count(), 0) + self.assertEqual(AuthzSchemaSource.objects.count(), 1) + + def test_hidden_flag_reaches_the_database(self): + """ADR 0023 §1: ``hidden`` is compiled state that has to be persisted.""" + _store(self._same_module("core", "roles.yaml", [role(rid="course_auditor", hidden=True)])) + + self.assertTrue(AuthzRoleDefinition.objects.get(role_id="course_auditor").hidden) + + def test_hidden_flag_can_be_cleared(self): + """Re-storing a role with hidden=False clears a previously persisted hidden flag.""" + _store(self._same_module("core", "roles.yaml", [role(rid="course_auditor", hidden=True)])) + + _store(self._same_module("core", "roles.yaml", [role(rid="course_auditor", hidden=False)])) + + self.assertFalse(AuthzRoleDefinition.objects.get(role_id="course_auditor").hidden) + + +class TestDefinitionDisplay(TestCase): + """Human-readable identifiers used by the Django admin fallback (ADR 0018 §7).""" + + def test_source_string_is_distribution_and_module_path(self): + """A source renders as ``distribution:module/path`` for its id and str().""" + _store(_core_doc()) + + source = AuthzSchemaSource.objects.get() + self.assertEqual(source.source_id, "test-dist:pkg/core") + self.assertEqual(str(source), "test-dist:pkg/core") + + def test_permission_string_is_its_complete_id(self): + """A permission renders as its full ``namespace.name`` identifier.""" + _store(_core_doc()) + + perm = AuthzPermissionDefinition.objects.get(namespace="courses", name="view_course") + self.assertEqual(perm.identifier, "courses.view_course") + self.assertEqual(str(perm), "courses.view_course") + + def test_role_and_category_strings_are_their_stable_ids(self): + """Roles and categories render as their stable string ids.""" + _store(_core_doc()) + + self.assertEqual(str(AuthzRoleDefinition.objects.get(role_id="course_admin")), "course_admin") + self.assertEqual(str(AuthzPermissionCategory.objects.get(category_id="cat")), "cat") + + def test_grant_string_names_role_permission_and_scope(self): + """Regression: this used to render the FK integers, not the identifiers.""" + _store(_core_doc()) + + grant = AuthzRolePermission.objects.get(role__role_id="course_admin", permission__name="view_course") + self.assertEqual(str(grant), "course_admin -> courses.view_course @ course-v1") + + +class TestDefensiveStorage(TestCase): + """Paths guarded against states validation is expected to have rejected.""" + + def test_grant_for_an_undefined_permission_is_skipped(self): + """A role listing a permission with no definition writes no grant.""" + document = make_document( + "core", + priority=100, + categories=[category("cat")], + permissions=[], + roles=[role(rid="course_admin", permissions=("courses.ghost",))], + ) + + _store(document) + + self.assertTrue(AuthzRoleDefinition.objects.filter(role_id="course_admin").exists()) + self.assertEqual(AuthzRolePermission.objects.count(), 0) + + def test_permission_with_an_unknown_category_is_stored_uncategorized(self): + """A permission referencing a missing category is stored with no category.""" + document = make_document( + "core", + priority=100, + categories=[], + permissions=[permission(name="view_course", cat="missing")], + roles=[], + ) + + _store(document) + + self.assertIsNone(AuthzPermissionDefinition.objects.get(name="view_course").category) diff --git a/src/openedx_authz/tests/schema/test_validation.py b/src/openedx_authz/tests/schema/test_validation.py new file mode 100644 index 00000000..1b8a3da4 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_validation.py @@ -0,0 +1,491 @@ +"""Unit tests for the schema validation step. + +Grouped by concern: per-document validation, duplicate severity, identifier and +scope rules, required fields, extension references, post-compile validation, and +the ``has_errors`` gate the pipeline uses to decide whether to stop. +""" + +import pytest + +from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.engine.schema.validation import ( + IssueLevel, + SchemaValidator, + ValidationIssue, +) + +from .factories import category, extension, make_document, make_source, permission, role + + +def _errors(issues): + return [i for i in issues if i.is_error] + + +def _warnings(issues): + return [i for i in issues if not i.is_error] + + +class TestDocumentValidation: + """Per-document ``validate`` checks (ADR 0017 §4). + + These run before compilation and see each document's own declarations, so + they cover identifier shape, Casbin-form rejection, intra-document + references (category, permission, role, scope), and duplicate conflicts. + """ + + def test_valid_document_has_no_errors(self): + """A well-formed document produces no error issues.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat")], + roles=[role(permissions=("courses.view_course",))], + ) + assert not _errors(SchemaValidator().validate([doc])) + + def test_unsupported_schema_version_is_error(self): + """A ``schema_version`` the validator cannot read is rejected.""" + doc = make_document(schema_version="9.9", categories=[category()]) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("Unsupported schema_version" in m for m in messages) + + def test_non_snakecase_identifier_is_error(self): + """Identifiers must be lowercase snake_case.""" + doc = make_document(permissions=[permission(namespace="Courses")]) + assert _errors(SchemaValidator().validate([doc])) + + def test_casbin_internal_form_rejected(self): + """An identifier using an internal Casbin form (``act^``) is rejected.""" + doc = make_document(categories=[category("act^foo")]) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("internal Casbin form" in m for m in messages) + + def test_unknown_category_reference_is_error(self): + """A permission referencing a category not defined in the schema fails.""" + doc = make_document(permissions=[permission(cat="missing")]) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("unknown category" in m for m in messages) + + def test_unknown_permission_in_role_is_error(self): + """A role listing a permission that does not exist fails.""" + doc = make_document(roles=[role(permissions=("courses.nope",))]) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("unknown permission" in m for m in messages) + + def test_role_scope_not_supported_by_permission_is_error(self): + """A role cannot grant a permission outside the permission's scopes.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat", scopes=("course-v1",))], + roles=[role(rid="r", scopes=("lib",), permissions=("courses.view_course",))], + ) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("does not support" in m for m in messages) + + def test_extension_targeting_unknown_role_is_error(self): + """An extension for a role absent from the same document fails here.""" + doc = make_document(role_extensions=[extension("ghost", add_permissions=("courses.view_course",))]) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("unknown role" in m for m in messages) + + def test_missing_scope_is_error(self): + """A permission must declare at least one scope.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat", scopes=())], + ) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("at least one scope" in m for m in messages) + + def test_conflicting_duplicate_definition_is_error(self): + """Two definitions with the same id but different content conflict.""" + doc = make_document( + categories=[category("cat")], + permissions=[ + permission(cat="cat", display_name="One"), + permission(cat="cat", display_name="Two"), # same id, different content + ], + ) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("Conflicting permission" in m for m in messages) + + +class TestDuplicateSeverity: + """ADR 0017 §4 splits duplicates by severity: identical warns, differing fails.""" + + def test_identical_duplicate_warns_instead_of_failing(self): + """Two packages shipping the same definition is legal, not an error.""" + first = make_document("first", categories=[category("cat")]) + second = make_document("second", categories=[category("cat")]) + + issues = SchemaValidator().validate([first, second]) + + assert not _errors(issues) + assert any("Duplicate identical category" in i.message for i in _warnings(issues)) + + def test_identical_duplicate_role_warns(self): + """Two identical role definitions warn rather than error.""" + first = make_document("first", roles=[role()]) + second = make_document("second", roles=[role()]) + + issues = SchemaValidator().validate([first, second]) + + assert any("Duplicate identical role" in i.message for i in _warnings(issues)) + + def test_conflicting_duplicate_role_is_error(self): + """Two role definitions with the same id but different content conflict.""" + first = make_document("first", roles=[role(display_name="Editor")]) + second = make_document("second", roles=[role(display_name="Author")]) + + messages = [i.message for i in _errors(SchemaValidator().validate([first, second]))] + + assert any("Conflicting role definition" in m for m in messages) + + def test_warning_names_the_second_source(self): + """The duplicate warning is attributed to the later contributing source.""" + first = make_document("first", categories=[category("cat")]) + second = make_document("second", categories=[category("cat")]) + + issues = _warnings(SchemaValidator().validate([first, second])) + + assert [i.source_id for i in issues] == [make_source("second").source_id] + + +class TestIdentifierAndScopeRules: + """Field-shape rules from ADR 0017 §4.""" + + @pytest.mark.parametrize("value", ["courses", "courses.view.course", "courses.", ".view", "courses..view", ""]) + def test_permission_id_must_be_namespace_dot_name(self, value): + """A permission id must be exactly two identifiers joined by one period.""" + doc = make_document(roles=[role(permissions=(value,))]) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("permission id" in m and "must match" in m for m in messages) + + def test_permission_id_halves_are_validated(self): + """Both halves of a permission id must be lowercase snake_case.""" + doc = make_document(roles=[role(permissions=("Courses.View_Course",))]) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("lowercase snake_case" in m for m in messages) + + @pytest.mark.parametrize("prefix", ["act^", "role^", "sub^", "scope^", "g^", "p^"]) + def test_every_casbin_prefix_is_rejected(self, prefix): + """Every reserved Casbin prefix (act^, role^, ...) is rejected in an id.""" + doc = make_document(roles=[role(rid=f"{prefix}thing")]) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("internal Casbin form" in m for m in messages) + + @pytest.mark.parametrize("scope", ["Course-V1", "1course", "course v1", "course.v1"]) + def test_invalid_scope_namespace_is_error(self, scope): + """A malformed scope namespace (caps, leading digit, spaces, dots) is rejected.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat", scopes=(scope,))], + ) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("invalid scope namespace" in m for m in messages) + + def test_hyphenated_scope_is_allowed(self): + """Scope namespaces keep their registered spelling, e.g. ``course-v1``.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat", scopes=("course-v1",))], + ) + + assert not _errors(SchemaValidator().validate([doc])) + + def test_role_without_scopes_is_error(self): + """A role must declare at least one scope.""" + doc = make_document(roles=[role(scopes=())]) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("at least one scope" in m for m in messages) + + +class TestRequiredFields: + """The subset of required fields the validator enforces today. + + Fuller required-field coverage (display fields, unknown keys, sizes) arrives + with JSON Schema validation; these pin the rules already in place. + """ + + def test_empty_category_id_is_error(self): + """A category with an empty id is a missing-required-field error.""" + doc = make_document(categories=[category("")]) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("Missing required field: category id" in m for m in messages) + + def test_permission_without_a_category_is_error(self): + """A permission missing its required category is an error.""" + doc = make_document(permissions=[permission(cat="")]) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("Missing required field: category for courses.view_course" in m for m in messages) + + +class TestExtensionReferences: + """ADR 0023 §3: an extension must target a real role and real permissions.""" + + def test_added_permission_must_exist(self): + """An extension that adds a nonexistent permission is an error.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat")], + roles=[role()], + role_extensions=[extension("course_editor", add_permissions=("courses.ghost",))], + ) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("references unknown permission 'courses.ghost'" in m for m in messages) + + def test_removed_permission_must_exist(self): + """An extension that removes a nonexistent permission is an error.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat")], + roles=[role()], + role_extensions=[extension("course_editor", remove_permissions=("courses.ghost",))], + ) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("references unknown permission 'courses.ghost'" in m for m in messages) + + def test_extension_may_target_a_role_from_another_document(self): + """An extension may target a role defined in a different document.""" + base = make_document( + "base", + categories=[category("cat")], + permissions=[permission(cat="cat")], + roles=[role()], + ) + ext = make_document("ext", role_extensions=[extension("course_editor", display_name="Author")]) + + assert not _errors(SchemaValidator().validate([base, ext])) + + +class TestValidateCompiled: + """Post-compile rules that only the resolved schema can answer. + + Document-level validation sees base declarations only, so these cases pass + ``validate`` and must be caught by ``validate_compiled``. + """ + + @staticmethod + def _compile(*documents): + return SchemaCompiler().compile(list(documents)) + + def test_extension_added_permission_must_support_role_scopes(self): + """An extension cannot grant a permission outside the role's scopes. + + Regression test: ``course_editor`` is a ``course-v1`` role, the added + permission only applies to ``lib``, yet document validation is clean + because it never inspects the extension's effect (ADR 0017 §4). + """ + base = make_document( + "base", + priority=100, + categories=[category("cat")], + permissions=[ + permission(cat="cat", scopes=("course-v1",)), + permission("libraries", "edit_library", cat="cat", scopes=("lib",)), + ], + roles=[role(rid="course_editor", scopes=("course-v1",), permissions=("courses.view_course",))], + ) + ext = make_document( + "ext", + priority=200, + role_extensions=[extension("course_editor", add_permissions=("libraries.edit_library",))], + ) + validator = SchemaValidator() + assert not _errors(validator.validate([base, ext])) + + messages = [i.message for i in _errors(validator.validate_compiled(self._compile(base, ext)))] + + assert any("libraries.edit_library" in m and "does not support" in m for m in messages) + + def test_error_names_the_extending_source(self): + """The operator needs the extending file's id, not the role's file.""" + base = make_document( + "base", + priority=100, + categories=[category("cat")], + permissions=[ + permission(cat="cat", scopes=("course-v1",)), + permission("libraries", "edit_library", cat="cat", scopes=("lib",)), + ], + roles=[role(rid="course_editor", scopes=("course-v1",), permissions=("courses.view_course",))], + ) + ext = make_document( + "ext", + priority=200, + role_extensions=[extension("course_editor", add_permissions=("libraries.edit_library",))], + ) + + issues = _errors(SchemaValidator().validate_compiled(self._compile(base, ext))) + + assert [i.source_id for i in issues] == [make_source("ext").source_id] + + def test_no_error_when_extension_permission_shares_the_role_scope(self): + """Negative control: the rule must not fire on a compatible extension.""" + base = make_document( + "base", + priority=100, + categories=[category("cat")], + permissions=[ + permission(cat="cat", scopes=("course-v1",)), + permission("courses", "export_course", cat="cat", scopes=("course-v1",)), + ], + roles=[role(rid="course_editor", scopes=("course-v1",), permissions=("courses.view_course",))], + ) + ext = make_document( + "ext", + priority=200, + role_extensions=[extension("course_editor", add_permissions=("courses.export_course",))], + ) + + assert not _errors(SchemaValidator().validate_compiled(self._compile(base, ext))) + + def test_multi_scope_permission_supports_a_narrower_role(self): + """A permission may support more scopes than the role uses.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat", scopes=("course-v1", "lib"))], + roles=[role(rid="course_editor", scopes=("course-v1",), permissions=("courses.view_course",))], + ) + + assert not _errors(SchemaValidator().validate_compiled(self._compile(doc))) + + def test_compiled_permission_with_unknown_category_is_error(self): + """Defensive: a category that vanished during resolution is reported.""" + doc = make_document( + permissions=[permission(cat="missing")], + ) + + messages = [i.message for i in _errors(SchemaValidator().validate_compiled(self._compile(doc)))] + + assert any("unknown category" in m for m in messages) + + def test_valid_schema_has_no_compiled_errors(self): + """A well-formed compiled schema produces no post-compile errors.""" + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat")], + roles=[role(permissions=("courses.view_course",))], + ) + + assert not _errors(SchemaValidator().validate_compiled(self._compile(doc))) + + def test_compiled_role_referencing_a_missing_permission_is_error(self): + """Defensive: a permission definition that never made it into the schema.""" + doc = make_document(roles=[role(permissions=("courses.ghost",))]) + + messages = [i.message for i in _errors(SchemaValidator().validate_compiled(self._compile(doc)))] + + assert any("resolves to unknown permission 'courses.ghost'" in m for m in messages) + + def test_source_id_is_omitted_when_provenance_is_missing(self): + """A grant with no recorded provenance still produces a usable issue.""" + doc = make_document(roles=[role(permissions=("courses.ghost",))]) + schema = self._compile(doc) + schema.role_permission_sources.clear() + + issues = _errors(SchemaValidator().validate_compiled(schema)) + + assert [i.source_id for i in issues] == [None] + + +class TestIconRules: + """ADR 0017 §4: icon names must be valid @openedx/paragon/icons names.""" + + def test_valid_icon_has_no_errors(self): + """A real Paragon icon name passes validation.""" + doc = make_document(categories=[category("cat", icon="BookOpen")]) + + assert not _errors(SchemaValidator().validate([doc])) + + @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.""" + + def test_true_when_any_issue_is_an_error(self): + """``has_errors`` is True when any issue is error-level.""" + issues = [ValidationIssue(IssueLevel.WARNING, "heads up"), ValidationIssue(IssueLevel.ERROR, "boom")] + + assert SchemaValidator.has_errors(issues) is True + + def test_false_for_warnings_only(self): + """``has_errors`` is False when every issue is a warning.""" + assert SchemaValidator.has_errors([ValidationIssue(IssueLevel.WARNING, "heads up")]) is False + + def test_false_for_no_issues(self): + """``has_errors`` is False for an empty issue list.""" + assert SchemaValidator.has_errors([]) is False