diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2488c34b..4969e827 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,54 @@ Change Log Unreleased ********** +1.24.0 - 2026-09-17 +******************* + +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.23.0 - 2026-08-13 ******************* diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index 52cd779d..033c384d 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "1.23.0" +__version__ = "1.24.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) diff --git a/openedx_authz/engine/renderer.py b/openedx_authz/engine/renderer.py new file mode 100644 index 00000000..3ebd4200 --- /dev/null +++ b/openedx_authz/engine/renderer.py @@ -0,0 +1,679 @@ +"""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 and imports nothing from Casbin/Django. ``plan``/``apply`` +import the enforcer lazily so this module stays importable without a configured +Django environment. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from openedx_authz.data import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR as SEP +from openedx_authz.engine.schema.exceptions import SchemaApplyError +from openedx_authz.engine.schema.types import CompiledSchema, RoleDefinition + +logger = logging.getLogger(__name__) + +# Namespace prefixes for the internal Casbin form (schema objects never carry them). +ROLE_PREFIX = "role" +ACTION_PREFIX = "act" +SCOPE_WILDCARD = "*" +ALLOW = "allow" +POLICY_PTYPE = "p" + + +@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]``).""" + subject, action, scope, effect = (list(values) + ["", "", "", ""])[:4] + return cls(POLICY_PTYPE, subject, action, scope, effect) + + +@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 + + +def policy_row(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. Both :meth:` + PolicyRenderer.render` and the stored-ownership lookup in + :meth:`SchemaApplier._managed_rows` go through it, so the rendered set and + the managed set cannot drift apart — a drift would silently stop pruning + from matching anything and let stale rows accumulate. + """ + return PolicyRow( + ptype=POLICY_PTYPE, + subject=f"{ROLE_PREFIX}{SEP}{role_id}", + action=f"{ACTION_PREFIX}{SEP}{permission_id}", + scope=f"{scope}{SEP}{SCOPE_WILDCARD}", + effect=ALLOW, + ) + + +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(policy_row(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. + """ + from django.db import transaction # pylint: disable=import-outside-toplevel + + from openedx_authz.engine.enforcer import AuthzEnforcer # pylint: disable=import-outside-toplevel + + 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, + ) + + # ---- helpers ---------------------------------------------------------- + + def _resolve_enforcer(self): + """Lazily resolve the enforcer to honor plugin/settings timing.""" + if self._enforcer is None: + from openedx_authz.engine.enforcer import AuthzEnforcer # pylint: disable=import-outside-toplevel + + 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``. Imported lazily so the module + stays importable without Django/openedx-events configured. + """ + if not removed_assignments: + return + + # pylint: disable=import-outside-toplevel + from crum import get_current_user + from openedx_events.authz.data import RoleAssignmentData as RoleAssignmentEventData + from openedx_events.authz.signals import ROLE_ASSIGNMENT_DELETED + + from openedx_authz.models.core import RoleAssignmentAudit + + 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. + """ + from openedx_authz.models import schema as m # pylint: disable=import-outside-toplevel + + return { + "categories": self._diff_kind( + {cid: compiled.definition for cid, compiled in schema.categories.items()}, + {obj.category_id: obj for obj in m.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 m.AuthzPermissionDefinition.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.category == (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 m.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 m.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. + """ + from openedx_authz.models import schema as m # pylint: disable=import-outside-toplevel + + return { + policy_row( + grant.role.role_id, + f"{grant.permission.namespace}.{grant.permission.name}", + grant.scope, + ) + for grant in m.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. + """ + from openedx_authz.models import schema as m # pylint: disable=import-outside-toplevel + + 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, _ = m.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, _ = m.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: + m.AuthzCategorySource.objects.update_or_create( + category=obj, source=source_obj(record), defaults={"origin_kind": m.OriginKind.BASE} + ) + + # Permissions. + permission_objs: dict[str, object] = {} + for pid, compiled in schema.permissions.items(): + definition = compiled.definition + obj, _ = m.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), + "scopes": list(definition.scopes), + "icon": definition.icon, + }, + ) + permission_objs[pid] = obj + for record in compiled.sources: + m.AuthzPermissionSource.objects.update_or_create( + permission=obj, source=source_obj(record), defaults={"origin_kind": m.OriginKind.BASE} + ) + + # Roles. + role_objs: dict[str, object] = {} + for rid, compiled in schema.roles.items(): + definition = compiled.definition + obj, _ = m.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: + m.AuthzRoleSource.objects.update_or_create( + role=obj, source=source_obj(record), defaults={"origin_kind": m.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, _ = m.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), []): + m.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(m, 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(m, 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. + m.AuthzRolePermission.objects.exclude(pk__in=live_grant_ids).delete() + + live_role_ids = {compiled.definition.id for compiled in schema.roles.values()} + m.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 m.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()} + m.AuthzPermissionCategory.objects.exclude(category_id__in=live_category_ids).delete() diff --git a/openedx_authz/engine/schema/__init__.py b/openedx_authz/engine/schema/__init__.py new file mode 100644 index 00000000..7a789345 --- /dev/null +++ b/openedx_authz/engine/schema/__init__.py @@ -0,0 +1,16 @@ +"""Authorization schema pipeline. + +Turns on-disk ``.yaml`` schema resources into a validated, compiled set of +static definitions, following the lifecycle defined in the authz ADRs: + + discover -> load -> validate -> compile (this package, Casbin-free) + render -> apply (openedx_authz.engine.renderer) + consume (existing enforcer + APIs) + +References: + * ADR 0017 - static authorization schema (format) + * ADR 0018 - authorization schema lifecycle (vocabulary + semantics) + * ADR 0019 - authorization schema discovery (entry points + resources) + * ADR 0023 - extend static roles (role_extensions merge rules) + * docs/references/authorization-schema.rst - field-level reference +""" diff --git a/openedx_authz/engine/schema/compilation.py b/openedx_authz/engine/schema/compilation.py new file mode 100644 index 00000000..66dd92a3 --- /dev/null +++ b/openedx_authz/engine/schema/compilation.py @@ -0,0 +1,340 @@ +"""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, field, replace + +from openedx_authz.engine.schema.exceptions import SchemaCompileError +from openedx_authz.engine.schema.types import ( + ORIGIN_BASE, + ORIGIN_EXTENSION, + CompiledDefinition, + CompiledSchema, + RelationshipSource, + RoleDefinition, + SchemaDocument, + SourceRecord, +) + +logger = logging.getLogger(__name__) + +# Metadata fields an extension may replace on a role. +_METADATA_FIELDS = ("display_name", "description", "icon", "hidden") + +# Singular labels for operator-facing messages, keyed by document attribute. +_KIND_LABELS = {"categories": "category", "permissions": "permission", "roles": "role"} + + +@dataclass +class _Tracked: + """A base definition plus the sources and priority that produced it.""" + + definition: object + sources: list[SourceRecord] = field(default_factory=list) + 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, "categories", key=lambda c: c.id) + permissions = self._collect(documents, "permissions", key=lambda p: p.identifier) + roles = self._collect(documents, "roles", key=lambda r: r.id) + + role_permission_sources = self._resolve_roles_and_provenance(roles, documents) + + return CompiledSchema( + categories=self._finalize(categories, "category"), + permissions=self._finalize(permissions, "permission"), + roles=self._finalize(roles, "role"), + role_permission_sources=role_permission_sources, + ) + + # ---- base collection -------------------------------------------------- + + def _collect(self, documents: list[SchemaDocument], attr: str, 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, attr): + identifier = key(definition) + existing = tracked.get(identifier) + if existing is None: + tracked[identifier] = _Tracked( + definition=definition, + sources=[document.source], + priority=document.priority, + ) + continue + + kind = _KIND_LABELS.get(attr, attr) + if existing.definition == definition: + existing.sources.append(document.source) + elif document.priority > existing.priority: + # The loser is discarded; say so, otherwise the contributing + # file looks like it took effect (ADR 0017 §4). + self._warn_discarded( + kind, + identifier, + loser=existing.sources[0], + loser_priority=existing.priority, + winner=document.source, + winner_priority=document.priority, + ) + tracked[identifier] = _Tracked( + definition=definition, + sources=[document.source], + priority=document.priority, + ) + elif document.priority == existing.priority: + raise SchemaCompileError( + f"Conflicting {kind} definition for {identifier!r} at equal priority " + f"{document.priority} ({existing.sources[0].source_id} vs {document.source.source_id})." + ) + else: + self._warn_discarded( + kind, + identifier, + loser=document.source, + loser_priority=document.priority, + winner=existing.sources[0], + winner_priority=existing.priority, + ) + return tracked + + @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, + ) + + # ---- roles + provenance ---------------------------------------------- + + def _resolve_roles_and_provenance( + self, roles: dict[str, _Tracked], documents: list[SchemaDocument] + ) -> dict[tuple[str, str], list[RelationshipSource]]: + """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 relationship provenance map. + """ + metadata_changes, perm_changes = self._gather_extension_changes(roles, documents) + rp_sources: dict[tuple[str, str], list[RelationshipSource]] = {} + + for role_id, tracked in roles.items(): + role: RoleDefinition = tracked.definition + base_sources = list(tracked.sources) + base_priority = tracked.priority + + # Seed base provenance for every permission the role declares. + provenance: dict[str, list[RelationshipSource]] = { + perm: [RelationshipSource(src, ORIGIN_BASE, base_priority) for src in base_sources] + for perm in role.permissions + } + + md = metadata_changes.get(role_id, {}) + if md: + new_values, contributing_sources = self._resolve_metadata(role_id, md) + tracked.definition = replace(role, **new_values) + role = tracked.definition + for src in contributing_sources: + if src not in tracked.sources: + tracked.sources.append(src) + + pc = perm_changes.get(role_id) + if pc and (pc["add"] or pc["remove"]): + final_perms, provenance = self._resolve_permissions( + role_id, role.permissions, base_sources, base_priority, pc + ) + tracked.definition = replace(tracked.definition, permissions=final_perms) + + for perm, sources in provenance.items(): + rp_sources[(role_id, perm)] = sources + + return rp_sources + + def _gather_extension_changes(self, roles: dict[str, _Tracked], documents: list[SchemaDocument]): + """Collect per-role metadata and permission changes 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]]]] = {} + perm_changes: dict[str, dict[str, list[tuple[str, int, SourceRecord]]]] = {} + + for document in documents: + for extension in document.role_extensions: + role_id = extension.role + if role_id not in roles: + # Validation already errors on this; skip defensively. + continue + md = metadata_changes.setdefault(role_id, {}) + for field_name in _METADATA_FIELDS: + value = getattr(extension, field_name) + if value is not None: + md.setdefault(field_name, []).append((value, document.priority, document.source)) + pc = perm_changes.setdefault(role_id, {"add": [], "remove": []}) + for perm in extension.add_permissions: + pc["add"].append((perm, document.priority, document.source)) + for perm in extension.remove_permissions: + pc["remove"].append((perm, document.priority, document.source)) + return metadata_changes, perm_changes + + def _resolve_metadata(self, role_id: str, md: 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 md.items(): + 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( + f"Conflicting {field_name!r} for role {role_id!r} at equal priority " + f"{max_priority}: {sorted(map(str, top_values))}." + ) + new_values[field_name] = next(iter(top_values)) + contributing.update(src for _, priority, src in entries if priority == max_priority) + winner = next(src for _, priority, src in entries if priority == max_priority) + for _, priority, src in entries: + if priority < max_priority: + self._warn_discarded( + f"role_extension {field_name}", + role_id, + loser=src, + loser_priority=priority, + winner=winner, + winner_priority=max_priority, + ) + return new_values, contributing + + def _resolve_permissions( + self, + role_id: str, + base: tuple[str, ...], + base_sources: list[SourceRecord], + base_priority: int, + pc: 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: dict[str, list[RelationshipSource]] = { + perm: [RelationshipSource(src, ORIGIN_BASE, base_priority) for src in base_sources] for perm in base + } + + actions: dict[str, list[tuple[str, int, SourceRecord]]] = {} + for perm, priority, src in pc["add"]: + actions.setdefault(perm, []).append(("add", priority, src)) + for perm, priority, src in pc["remove"]: + actions.setdefault(perm, []).append(("remove", priority, src)) + + for perm, entries in actions.items(): + max_priority = max(priority for _, priority, _ in entries) + top = {action for action, priority, _ in entries if priority == max_priority} + if len(top) > 1: + raise SchemaCompileError( + f"Conflicting add/remove for permission {perm!r} on role {role_id!r} " + f"at equal priority {max_priority}." + ) + action = next(iter(top)) + winning_sources = [src for act, priority, src in entries if priority == max_priority and act == action] + + for act, priority, src in entries: + if priority < max_priority: + self._warn_discarded( + f"role_extension {act} of {perm!r} on role", + role_id, + loser=src, + loser_priority=priority, + winner=winning_sources[0], + winner_priority=max_priority, + ) + + if action == "add": + 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( + RelationshipSource(src, ORIGIN_EXTENSION, max_priority) for src in winning_sources + ) + else: # remove + 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) + + return tuple(sorted(current)), provenance + + # ---- finalize --------------------------------------------------------- + + def _finalize(self, tracked: dict[str, _Tracked], kind: str) -> dict[str, CompiledDefinition]: + """Turn tracked definitions into CompiledDefinition entries.""" + return { + identifier: CompiledDefinition( + kind=kind, + key=identifier, + definition=entry.definition, + sources=tuple(entry.sources), + ) + for identifier, entry in tracked.items() + } diff --git a/openedx_authz/engine/schema/discovery.py b/openedx_authz/engine/schema/discovery.py new file mode 100644 index 00000000..22930f91 --- /dev/null +++ b/openedx_authz/engine/schema/discovery.py @@ -0,0 +1,200 @@ +"""Discover static authz schema resources (the ``discover`` step, ADR 0019). + +Providers register **directories** (not individual files); the loader reads +every ``.yaml`` file inside them. Two contribution sources are merged: + +1. The ``authz.schema`` entry-point group. Each registered callable returns + directory paths relative to an importable top-level package (e.g. + openedx-authz's ``["openedx_authz/authz/schema"]``). +2. The ``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` Django setting, a list of directory + path strings in the same format. This lets operators and CI contribute + directories without shipping a package entry point. + +Directory paths are resolved with ``importlib.resources`` so discovery does not +depend on virtualenv or container layout. If any provider raises, discovery +stops and reports the failing application (ADR 0019): deployment must not +proceed with an incomplete set of static definitions. + +Timing: call only after Django settings are available (from the management +command or ``AppConfig.ready()``), never at module import. Django is imported +lazily so this module stays importable (and unit-testable) without a configured +Django environment. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from importlib import metadata, resources + +ENTRY_POINT_GROUP = "authz.schema" +SETTINGS_DIRECTORIES_NAME = "OPENEDX_AUTHZ_SCHEMA_DIRECTORIES" +SCHEMA_FILE_SUFFIXES = (".yaml", ".yml") + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class DiscoveredResource: + """A single located schema file discovered inside a contributed directory. + + Attributes: + package: Importable top-level package used as the ``importlib.resources`` + anchor (e.g. ``openedx_authz``). + resource_path: Path to the file within that anchor + (e.g. ``authz/schema/course_roles.yaml``). + module: Dotted path of the owning directory, used as the source-record + module and provenance identity (e.g. ``openedx_authz.authz.schema``). + origin: Where the contribution came from: ``"entry_point"``, + ``"settings"``, or ``"explicit"`` (diagnostics only). + """ + + package: str + resource_path: str + module: str + origin: str + + +class SchemaDiscoveryError(Exception): + """Raised when a provider fails or a declared directory cannot be read.""" + + +class SchemaDiscovery: + """Enumerates registered schema directories into discovered files.""" + + def __init__(self, *, explicit_directories: list[str] | None = None): + """Initialize discovery. + + Args: + explicit_directories: Optional directory path strings supplied + directly (the ADR 0019 CI/local mode where directories are + passed to the command). Discovered in addition to entry points + and settings. + """ + self._explicit_directories = explicit_directories or [] + + def discover(self) -> list[DiscoveredResource]: + """Return every discovered schema file in a deterministic order. + + Expands entry-point directories, settings directories, and explicit + directories into individual ``.yaml`` files, then de-duplicates and + sorts. Order is normalized here because discovery order may vary across + environments (ADR 0019); priority — not discovery order — drives + conflict resolution later. + + Raises: + SchemaDiscoveryError: If a provider callable raises or a declared + directory cannot be located/read. + """ + found: list[DiscoveredResource] = [] + found.extend(self._discover_entry_points()) + found.extend(self._discover_settings_directories()) + for directory in self._explicit_directories: + found.extend(self._iter_directory(directory, origin="explicit")) + + seen: dict[tuple[str, str], DiscoveredResource] = {} + for resource in found: + seen.setdefault((resource.package, resource.resource_path), resource) + + return sorted(seen.values(), key=lambda r: (r.package, r.resource_path)) + + def _discover_entry_points(self) -> list[DiscoveredResource]: + """Load the ``authz.schema`` group; each provider returns directories.""" + discovered: list[DiscoveredResource] = [] + for entry_point in metadata.entry_points(group=ENTRY_POINT_GROUP): + try: + provider = entry_point.load() + directories = provider() + except Exception as exc: # noqa: BLE001 - re-raised with context below + raise SchemaDiscoveryError( + f"authz.schema provider {entry_point.name!r} " + f"({entry_point.value}) failed during discovery: {exc}" + ) from exc + for directory in directories: + discovered.extend(self._iter_directory(directory, origin="entry_point")) + return discovered + + def _discover_settings_directories(self) -> list[DiscoveredResource]: + """Read ``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` from Django settings. + + This is the operator/Tutor contribution route (ADR 0019 §1, ADR 0023 §4): + each item is a directory path string. Absent, empty, or unconfigured + settings yield nothing. + + Django is imported lazily and both "not installed" and "installed but + unconfigured" degrade to no contribution, so the pipeline stays usable + outside a Django process (CI schema checks, unit tests) rather than + failing with an unrelated Django error. + """ + try: + # pylint: disable=import-outside-toplevel + from django.conf import settings + from django.core.exceptions import ImproperlyConfigured + except ImportError: + return [] + + try: + directories = getattr(settings, SETTINGS_DIRECTORIES_NAME, None) or [] + except ImproperlyConfigured: + return [] + discovered: list[DiscoveredResource] = [] + for directory in directories: + discovered.extend(self._iter_directory(directory, origin="settings")) + return discovered + + def _iter_directory(self, directory: str, *, origin: str) -> list[DiscoveredResource]: + """Resolve a directory path and yield a resource per ``.yaml`` file. + + The path's first segment is an importable top-level package used as the + anchor; the remainder is a subdirectory within it. For example + ``"openedx_authz/authz/schema"`` anchors on ``openedx_authz`` and reads + the ``authz/schema`` subdirectory. + """ + parts = [segment for segment in directory.strip("/").split("/") if segment] + if not parts: + raise SchemaDiscoveryError(f"Empty schema directory path: {directory!r}.") + + anchor = parts[0] + subpath = "/".join(parts[1:]) + module = ".".join(parts) + + try: + base = resources.files(anchor) + target = base.joinpath(subpath) if subpath else base + entries = sorted(target.iterdir(), key=lambda entry: entry.name) + except (FileNotFoundError, ModuleNotFoundError, NotADirectoryError, OSError) as exc: + raise SchemaDiscoveryError( + f"Could not read schema directory {directory!r}: {exc}" + ) from exc + + discovered: list[DiscoveredResource] = [] + for entry in entries: + if not entry.name.endswith(SCHEMA_FILE_SUFFIXES): + continue + if not entry.is_file(): + continue + resource_path = f"{subpath}/{entry.name}" if subpath else entry.name + discovered.append( + DiscoveredResource( + package=anchor, resource_path=resource_path, module=module, origin=origin + ) + ) + return discovered + + def resolve_contents(self, resource: DiscoveredResource) -> bytes: + """Read a discovered resource's bytes via ``importlib.resources``. + + Kept separate from :meth:`discover` so the loader controls when files + are read and so the content digest is computed from the exact bytes + used. + + Raises: + SchemaDiscoveryError: If the resource cannot be located or read. + """ + try: + return resources.files(resource.package).joinpath(resource.resource_path).read_bytes() + except (FileNotFoundError, ModuleNotFoundError, OSError) as exc: + raise SchemaDiscoveryError( + f"Could not read schema resource {resource.resource_path!r} " + f"from package {resource.package!r}: {exc}" + ) from exc diff --git a/openedx_authz/engine/schema/exceptions.py b/openedx_authz/engine/schema/exceptions.py new file mode 100644 index 00000000..2a87fd6e --- /dev/null +++ b/openedx_authz/engine/schema/exceptions.py @@ -0,0 +1,39 @@ +"""Exceptions for the authz schema pipeline.""" + +from __future__ import annotations + + +class SchemaError(Exception): + """Base class for schema pipeline errors.""" + + +class SchemaLoadError(SchemaError): + """A resource could not be parsed into a schema document.""" + + +class SchemaValidationError(SchemaError): + """Validation found error-level issues; deployment must stop. + + Carries the collected issues so the caller can report them all at once + rather than failing on the first problem. + """ + + def __init__(self, issues): + self.issues = issues + super().__init__(f"Schema validation failed with {len(issues)} error(s).") + + +class SchemaCompileError(SchemaError): + """Compilation could not resolve the definitions. + + For example, an unresolvable role_extension conflict at equal priority + (ADR 0023). + """ + + +class SchemaApplyError(SchemaError): + """Applying the rendered policy to the database is not safe to proceed. + + For example, a static role slated for removal still has user assignments + and ``force`` was not set (ADR 0018 §6). + """ diff --git a/openedx_authz/engine/schema/loading.py b/openedx_authz/engine/schema/loading.py new file mode 100644 index 00000000..c1fec1df --- /dev/null +++ b/openedx_authz/engine/schema/loading.py @@ -0,0 +1,192 @@ +"""Read discovered resources into schema documents (the ``load`` step, ADR 0018). + +Parses each ``.yaml`` schema resource into a :class:`SchemaDocument`, attaching +its :class:`SourceRecord` (including a content digest computed from the exact +bytes read). This step performs only parsing and structural shaping; semantic +checks belong to :mod:`.validation` and cross-file resolution to +:mod:`.compilation`. + +No Casbin or Django imports, so it stays unit-testable in isolation. +""" + +from __future__ import annotations + +import hashlib +from importlib import metadata + +import yaml + +from openedx_authz.engine.schema.discovery import DiscoveredResource, SchemaDiscovery +from openedx_authz.engine.schema.exceptions import SchemaLoadError +from openedx_authz.engine.schema.types import ( + PermissionCategory, + PermissionDefinition, + RoleDefinition, + RoleExtension, + SchemaDocument, + SourceRecord, +) + +UNKNOWN = "unknown" + + +class SchemaLoader: + """Turns discovered resources into typed schema documents.""" + + def __init__(self, discovery: SchemaDiscovery | None = None): + """Args: + discovery: Discovery instance used to read resource bytes. Injected + for testability; defaults to a standard :class:`SchemaDiscovery`. + """ + self._discovery = discovery or SchemaDiscovery() + + def load(self, resources: list[DiscoveredResource]) -> list[SchemaDocument]: + """Load every discovered resource into a :class:`SchemaDocument`. + + Raises: + SchemaLoadError: On invalid YAML or an unusable document structure. + """ + documents: list[SchemaDocument] = [] + for resource in resources: + contents = self._discovery.resolve_contents(resource) + raw = self._parse_yaml(contents, resource) + schema_version = str(raw.get("schema_version", "")) + source = self._build_source_record(resource, contents, schema_version) + documents.append(self._build_document(raw, source)) + return documents + + def _parse_yaml(self, contents: bytes, resource: DiscoveredResource) -> dict: + """Parse YAML bytes into a mapping, raising on malformed input.""" + try: + data = yaml.safe_load(contents) + except yaml.YAMLError as exc: + raise SchemaLoadError( + f"Invalid YAML in {resource.package}:{resource.resource_path}: {exc}" + ) from exc + + if data is None: + data = {} + if not isinstance(data, dict): + raise SchemaLoadError( + f"Schema file {resource.package}:{resource.resource_path} must be a mapping " + f"at the top level, got {type(data).__name__}." + ) + return data + + def _build_source_record( + self, resource: DiscoveredResource, contents: bytes, schema_version: str + ) -> SourceRecord: + """Assemble packaging metadata + content digest into a SourceRecord. + + Resolves the installed distribution name/version that owns the resource + package via ``importlib.metadata`` and hashes ``contents`` for the + digest. Falls back to ``"unknown"`` when the package is not tied to an + installed distribution (e.g. operator-supplied settings resources). + """ + distribution, version = self._resolve_distribution(resource.module) + content_digest = hashlib.sha256(contents).hexdigest() + return SourceRecord( + distribution=distribution, + distribution_version=version, + module=resource.module, + resource_path=resource.resource_path, + schema_version=schema_version, + content_digest=content_digest, + ) + + @staticmethod + def _resolve_distribution(package: str) -> tuple[str, str]: + """Map an import package to its providing distribution name and version.""" + top_level = package.split(".", 1)[0] + try: + mapping = metadata.packages_distributions() + # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 - defensive; metadata quirks across envs + mapping = {} + candidates = mapping.get(top_level) or [] + if candidates: + distribution = candidates[0] + try: + return distribution, metadata.version(distribution) + except metadata.PackageNotFoundError: + return distribution, UNKNOWN + return top_level, UNKNOWN + + def _build_document(self, raw: dict, source: SourceRecord) -> SchemaDocument: + """Map the parsed mapping's blocks into a typed SchemaDocument.""" + try: + priority = int(raw.get("priority", 0)) + except (TypeError, ValueError) as exc: + raise SchemaLoadError( + f"{source.source_id}: 'priority' must be an integer, got {raw.get('priority')!r}." + ) from exc + + return SchemaDocument( + source=source, + priority=priority, + categories=[self._build_category(item, source) for item in raw.get("permission_categories", []) or []], + permissions=[self._build_permission(item, source) for item in raw.get("permissions", []) or []], + roles=[self._build_role(item, source) for item in raw.get("roles", []) or []], + role_extensions=[self._build_extension(item, source) for item in raw.get("role_extensions", []) or []], + ) + + @staticmethod + def _as_tuple(value) -> tuple[str, ...]: + """Coerce a YAML list (or None) into a tuple of strings.""" + if not value: + return () + if isinstance(value, str): + return (value,) + return tuple(str(item) for item in value) + + def _build_category(self, item: dict, source: SourceRecord) -> PermissionCategory: + self._require_mapping(item, "permission_categories", source) + return PermissionCategory( + id=item.get("id", ""), + display_name=item.get("display_name", ""), + description=item.get("description", ""), + icon=item.get("icon"), + ) + + def _build_permission(self, item: dict, source: SourceRecord) -> PermissionDefinition: + self._require_mapping(item, "permissions", source) + return PermissionDefinition( + namespace=item.get("namespace", ""), + name=item.get("name", ""), + display_name=item.get("display_name", ""), + description=item.get("description", ""), + category=item.get("category", ""), + scopes=self._as_tuple(item.get("scopes")), + icon=item.get("icon"), + ) + + def _build_role(self, item: dict, source: SourceRecord) -> RoleDefinition: + self._require_mapping(item, "roles", source) + return RoleDefinition( + id=item.get("id", ""), + display_name=item.get("display_name", ""), + description=item.get("description", ""), + scopes=self._as_tuple(item.get("scopes")), + permissions=self._as_tuple(item.get("permissions")), + icon=item.get("icon"), + hidden=bool(item.get("hidden", False)), + ) + + def _build_extension(self, item: dict, source: SourceRecord) -> RoleExtension: + self._require_mapping(item, "role_extensions", source) + return RoleExtension( + role=item.get("role", ""), + add_permissions=self._as_tuple(item.get("add_permissions")), + remove_permissions=self._as_tuple(item.get("remove_permissions")), + display_name=item.get("display_name"), + description=item.get("description"), + icon=item.get("icon"), + hidden=item.get("hidden"), # tri-state: None means "leave unchanged" + ) + + @staticmethod + def _require_mapping(item, block: str, source: SourceRecord) -> None: + if not isinstance(item, dict): + raise SchemaLoadError( + f"{source.source_id}: each entry in '{block}' must be a mapping, got {type(item).__name__}." + ) diff --git a/openedx_authz/engine/schema/pipeline.py b/openedx_authz/engine/schema/pipeline.py new file mode 100644 index 00000000..8b5c0831 --- /dev/null +++ b/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._discovery) + 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/openedx_authz/engine/schema/types.py b/openedx_authz/engine/schema/types.py new file mode 100644 index 00000000..d6c8dc2f --- /dev/null +++ b/openedx_authz/engine/schema/types.py @@ -0,0 +1,231 @@ +"""Typed schema objects and source records for the authz schema pipeline. + +These dataclasses are the data contract passed between lifecycle steps +(ADR 0018): discovery produces :class:`DiscoveredResource`, loading produces +:class:`SchemaDocument`, and compilation produces :class:`CompiledSchema`. + +Definition field shapes follow ``docs/references/authorization-schema.rst`` +(reference PR): identifiers match ``[a-z][a-z0-9_]*``, permission IDs join +``namespace`` and ``name`` with a period, and the internal Casbin forms +(``act^...``, ``role^...``) never appear here. + +This module is intentionally free of any Casbin or Django imports so it can be +unit-tested in isolation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +# --------------------------------------------------------------------------- +# Provenance +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SourceRecord: + """Identifies a single schema contribution across deployment layouts. + + Per ADR 0019 §2, these packaging-based values (not filesystem paths) must + identify the same source under Tutor, native, and local deployments. A + compiled definition retains every ``SourceRecord`` that contributed to it, + so a role assembled from a base definition plus one or more extensions + keeps all of its sources. + + Attributes: + distribution: Installed distribution name, e.g. ``"openedx-authz"``. + distribution_version: Version of that distribution. + module: Python module that owns the resource. + resource_path: Resource path within that module. + schema_version: The ``schema_version`` declared by the file. + content_digest: Digest of the resource contents (change detection). + """ + + distribution: str + distribution_version: str + module: str + resource_path: str + schema_version: str + content_digest: str + + @property + def source_id(self) -> str: + """Stable, human-readable id. + + Combines the distribution with the module directory path and the file + name, e.g. ``"openedx-authz:openedx_authz/authz/schema/roles.yaml"``. + + ``module`` is the dotted path of the owning directory and ``resource_path`` + is anchor-relative (so it may repeat the directory); only the file name + is appended here to avoid duplicating the directory segments. + """ + module_path = self.module.replace(".", "/") + filename = self.resource_path.rsplit("/", 1)[-1] + return f"{self.distribution}:{module_path}/{filename}" + + +# --------------------------------------------------------------------------- +# Definition objects (ADR 0017 / reference) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PermissionCategory: + """A display/grouping category for permissions. Grants no access.""" + + id: str + display_name: str + description: str + icon: str | None = None + + +@dataclass(frozen=True) +class PermissionDefinition: + """A single permission. + + The complete permission ID (used by role definitions, extensions, app + checks, and API responses) is :attr:`identifier`. + """ + + namespace: str + name: str + display_name: str + description: str + category: str + scopes: tuple[str, ...] + icon: str | None = None + + @property + def identifier(self) -> str: + """Complete permission ID, e.g. ``"courses.view_course"``.""" + return f"{self.namespace}.{self.name}" + + +@dataclass(frozen=True) +class RoleDefinition: + """A static role listing every permission assigned to it. + + ``hidden`` mirrors ADR 0023: a hidden role is excluded from normal role + discovery/selection but keeps its assignments, permission checks, and + reserved ID. + """ + + id: str + display_name: str + description: str + scopes: tuple[str, ...] + permissions: tuple[str, ...] + icon: str | None = None + hidden: bool = False + + +@dataclass(frozen=True) +class RoleExtension: + """A change to an existing static role (ADR 0023). + + Only the included fields change; ``None``/empty means "leave unchanged". + An extension can never change the role ID or replace the whole definition. + ``hidden`` is tri-state: ``None`` leaves the current value untouched. + """ + + role: str + add_permissions: tuple[str, ...] = () + remove_permissions: tuple[str, ...] = () + display_name: str | None = None + description: str | None = None + icon: str | None = None + hidden: bool | None = None + + +# --------------------------------------------------------------------------- +# Loading output +# --------------------------------------------------------------------------- + + +@dataclass +class SchemaDocument: + """One loaded ``.yaml`` schema file plus its provenance and priority. + + Output of the ``load`` step. Still per-file: cross-file references are not + yet resolved (that happens during ``compile``). + """ + + source: SourceRecord + priority: int + categories: list[PermissionCategory] = field(default_factory=list) + permissions: list[PermissionDefinition] = field(default_factory=list) + roles: list[RoleDefinition] = field(default_factory=list) + role_extensions: list[RoleExtension] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Compilation output +# --------------------------------------------------------------------------- + + +# Origin of a contribution to a role or a role-permission grant (ADR 0023/0025). +ORIGIN_BASE = "base" +ORIGIN_EXTENSION = "extension" + + +@dataclass(frozen=True) +class RelationshipSource: + """Provenance of a single role-permission grant (ADR 0025). + + Attributes: + source: The contributing source record. + origin_kind: ``ORIGIN_BASE`` (from the role's own definition) or + ``ORIGIN_EXTENSION`` (added by a ``role_extensions`` entry). + priority: The contributing file's priority. + """ + + source: SourceRecord + origin_kind: str + priority: int + + +@dataclass(frozen=True) +class CompiledDefinition: + """A resolved definition plus every source that contributed to it. + + Attributes: + kind: ``"category"`` | ``"permission"`` | ``"role"``. + key: The category id, permission identifier, or role id. + definition: The resolved dataclass instance (category/permission/role). + sources: All contributing sources, in priority-then-discovery order. + """ + + kind: str + key: str + definition: object + sources: tuple[SourceRecord, ...] + + +@dataclass +class CompiledSchema: + """The full set of resolved static definitions (output of ``compile``). + + Keyed by stable identifier. This is what the renderer turns into Casbin + ``p`` rows and what the applier persists alongside source records. + """ + + categories: dict[str, CompiledDefinition] = field(default_factory=dict) + permissions: dict[str, CompiledDefinition] = field(default_factory=dict) + 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) + + def role_permission_pairs(self) -> list[tuple[str, str]]: + """Return ``(role_id, permission_identifier)`` pairs for every role. + + This is the flattened relation the renderer maps to Casbin ``p`` rows. + Pairs are returned in a deterministic order (role id, then permission + id) so downstream rendering and diffing are stable across runs. + """ + pairs: list[tuple[str, str]] = [] + for role_id in sorted(self.roles): + role = self.roles[role_id].definition + for permission in sorted(role.permissions): + pairs.append((role_id, permission)) + return pairs diff --git a/openedx_authz/engine/schema/validation.py b/openedx_authz/engine/schema/validation.py new file mode 100644 index 00000000..57da510d --- /dev/null +++ b/openedx_authz/engine/schema/validation.py @@ -0,0 +1,360 @@ +"""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. + +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 dataclasses import dataclass + +from openedx_authz.engine.schema.types import ( + ORIGIN_EXTENSION, + CompiledSchema, + SchemaDocument, +) + +IDENTIFIER_RE = re.compile(r"^[a-z][a-z0-9_]*$") +# Scope namespaces follow their registered spelling and may contain hyphens. +SCOPE_RE = re.compile(r"^[a-z][a-z0-9_-]*$") + +# Casbin-internal prefixes that must never appear in a schema identifier. +CASBIN_INTERNAL_PREFIXES = ("act^", "role^", "sub^", "scope^", "g^", "p^") + +ERROR = "error" +WARNING = "warning" + + +@dataclass(frozen=True) +class ValidationIssue: + """A single validation finding. + + Attributes: + level: ``"error"`` (blocks deployment) or ``"warning"`` (reported only). + message: Human-readable description. + source_id: The contributing source, when the issue is file-specific. + """ + + level: str + message: str + source_id: str | None = None + + @property + def is_error(self) -> bool: + return self.level == ERROR + + +class SchemaValidator: + """Runs per-document and whole-set validation.""" + + SUPPORTED_SCHEMA_VERSIONS = frozenset({"1.0"}) + + # ---- entry points ----------------------------------------------------- + + 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: + sid = self._relationship_source_id(schema, role_id, perm_id) + compiled_permission = schema.permissions.get(perm_id) + if compiled_permission is None: + issues.append( + ValidationIssue( + ERROR, + f"Role {role_id} resolves to unknown permission {perm_id!r}.", + sid, + ) + ) + continue + unsupported = set(role.scopes) - set(compiled_permission.definition.scopes) + if unsupported: + issues.append( + ValidationIssue( + ERROR, + f"Role {role_id} is defined for scope(s) {sorted(unsupported)} " + f"that permission {perm_id!r} does not support.", + sid, + ) + ) + + for perm_id, compiled_permission in schema.permissions.items(): + category = compiled_permission.definition.category + if category and category not in schema.categories: + sources = compiled_permission.sources + issues.append( + ValidationIssue( + ERROR, + f"Permission {perm_id} references unknown category {category!r}.", + 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) + + # ---- per-document ----------------------------------------------------- + + 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( + 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)) + + 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, f"category for {permission.identifier}", sid)) + issues.extend(self._check_scopes(permission.scopes, 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)) + 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, "role_extension target", sid)) + for perm_id in (*extension.add_permissions, *extension.remove_permissions): + issues.extend(self._check_permission_id(perm_id, f"role_extension {extension.role}", sid)) + + return issues + + # ---- whole-set -------------------------------------------------------- + + 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: + if permission.category and permission.category not in category_ids: + issues.append( + ValidationIssue( + ERROR, + f"Permission {permission.identifier} references unknown category " + f"{permission.category!r}.", + sid, + ) + ) + + # Role permissions exist, and role scopes are supported by each permission. + for role in document.roles: + for perm_id in role.permissions: + if perm_id not in permission_index: + issues.append( + ValidationIssue( + ERROR, + f"Role {role.id} references unknown permission {perm_id!r}.", + sid, + ) + ) + continue + unsupported = set(role.scopes) - set(permission_index[perm_id]) + if unsupported: + issues.append( + ValidationIssue( + ERROR, + f"Role {role.id} is defined for scope(s) {sorted(unsupported)} " + f"that permission {perm_id!r} does not support.", + sid, + ) + ) + + # Extensions target existing roles and reference existing permissions. + for extension in document.role_extensions: + if extension.role not in role_ids: + issues.append( + ValidationIssue( + ERROR, + f"role_extension targets unknown role {extension.role!r}.", + sid, + ) + ) + for perm_id in (*extension.add_permissions, *extension.remove_permissions): + if perm_id not in permission_index: + issues.append( + ValidationIssue( + ERROR, + f"role_extension {extension.role} 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(WARNING, f"Duplicate identical {kind} {key!r}.", sid)] + return [ValidationIssue(ERROR, f"Conflicting {kind} definition for {key!r}.", sid)] + + # ---- helpers ---------------------------------------------------------- + + 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(ERROR, f"{label} {value!r} uses an internal Casbin form.", sid)] + if not IDENTIFIER_RE.match(value): + return [ + ValidationIssue( + 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.""" + if value.count(".") != 1: + return [ + ValidationIssue( + ERROR, + f"{context}: permission id {value!r} must be 'namespace.name'.", + sid, + ) + ] + namespace, name = value.split(".", 1) + issues = self._check_identifier(namespace, f"{context} permission namespace", sid) + issues += self._check_identifier(name, f"{context} permission name", sid) + return issues + + 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(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(ERROR, f"{context}: invalid scope namespace {scope!r}.", sid) + ) + return issues + + @staticmethod + def _require(value: str, label: str, sid: str) -> list[ValidationIssue]: + if not value: + return [ValidationIssue(ERROR, f"Missing required field: {label}.", sid)] + return [] + + @staticmethod + def _relationship_source_id(schema: CompiledSchema, role_id: str, perm_id: str) -> str | None: + """Name the source(s) responsible for a compiled role-permission grant. + + 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. + """ + relationships = schema.role_permission_sources.get((role_id, perm_id), []) + extensions = [rel for rel in relationships if rel.origin_kind == ORIGIN_EXTENSION] + chosen = extensions or relationships + if not chosen: + return None + return ", ".join(sorted({rel.source.source_id for rel in chosen})) diff --git a/openedx_authz/management/commands/load_authz_schema.py b/openedx_authz/management/commands/load_authz_schema.py new file mode 100644 index 00000000..95b292ba --- /dev/null +++ b/openedx_authz/management/commands/load_authz_schema.py @@ -0,0 +1,132 @@ +"""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(explicit_directories=directories) if directories else SchemaDiscovery() + pipeline = SchemaPipeline(discovery=discovery) + + try: + if options.get("dry_run"): + plan = pipeline.plan() + self._report_plan(plan) + 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.")) + else: + self.stdout.write( + self.style.SUCCESS(f"Authz schema applied: {result.added} row(s) added, {result.removed} removed.") + ) + + def _report_plan(self, plan) -> None: + """Print the change report (ADR 0018 §6). + + Covers the definition tables as well as the policy rows: apply syncs + definitions even when no ``p`` row changes, so a metadata-only edit is a + real change the operator needs to see before it lands. + """ + if plan.unchanged: + self.stdout.write(self.style.SUCCESS("Authz schema unchanged; nothing would be written.")) + return + + self.stdout.write(f"Rows to add ({len(plan.added_rows)}):") + for row in plan.added_rows: + self.stdout.write(f" + {row.as_policy()}") + + self.stdout.write(f"Rows to remove ({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-level changes, one section per kind.""" + if plan.definitions_unchanged: + self.stdout.write("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/openedx_authz/migrations/0011_authz_schema_definitions.py b/openedx_authz/migrations/0011_authz_schema_definitions.py new file mode 100644 index 00000000..edd84044 --- /dev/null +++ b/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/openedx_authz/models/__init__.py b/openedx_authz/models/__init__.py index 06b5d003..6f3a3b22 100644 --- a/openedx_authz/models/__init__.py +++ b/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/openedx_authz/models/authz_migration.py b/openedx_authz/models/authz_migration.py index 1be9f8ce..8141a2fb 100644 --- a/openedx_authz/models/authz_migration.py +++ b/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/openedx_authz/models/schema.py b/openedx_authz/models/schema.py new file mode 100644 index 00000000..d803426e --- /dev/null +++ b/openedx_authz/models/schema.py @@ -0,0 +1,364 @@ +"""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. +""" + +from __future__ import annotations + +from django.db import models + +__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).""" + + BASE = "base", "Base" + EXTENSION = "extension", "Extension" + + +class AuthzSchemaSource(models.Model): + """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. + """ + + 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="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + 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(models.Model): + """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" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Authz Permission Category" + verbose_name_plural = "Authz Permission Categories" + + def __str__(self): + return self.category_id + + +class AuthzPermissionDefinition(models.Model): + """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" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + 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(models.Model): + """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" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Authz Role Definition" + verbose_name_plural = "Authz Role Definitions" + + def __str__(self): + return self.role_id + + +class AuthzRolePermission(models.Model): + """A single role-permission-scope grant (one per rendered Casbin ``p`` row). + + .. no_pii: + + This is the atomic unit of attribution: a base grant and a module-added + grant 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" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + 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 core grant links to the + core source (``origin_kind=base``) and a module-added grant links to that + module's 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 sorted( + AuthzSchemaSource.objects.filter(roles__role_id=role_id).values_list("distribution", flat=True).distinct() + ) + + +def origins_for_permission(identifier: str) -> list[str]: + """Return the distributions that define a permission, by complete id.""" + namespace, _, name = identifier.partition(".") + return sorted( + AuthzSchemaSource.objects.filter(permissions__namespace=namespace, permissions__name=name) + .values_list("distribution", flat=True) + .distinct() + ) + + +def origins_for_category(category_id: str) -> list[str]: + """Return the distributions that define a category.""" + return sorted( + AuthzSchemaSource.objects.filter(categories__category_id=category_id) + .values_list("distribution", flat=True) + .distinct() + ) + + +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 core-provided grants from a grant a + module added, even though both live in the same role. + """ + namespace, _, name = permission_identifier.partition(".") + return sorted( + AuthzSchemaSource.objects.filter( + role_permissions__role__role_id=role_id, + role_permissions__permission__namespace=namespace, + role_permissions__permission__name=name, + ) + .values_list("distribution", flat=True) + .distinct() + ) diff --git a/openedx_authz/tests/integration/test_schema_apply.py b/openedx_authz/tests/integration/test_schema_apply.py new file mode 100644 index 00000000..24edac10 --- /dev/null +++ b/openedx_authz/tests/integration/test_schema_apply.py @@ -0,0 +1,442 @@ +"""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="cat", + scopes=(SCOPE_NAMESPACE,), + ), + PermissionDefinition( + namespace="courses", + name="manage_tags", + display_name="Tags", + description="d", + category="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 SchemaApplyPruningIntegrationTests(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="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 SchemaApplyAdoptionIntegrationTests(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): + 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): + 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): + 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 SchemaApplyFailureIntegrationTests(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): + 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/openedx_authz/tests/schema/__init__.py b/openedx_authz/tests/schema/__init__.py new file mode 100644 index 00000000..82f0bba0 --- /dev/null +++ b/openedx_authz/tests/schema/__init__.py @@ -0,0 +1 @@ +"""Tests for the authz schema pipeline (openedx_authz.engine.schema).""" diff --git a/openedx_authz/tests/schema/factories.py b/openedx_authz/tests/schema/factories.py new file mode 100644 index 00000000..87450394 --- /dev/null +++ b/openedx_authz/tests/schema/factories.py @@ -0,0 +1,116 @@ +"""Small builders and a stub discovery for schema pipeline tests.""" + +from __future__ import annotations + +from openedx_authz.engine.schema.discovery import DiscoveredResource +from openedx_authz.engine.schema.types import ( + PermissionCategory, + PermissionDefinition, + RoleDefinition, + RoleExtension, + SchemaDocument, + SourceRecord, +) + + +def make_source(name: str = "doc", schema_version: str = "1.0") -> SourceRecord: + """Build a SourceRecord with predictable values for a named document.""" + return SourceRecord( + distribution="test-dist", + distribution_version="1.0", + module=f"pkg.{name}", + resource_path=f"{name}.authz.yaml", + schema_version=schema_version, + content_digest=f"digest-{name}", + ) + + +def make_document( + name: str = "doc", + *, + priority: int = 100, + schema_version: str = "1.0", + categories=None, + permissions=None, + roles=None, + role_extensions=None, +) -> SchemaDocument: + """Build a SchemaDocument with sensible empty defaults.""" + return SchemaDocument( + source=make_source(name, schema_version), + priority=priority, + categories=categories or [], + permissions=permissions or [], + roles=roles or [], + role_extensions=role_extensions or [], + ) + + +def category(cid: str = "cat", **kwargs) -> PermissionCategory: + return PermissionCategory( + id=cid, + display_name=kwargs.get("display_name", "Cat"), + description=kwargs.get("description", "desc"), + icon=kwargs.get("icon"), + ) + + +def permission(namespace="courses", name="view_course", *, cat="cat", scopes=("course-v1",), **kwargs): + return PermissionDefinition( + namespace=namespace, + name=name, + display_name=kwargs.get("display_name", "View"), + description=kwargs.get("description", "desc"), + category=cat, + scopes=tuple(scopes), + icon=kwargs.get("icon"), + ) + + +def role(rid="course_editor", *, scopes=("course-v1",), permissions=(), hidden=False, **kwargs): + return RoleDefinition( + id=rid, + display_name=kwargs.get("display_name", "Editor"), + description=kwargs.get("description", "desc"), + scopes=tuple(scopes), + permissions=tuple(permissions), + icon=kwargs.get("icon"), + hidden=hidden, + ) + + +def extension(role_id, **kwargs) -> RoleExtension: + return RoleExtension( + role=role_id, + add_permissions=tuple(kwargs.get("add_permissions", ())), + remove_permissions=tuple(kwargs.get("remove_permissions", ())), + display_name=kwargs.get("display_name"), + description=kwargs.get("description"), + icon=kwargs.get("icon"), + hidden=kwargs.get("hidden"), + ) + + +class StubDiscovery: + """A discovery double whose ``resolve_contents`` returns preset bytes. + + Maps ``(package, resource_path)`` to raw bytes; ``discover`` returns the + corresponding :class:`DiscoveredResource` list. + """ + + def __init__(self, contents: dict[tuple[str, str], bytes]): + self._contents = contents + + def discover(self): + return [ + DiscoveredResource( + package=pkg, + resource_path=path, + module=pkg.replace("/", "."), + origin="explicit", + ) + for (pkg, path) in self._contents + ] + + def resolve_contents(self, resource: DiscoveredResource) -> bytes: + return self._contents[(resource.package, resource.resource_path)] diff --git a/openedx_authz/tests/schema/test_apply.py b/openedx_authz/tests/schema/test_apply.py new file mode 100644 index 00000000..6be2fa8b --- /dev/null +++ b/openedx_authz/tests/schema/test_apply.py @@ -0,0 +1,559 @@ +"""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): + 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): + 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): + # 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): + 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): + 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): + 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): + 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): + 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): + 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 + 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 + 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): + _apply(FakeEnforcer(), _editor(("courses.view_course",))) + + cache_invalidation.assert_called_once_with() + + def test_successful_apply_skips_invalidation_when_unchanged(self, cache_invalidation): + enforcer = FakeEnforcer() + document = _editor(("courses.view_course",)) + _apply(enforcer, document) + cache_invalidation.reset_mock() + + _apply(enforcer, document) + + cache_invalidation.assert_not_called() + + +class DefinitionPruningTests(TestCase): + """Definition/source tables track the compiled schema across applies.""" + + def test_removed_permission_prunes_grant_and_definition(self): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + enforcer = FakeEnforcer() + + self._plan(enforcer, _editor(("courses.view_course",))) + + assert enforcer.get_policy() == [] + assert AuthzRoleDefinition.objects.count() == 0 diff --git a/openedx_authz/tests/schema/test_compilation.py b/openedx_authz/tests/schema/test_compilation.py new file mode 100644 index 00000000..37e62120 --- /dev/null +++ b/openedx_authz/tests/schema/test_compilation.py @@ -0,0 +1,331 @@ +"""Unit tests for the schema compilation step (merge + extensions + priority).""" + +import logging + +import pytest + +from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.engine.schema.exceptions import SchemaCompileError +from openedx_authz.engine.schema.types import ORIGIN_BASE, ORIGIN_EXTENSION + +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)], + ) + + +def test_base_definitions_compile(): + 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", + ) + + +def test_extension_adds_and_removes_permissions_and_metadata(): + 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(): + 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 + + +def test_equal_priority_metadata_conflict_raises(): + 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(): + 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(): + 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(): + 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(): + 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" + + +def test_base_permissions_get_base_provenance(): + 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", ORIGIN_BASE)] + + +def test_extension_grant_is_attributed_to_the_module_not_core(): + 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] == [ORIGIN_BASE] + assert [rs.origin_kind for rs in added] == [ORIGIN_EXTENSION] + + +def test_removed_permission_has_no_provenance(): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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/openedx_authz/tests/schema/test_discovery.py b/openedx_authz/tests/schema/test_discovery.py new file mode 100644 index 00000000..e1c6071a --- /dev/null +++ b/openedx_authz/tests/schema/test_discovery.py @@ -0,0 +1,277 @@ +"""Tests for directory-based schema discovery (ADR 0019).""" + +import sys +from unittest import mock + +import pytest + +from openedx_authz.engine.schema.discovery import ( + DiscoveredResource, + SchemaDiscovery, + SchemaDiscoveryError, +) + +SCHEMA_DIR = "openedx_authz/authz/schema" +EXPECTED_FILES = { + "course_permissions.yaml", + "course_roles.yaml", + "library_permissions.yaml", + "library_roles.yaml", +} + + +def test_directory_is_expanded_to_yaml_files(): + resources = SchemaDiscovery(explicit_directories=[SCHEMA_DIR]).discover() + names = {r.resource_path.rsplit("/", 1)[-1] for r in resources} + assert names == EXPECTED_FILES + + +def test_discovered_resource_anchors_and_module_are_set(): + resources = SchemaDiscovery(explicit_directories=[SCHEMA_DIR]).discover() + sample = resources[0] + assert sample.package == "openedx_authz" # importable anchor + assert sample.resource_path.startswith("authz/schema/") + assert sample.module == "openedx_authz.authz.schema" # source identity + + +def test_contents_are_readable(): + discovery = SchemaDiscovery(explicit_directories=[SCHEMA_DIR]) + resources = discovery.discover() + assert discovery.resolve_contents(resources[0]) # non-empty bytes + + +def test_discovery_is_deterministic(): + a = SchemaDiscovery(explicit_directories=[SCHEMA_DIR]).discover() + b = SchemaDiscovery(explicit_directories=[SCHEMA_DIR]).discover() + assert a == b + + +def test_unknown_directory_raises(): + with pytest.raises(SchemaDiscoveryError): + SchemaDiscovery(explicit_directories=["openedx_authz/authz/does_not_exist"]).discover() + + +def test_own_entry_point_directory_is_discovered(): + # The installed 'authz.schema' entry point should yield this package's files. + resources = SchemaDiscovery().discover() + names = {r.resource_path.rsplit("/", 1)[-1] for r in resources} + assert EXPECTED_FILES.issubset(names) + + +def test_duplicate_directories_are_deduplicated(): + resources = SchemaDiscovery(explicit_directories=[SCHEMA_DIR, SCHEMA_DIR]).discover() + paths = [r.resource_path for r in resources] + assert len(paths) == len(set(paths)) == len(EXPECTED_FILES) + + +class TestSettingsDirectories: + """The operator/Tutor contribution route (ADR 0019 §1, ADR 0023 §4). + + Tutor patches ``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` and then runs the + deployment command, so this is the only path a site operator has for + contributing a schema without shipping a Python package. + """ + + def test_settings_directories_are_discovered(self, settings): + settings.OPENEDX_AUTHZ_SCHEMA_DIRECTORIES = [SCHEMA_DIR] + + resources = SchemaDiscovery().discover() + + names = {r.resource_path.rsplit("/", 1)[-1] for r in resources} + assert EXPECTED_FILES.issubset(names) + + def test_absent_setting_contributes_nothing(self): + """The default state: no deployment has declared the setting at all. + + ``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` is deliberately not defined in the + packaged settings, so it is read with a ``getattr`` default. + """ + # pylint: disable=protected-access + assert not SchemaDiscovery()._discover_settings_directories() + + def test_empty_setting_contributes_nothing(self, settings): + settings.OPENEDX_AUTHZ_SCHEMA_DIRECTORIES = [] + + # pylint: disable=protected-access + assert not SchemaDiscovery()._discover_settings_directories() + + def test_none_setting_contributes_nothing(self, settings): + settings.OPENEDX_AUTHZ_SCHEMA_DIRECTORIES = None + + # pylint: disable=protected-access + assert not SchemaDiscovery()._discover_settings_directories() + + def test_bad_settings_directory_raises(self, settings): + settings.OPENEDX_AUTHZ_SCHEMA_DIRECTORIES = ["openedx_authz/authz/does_not_exist"] + + with pytest.raises(SchemaDiscoveryError): + SchemaDiscovery().discover() + + def test_settings_resource_is_marked_with_its_origin(self, settings): + """``origin`` is diagnostic only, but it must identify the real route.""" + settings.OPENEDX_AUTHZ_SCHEMA_DIRECTORIES = [SCHEMA_DIR] + + # pylint: disable=protected-access + resources = SchemaDiscovery()._discover_settings_directories() + + assert {r.origin for r in resources} == {"settings"} + + def test_missing_django_contributes_nothing(self): + """The Casbin-free steps must stay importable and runnable without Django.""" + with mock.patch.dict(sys.modules, {"django.conf": None}): + # pylint: disable=protected-access + assert not SchemaDiscovery()._discover_settings_directories() + + def test_unconfigured_django_contributes_nothing(self, monkeypatch): + """The pipeline must stay usable outside a Django process. + + Reading a setting with no ``DJANGO_SETTINGS_MODULE`` raises + ``ImproperlyConfigured``, which would otherwise surface as an unrelated + Django failure during a CI schema check. + """ + # pylint: disable=import-outside-toplevel + from django.conf import LazySettings + from django.core.exceptions import ImproperlyConfigured + + def _unconfigured(_self, name): + raise ImproperlyConfigured(f"Requested setting {name}, but settings are not configured") + + monkeypatch.setattr(LazySettings, "__getattr__", _unconfigured) + + # pylint: disable=protected-access + assert not SchemaDiscovery()._discover_settings_directories() + + +class TestDiscoverySourcePrecedence: + """Discovery merges sources in a fixed order and de-duplicates first-wins.""" + + def test_entry_point_origin_wins_over_explicit_duplicate(self): + """The same file from two routes is kept once, tagged with the first route.""" + resources = SchemaDiscovery(explicit_directories=[SCHEMA_DIR]).discover() + + assert {r.origin for r in resources} == {"entry_point"} + assert len(resources) == len(EXPECTED_FILES) + + def test_explicit_only_directory_is_marked_explicit(self): + resources = SchemaDiscovery(explicit_directories=[SCHEMA_DIR])._iter_directory( # pylint: disable=protected-access + SCHEMA_DIR, origin="explicit" + ) + + assert {r.origin for r in resources} == {"explicit"} + + +class TestDiscoveryErrors: + """Every failure surfaces as SchemaDiscoveryError naming what went wrong.""" + + def test_failing_provider_names_the_entry_point(self, monkeypatch): + """ADR 0019 §1: discovery stops and reports which application failed. + + Continuing would apply an incomplete set of static definitions. + """ + from importlib import metadata # pylint: disable=import-outside-toplevel + + class _BrokenEntryPoint: + """An ``authz.schema`` entry point whose import fails.""" + + name = "broken_app" + value = "broken_app.authz:get_schema_resources" + + @staticmethod + def load(): + """Fail the way a broken module import would.""" + raise RuntimeError("provider exploded") + + monkeypatch.setattr(metadata, "entry_points", lambda **_kwargs: [_BrokenEntryPoint()]) + + with pytest.raises(SchemaDiscoveryError) as exc_info: + SchemaDiscovery().discover() + + assert "broken_app" in str(exc_info.value) + assert "provider exploded" in str(exc_info.value) + + def test_provider_raising_when_called_is_also_reported(self, monkeypatch): + from importlib import metadata # pylint: disable=import-outside-toplevel + + class _BrokenProvider: + """An entry point that imports fine but fails when called.""" + + name = "late_app" + value = "late_app.authz:get_schema_resources" + + @staticmethod + def load(): + """Return a provider that raises on invocation.""" + + def _provider(): + raise ValueError("no schema here") + + return _provider + + monkeypatch.setattr(metadata, "entry_points", lambda **_kwargs: [_BrokenProvider()]) + + with pytest.raises(SchemaDiscoveryError, match="late_app"): + SchemaDiscovery().discover() + + def test_empty_directory_path_raises(self): + with pytest.raises(SchemaDiscoveryError, match="Empty schema directory path"): + SchemaDiscovery(explicit_directories=["/"]).discover() + + def test_unreadable_resource_raises(self): + discovery = SchemaDiscovery(explicit_directories=[SCHEMA_DIR]) + resource = discovery.discover()[0] + missing = DiscoveredResource( + package=resource.package, + resource_path="authz/schema/not_a_real_file.yaml", + module=resource.module, + origin="explicit", + ) + + with pytest.raises(SchemaDiscoveryError, match="Could not read schema resource"): + discovery.resolve_contents(missing) + + +class TestSchemaFileSelection: + """Only YAML *files* are picked up; other entries are ignored. + + These drive ``_iter_directory`` directly so the assertions cover just the + directory being scanned, not the entry points ``discover`` also merges in. + """ + + @staticmethod + def _iter(directory): + # pylint: disable=protected-access + return SchemaDiscovery()._iter_directory(directory, origin="explicit") + + def test_yml_suffix_is_accepted(self, tmp_path, monkeypatch): + package = tmp_path / "fake_authz_pkg" + (package / "schema").mkdir(parents=True) + (package / "schema" / "roles.yml").write_text("schema_version: '1.0'\n", encoding="utf-8") + (package / "schema" / "notes.txt").write_text("ignored\n", encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + + resources = self._iter("fake_authz_pkg/schema") + + assert [r.resource_path for r in resources] == ["schema/roles.yml"] + + def test_directory_named_like_a_schema_file_is_skipped(self, tmp_path, monkeypatch): + package = tmp_path / "other_authz_pkg" + # A directory named '*.yaml' passes the suffix check but is not a file. + (package / "schema" / "subdir.yaml").mkdir(parents=True) + (package / "schema" / "readme.md").write_text("ignored\n", encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + + assert not self._iter("other_authz_pkg/schema") + + def test_both_yaml_and_yml_are_collected_in_name_order(self, tmp_path, monkeypatch): + package = tmp_path / "mixed_authz_pkg" + (package / "schema").mkdir(parents=True) + for name in ("b_roles.yml", "a_permissions.yaml"): + (package / "schema" / name).write_text("schema_version: '1.0'\n", encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + + resources = self._iter("mixed_authz_pkg/schema") + + assert [r.resource_path for r in resources] == [ + "schema/a_permissions.yaml", + "schema/b_roles.yml", + ] diff --git a/openedx_authz/tests/schema/test_load_authz_schema_command.py b/openedx_authz/tests/schema/test_load_authz_schema_command.py new file mode 100644 index 00000000..4d7de792 --- /dev/null +++ b/openedx_authz/tests/schema/test_load_authz_schema_command.py @@ -0,0 +1,267 @@ +"""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): + 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 row(s) added, 1 removed" in output + + def test_apply_reports_unchanged(self): + 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): + 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): + 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): + 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): + 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 "Rows to add (1)" in output + assert "Rows to remove (1)" in output + assert "role^r" in output + assert "role^old" in output + + def test_dry_run_reports_blocking_assignments(self): + 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): + 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): + 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): + 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): + 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 "Definitions unchanged." in output + + +class TestDirectoryOption: + """Cover the --dir option wiring into SchemaDiscovery.""" + + def test_dir_builds_discovery_with_explicit_directories(self): + 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( + explicit_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): + 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): + 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): + 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): + 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): + 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): + 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(explicit_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/openedx_authz/tests/schema/test_loading.py b/openedx_authz/tests/schema/test_loading.py new file mode 100644 index 00000000..8999fc0b --- /dev/null +++ b/openedx_authz/tests/schema/test_loading.py @@ -0,0 +1,234 @@ +"""Unit tests for the schema loading step.""" + +from importlib import metadata + +import pytest + +from openedx_authz.engine.schema.discovery import SchemaDiscovery +from openedx_authz.engine.schema.exceptions import SchemaLoadError +from openedx_authz.engine.schema.loading import UNKNOWN, SchemaLoader + +from .factories import StubDiscovery + +VALID_YAML = b""" +schema_version: "1.0" +priority: 150 + +permission_categories: + - id: course_content + display_name: Course content + description: Course content permissions. + icon: Article + +permissions: + - namespace: courses + name: view_course + display_name: View course + description: View a course. + category: course_content + scopes: [course-v1] + +roles: + - id: course_observer + display_name: Course observer + description: Reviews a course. + scopes: [course-v1] + hidden: true + permissions: + - courses.view_course + +role_extensions: + - role: course_editor + add_permissions: [courses.export_course] +""" + + +def _load(contents: bytes): + key = ("pkg.mod", "file.authz.yaml") + discovery = StubDiscovery({key: contents}) + loader = SchemaLoader(discovery=discovery) + return loader.load(discovery.discover()) + + +def test_loads_all_blocks_into_typed_objects(): + docs = _load(VALID_YAML) + assert len(docs) == 1 + doc = docs[0] + assert doc.priority == 150 + assert doc.source.schema_version == "1.0" + assert doc.source.content_digest # digest computed + assert doc.categories[0].id == "course_content" + assert doc.permissions[0].identifier == "courses.view_course" + assert doc.permissions[0].scopes == ("course-v1",) + assert doc.roles[0].hidden is True + assert doc.roles[0].permissions == ("courses.view_course",) + assert doc.role_extensions[0].role == "course_editor" + assert doc.role_extensions[0].add_permissions == ("courses.export_course",) + + +def test_empty_document_yields_empty_blocks(): + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + assert docs[0].categories == [] + assert docs[0].roles == [] + + +def test_completely_empty_file_is_treated_as_an_empty_mapping(): + """An empty file parses to ``None``; validation rejects it, loading must not.""" + docs = _load(b"") + + assert docs[0].priority == 0 + assert docs[0].source.schema_version == "" + assert docs[0].roles == [] + + +def test_invalid_yaml_raises_load_error(): + with pytest.raises(SchemaLoadError): + _load(b"schema_version: '1.0'\n bad: [unclosed\n") + + +def test_non_mapping_top_level_raises_load_error(): + with pytest.raises(SchemaLoadError): + _load(b"- just\n- a\n- list\n") + + +def test_non_integer_priority_raises_load_error(): + with pytest.raises(SchemaLoadError): + _load(b"schema_version: '1.0'\npriority: high\n") + + +class TestSourceIdentity: + """``(distribution, module)`` is the identity of a source record (ADR 0025 §2). + + The rest of the suite builds ``SourceRecord`` values through factories, so + these tests are the only ones that exercise the real resolution against + installed package metadata. + """ + + def test_installed_package_resolves_to_its_distribution(self): + """A module shipped by this package resolves to the real distribution.""" + discovery = SchemaDiscovery(explicit_directories=["openedx_authz/authz/schema"]) + docs = SchemaLoader(discovery=discovery).load(discovery.discover()) + + assert {doc.source.distribution for doc in docs} == {"openedx-authz"} + assert all(doc.source.distribution_version != UNKNOWN for doc in docs) + + def test_module_is_recorded_as_the_dotted_path(self): + discovery = SchemaDiscovery(explicit_directories=["openedx_authz/authz/schema"]) + docs = SchemaLoader(discovery=discovery).load(discovery.discover()) + + assert {doc.source.module for doc in docs} == {"openedx_authz.authz.schema"} + + def test_unknown_package_falls_back_to_its_top_level_name(self): + """An operator-supplied directory need not belong to a distribution.""" + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + + assert docs[0].source.distribution == "pkg" + assert docs[0].source.distribution_version == UNKNOWN + + def test_missing_distribution_metadata_falls_back_to_unknown_version(self, monkeypatch): + monkeypatch.setattr(metadata, "packages_distributions", lambda: {"pkg": ["ghost-dist"]}) + + def _missing(_name): + raise metadata.PackageNotFoundError("ghost-dist") + + monkeypatch.setattr(metadata, "version", _missing) + + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + + assert docs[0].source.distribution == "ghost-dist" + assert docs[0].source.distribution_version == UNKNOWN + + def test_unreadable_package_metadata_is_tolerated(self, monkeypatch): + """Environment quirks must not break the loader.""" + + def _boom(): + raise RuntimeError("metadata backend unavailable") + + monkeypatch.setattr(metadata, "packages_distributions", _boom) + + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + + assert docs[0].source.distribution == "pkg" + + def test_digest_reflects_the_file_contents(self): + first = _load(b"schema_version: '1.0'\npriority: 1\n")[0] + second = _load(b"schema_version: '1.0'\npriority: 2\n")[0] + + assert first.source.content_digest != second.source.content_digest + + def test_identical_contents_produce_the_same_digest(self): + first = _load(b"schema_version: '1.0'\npriority: 1\n")[0] + second = _load(b"schema_version: '1.0'\npriority: 1\n")[0] + + assert first.source.content_digest == second.source.content_digest + + +class TestFieldCoercion: + """Loader-level normalization of YAML shapes.""" + + def test_scalar_scope_becomes_a_one_tuple(self): + """``scopes: course-v1`` is accepted as shorthand for a single-item list.""" + docs = _load( + b"schema_version: '1.0'\n" + b"priority: 1\n" + b"roles:\n" + b" - id: course_observer\n" + b" scopes: course-v1\n" + b" permissions: courses.view_course\n" + ) + + assert docs[0].roles[0].scopes == ("course-v1",) + assert docs[0].roles[0].permissions == ("courses.view_course",) + + def test_null_blocks_are_treated_as_empty(self): + docs = _load(b"schema_version: '1.0'\npriority: 1\nroles:\npermissions:\n") + + assert docs[0].roles == [] + assert docs[0].permissions == [] + + def test_missing_priority_defaults_to_zero(self): + """Priority decides every conflict, so the default is worth pinning down.""" + docs = _load(b"schema_version: '1.0'\n") + + assert docs[0].priority == 0 + + def test_missing_schema_version_is_empty_not_absent(self): + """Validation rejects it later; the loader must not crash on it.""" + docs = _load(b"priority: 1\n") + + assert docs[0].source.schema_version == "" + + def test_numeric_string_priority_is_accepted(self): + docs = _load(b"schema_version: '1.0'\npriority: '150'\n") + + assert docs[0].priority == 150 + + def test_extension_hidden_false_is_preserved_as_a_change(self): + """``hidden`` is tri-state: ``False`` differs from absent (ADR 0023 §1).""" + docs = _load( + b"schema_version: '1.0'\npriority: 1\nrole_extensions:\n - role: course_editor\n hidden: false\n" + ) + + assert docs[0].role_extensions[0].hidden is False + + def test_extension_without_hidden_leaves_it_unset(self): + docs = _load( + b"schema_version: '1.0'\npriority: 1\nrole_extensions:\n - role: course_editor\n icon: Article\n" + ) + + assert docs[0].role_extensions[0].hidden is None + + +class TestMalformedEntries: + """A block entry that is not a mapping stops the load with context.""" + + @pytest.mark.parametrize("block", ["permission_categories", "permissions", "roles", "role_extensions"]) + def test_non_mapping_entry_raises_with_the_block_name(self, block): + contents = f"schema_version: '1.0'\npriority: 1\n{block}:\n - just_a_string\n".encode() + + with pytest.raises(SchemaLoadError, match=block): + _load(contents) + + def test_error_names_the_source(self): + with pytest.raises(SchemaLoadError, match="pkg.mod"): + _load(b"schema_version: '1.0'\npriority: 1\nroles:\n - 5\n") diff --git a/openedx_authz/tests/schema/test_pipeline.py b/openedx_authz/tests/schema/test_pipeline.py new file mode 100644 index 00000000..39c17d90 --- /dev/null +++ b/openedx_authz/tests/schema/test_pipeline.py @@ -0,0 +1,188 @@ +"""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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + pipeline, m = _pipeline() + pipeline.apply(force=True) + m["applier"].apply.assert_called_once_with("rendered-policy", "compiled-schema", force=True) + + +def test_default_components_are_constructed_when_not_injected(): + """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/openedx_authz/tests/schema/test_renderer.py b/openedx_authz/tests/schema/test_renderer.py new file mode 100644 index 00000000..cdde5078 --- /dev/null +++ b/openedx_authz/tests/schema/test_renderer.py @@ -0,0 +1,166 @@ +"""Unit tests for the (pure) render step and renderer helper methods.""" + +import sys +import types +from unittest import mock + +from openedx_authz.engine.renderer import PolicyRenderer, SchemaApplier +from openedx_authz.engine.schema.compilation import SchemaCompiler + +from .factories import category, make_document, permission, role + + +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(): + rendered = PolicyRenderer().render(_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(): + rendered = PolicyRenderer().render(_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(): + schema = _schema() + assert PolicyRenderer().render(schema).rows == PolicyRenderer().render(schema).rows + + +def test_multiple_scopes_multiply_rows(): + 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: + """Cover SchemaApplier._resolve_enforcer both branches.""" + + 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 lazy import target to prove it is never touched. + with mock.patch("openedx_authz.engine.enforcer.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.enforcer.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: + """Cover SchemaApplier._emit_assignment_deleted.""" + + def test_no_op_when_no_assignments(self): + """Empty input emits nothing and does not import event machinery.""" + with mock.patch.dict(sys.modules): + # If the method tried to import openedx_events, a missing stub would + # raise; the early return means it never gets there. + SchemaApplier._emit_assignment_deleted([]) # pylint: disable=protected-access + + 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^*"), + ] + + # Build lazy-import stubs for the modules the method imports internally. + crum_mod = types.ModuleType("crum") + crum_mod.get_current_user = lambda: types.SimpleNamespace(id=42) + + role_assignment_data = mock.MagicMock(name="RoleAssignmentEventData") + events_data = types.ModuleType("openedx_events.authz.data") + events_data.RoleAssignmentData = role_assignment_data + + signal = mock.MagicMock(name="ROLE_ASSIGNMENT_DELETED") + events_signals = types.ModuleType("openedx_events.authz.signals") + events_signals.ROLE_ASSIGNMENT_DELETED = signal + + with mock.patch.dict( + sys.modules, + { + "crum": crum_mod, + "openedx_events.authz.data": events_data, + "openedx_events.authz.signals": events_signals, + }, + ): + 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^*")] + + crum_mod = types.ModuleType("crum") + crum_mod.get_current_user = lambda: None + + role_assignment_data = mock.MagicMock(name="RoleAssignmentEventData") + events_data = types.ModuleType("openedx_events.authz.data") + events_data.RoleAssignmentData = role_assignment_data + + signal = mock.MagicMock(name="ROLE_ASSIGNMENT_DELETED") + events_signals = types.ModuleType("openedx_events.authz.signals") + events_signals.ROLE_ASSIGNMENT_DELETED = signal + + with mock.patch.dict( + sys.modules, + { + "crum": crum_mod, + "openedx_events.authz.data": events_data, + "openedx_events.authz.signals": events_signals, + }, + ): + 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/openedx_authz/tests/schema/test_source_storage.py b/openedx_authz/tests/schema/test_source_storage.py new file mode 100644 index 00000000..230b31f2 --- /dev/null +++ b/openedx_authz/tests/schema/test_source_storage.py @@ -0,0 +1,329 @@ +"""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 StoreSourcesTests(TestCase): + """Persistence of compiled definitions and their provenance.""" + + def test_definitions_are_persisted(self): + _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): + _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): + _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): + _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): + _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): + _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): + # 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 SourceGranularityTests(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): + 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): + 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): + 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): + _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): + _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 DefinitionDisplayTests(TestCase): + """Human-readable identifiers used by the Django admin fallback (ADR 0018 §7).""" + + def test_source_string_is_distribution_and_module_path(self): + _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): + _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): + _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 DefensiveStorageTests(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): + 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/openedx_authz/tests/schema/test_types.py b/openedx_authz/tests/schema/test_types.py new file mode 100644 index 00000000..1ee4c2ea --- /dev/null +++ b/openedx_authz/tests/schema/test_types.py @@ -0,0 +1,50 @@ +"""Unit tests for schema type helpers.""" + +from openedx_authz.engine.schema.types import ( + CompiledDefinition, + CompiledSchema, + PermissionDefinition, + RoleDefinition, + SourceRecord, +) + + +def test_source_id_combines_distribution_and_module_path(): + source = SourceRecord( + distribution="openedx-authz", + distribution_version="1.0", + module="openedx_authz.authz", + resource_path="course_roles.authz.yaml", + schema_version="1.0", + content_digest="abc", + ) + assert source.source_id == "openedx-authz:openedx_authz/authz/course_roles.authz.yaml" + + +def test_permission_identifier_joins_namespace_and_name(): + perm = PermissionDefinition( + namespace="courses", + name="view_course", + display_name="View", + description="d", + category="cat", + scopes=("course-v1",), + ) + assert perm.identifier == "courses.view_course" + + +def test_role_permission_pairs_are_sorted_and_flattened(): + role = RoleDefinition( + id="course_admin", + display_name="Admin", + description="d", + scopes=("course-v1",), + permissions=("courses.view_course", "courses.edit_course_content"), + ) + schema = CompiledSchema( + roles={"course_admin": CompiledDefinition("role", "course_admin", role, ())} + ) + assert schema.role_permission_pairs() == [ + ("course_admin", "courses.edit_course_content"), + ("course_admin", "courses.view_course"), + ] diff --git a/openedx_authz/tests/schema/test_validation.py b/openedx_authz/tests/schema/test_validation.py new file mode 100644 index 00000000..0b02f68d --- /dev/null +++ b/openedx_authz/tests/schema/test_validation.py @@ -0,0 +1,394 @@ +"""Unit tests for the schema validation step.""" + +import pytest + +from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.engine.schema.validation import ( + ERROR, + WARNING, + 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 test_valid_document_has_no_errors(): + 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(): + 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(): + doc = make_document(permissions=[permission(namespace="Courses")]) + assert _errors(SchemaValidator().validate([doc])) + + +def test_casbin_internal_form_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(): + 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(): + 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(): + 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(): + 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(): + 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(): + 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) + + +def _warnings(issues): + return [i for i in issues if not i.is_error] + + +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): + 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): + 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): + 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", ""]) + def test_permission_id_must_be_namespace_dot_name(self, value): + doc = make_document(roles=[role(permissions=(value,))]) + + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + + assert any("must be 'namespace.name'" in m for m in messages) + + def test_permission_id_halves_are_validated(self): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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 TestHasErrors: + """The gate the pipeline uses to decide whether to stop.""" + + def test_true_when_any_issue_is_an_error(self): + issues = [ValidationIssue(WARNING, "heads up"), ValidationIssue(ERROR, "boom")] + + assert SchemaValidator.has_errors(issues) is True + + def test_false_for_warnings_only(self): + assert SchemaValidator.has_errors([ValidationIssue(WARNING, "heads up")]) is False + + def test_false_for_no_issues(self): + assert SchemaValidator.has_errors([]) is False diff --git a/setup.py b/setup.py index b43cec91..87cdc1bb 100755 --- a/setup.py +++ b/setup.py @@ -165,5 +165,12 @@ def is_requirement(line): "cms.djangoapp": [ "openedx_authz = openedx_authz.apps:OpenedxAuthzConfig", ], + # Static authorization schema resources contributed by this package + # (ADR 0019). openedx-authz is a schema provider like any other + # distribution; the callable returns resource paths relative to the + # openedx_authz.authz module. + "authz.schema": [ + "openedx_authz = openedx_authz.authz:get_schema_resources", + ], }, )