From c0f666ea1b54d5a3348553c87d10cfbc1164da89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20M=C3=A9ndez?= Date: Wed, 23 Sep 2026 10:03:34 -0600 Subject: [PATCH 1/4] feat: add authz schema definition models --- .../0011_authz_schema_definitions.py | 338 +++++++++++++++++ src/openedx_authz/models/__init__.py | 1 + src/openedx_authz/models/schema.py | 358 ++++++++++++++++++ .../tests/schema/test_schema_models.py | 145 +++++++ 4 files changed, 842 insertions(+) create mode 100644 src/openedx_authz/migrations/0011_authz_schema_definitions.py create mode 100644 src/openedx_authz/models/schema.py create mode 100644 src/openedx_authz/tests/schema/test_schema_models.py diff --git a/src/openedx_authz/migrations/0011_authz_schema_definitions.py b/src/openedx_authz/migrations/0011_authz_schema_definitions.py new file mode 100644 index 00000000..edd84044 --- /dev/null +++ b/src/openedx_authz/migrations/0011_authz_schema_definitions.py @@ -0,0 +1,338 @@ +"""Compiled authorization definitions and source-tracking tables (ADR 0025).""" + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Create compiled authz definition and source-tracking tables (ADR 0025).""" + + dependencies = [ + ("openedx_authz", "0010_scope_external_key"), + ] + + operations = [ + migrations.CreateModel( + name="AuthzSchemaSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "distribution", + models.CharField( + help_text="Installed distribution that shipped the contribution (e.g. 'openedx-authz').", + max_length=255, + ), + ), + ( + "module", + models.CharField( + help_text="Python module that owns the schema resource (e.g. 'openedx_authz.authz').", + max_length=255, + ), + ), + ("distribution_version", models.CharField(blank=True, default="", max_length=64)), + ( + "resource_path", + models.CharField( + blank=True, + default="", + help_text="Latest-seen resource path within the module. Non-identifying.", + max_length=255, + ), + ), + ("content_digest", models.CharField(blank=True, default="", max_length=64)), + ("schema_version", models.CharField(blank=True, default="", max_length=16)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Authz Schema Source", + "verbose_name_plural": "Authz Schema Sources", + }, + ), + migrations.CreateModel( + name="AuthzPermissionCategory", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("category_id", models.CharField(max_length=255, unique=True)), + ("display_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("icon", models.CharField(blank=True, max_length=128, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Authz Permission Category", + "verbose_name_plural": "Authz Permission Categories", + }, + ), + migrations.CreateModel( + name="AuthzPermissionDefinition", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("namespace", models.CharField(max_length=255)), + ("name", models.CharField(max_length=255)), + ("display_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("scopes", models.JSONField(default=list)), + ("icon", models.CharField(blank=True, max_length=128, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "category", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="permissions", + to="openedx_authz.authzpermissioncategory", + ), + ), + ], + options={ + "verbose_name": "Authz Permission Definition", + "verbose_name_plural": "Authz Permission Definitions", + }, + ), + migrations.CreateModel( + name="AuthzRoleDefinition", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("role_id", models.CharField(max_length=255, unique=True)), + ("display_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("scopes", models.JSONField(default=list)), + ("icon", models.CharField(blank=True, max_length=128, null=True)), + ("hidden", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Authz Role Definition", + "verbose_name_plural": "Authz Role Definitions", + }, + ), + migrations.CreateModel( + name="AuthzRolePermission", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "scope", + models.CharField( + help_text="Scope namespace where the grant applies (e.g. 'course-v1', 'lib').", + max_length=255, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="role_permissions", + to="openedx_authz.authzpermissiondefinition", + ), + ), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="role_permissions", + to="openedx_authz.authzroledefinition", + ), + ), + ], + options={ + "verbose_name": "Authz Role Permission", + "verbose_name_plural": "Authz Role Permissions", + }, + ), + migrations.CreateModel( + name="AuthzCategorySource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzpermissioncategory" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Category Source", + "verbose_name_plural": "Authz Category Sources", + }, + ), + migrations.CreateModel( + name="AuthzPermissionSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzpermissiondefinition" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Permission Source", + "verbose_name_plural": "Authz Permission Sources", + }, + ), + migrations.CreateModel( + name="AuthzRoleSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzroledefinition" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Role Source", + "verbose_name_plural": "Authz Role Sources", + }, + ), + migrations.CreateModel( + name="AuthzRolePermissionSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "role_permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzrolepermission" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Role Permission Source", + "verbose_name_plural": "Authz Role Permission Sources", + }, + ), + migrations.AddField( + model_name="authzpermissioncategory", + name="sources", + field=models.ManyToManyField( + related_name="categories", + through="openedx_authz.AuthzCategorySource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddField( + model_name="authzpermissiondefinition", + name="sources", + field=models.ManyToManyField( + related_name="permissions", + through="openedx_authz.AuthzPermissionSource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddField( + model_name="authzroledefinition", + name="sources", + field=models.ManyToManyField( + related_name="roles", + through="openedx_authz.AuthzRoleSource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddField( + model_name="authzrolepermission", + name="sources", + field=models.ManyToManyField( + related_name="role_permissions", + through="openedx_authz.AuthzRolePermissionSource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddConstraint( + model_name="authzschemasource", + constraint=models.UniqueConstraint( + fields=["distribution", "module"], name="authz_source_dist_module_uniq" + ), + ), + migrations.AddConstraint( + model_name="authzpermissiondefinition", + constraint=models.UniqueConstraint(fields=["namespace", "name"], name="authz_permission_ns_name_uniq"), + ), + migrations.AddConstraint( + model_name="authzrolepermission", + constraint=models.UniqueConstraint( + fields=["role", "permission", "scope"], name="authz_role_permission_uniq" + ), + ), + migrations.AddConstraint( + model_name="authzcategorysource", + constraint=models.UniqueConstraint(fields=["category", "source"], name="authz_category_source_uniq"), + ), + migrations.AddConstraint( + model_name="authzpermissionsource", + constraint=models.UniqueConstraint( + fields=["permission", "source"], name="authz_permission_source_uniq" + ), + ), + migrations.AddConstraint( + model_name="authzrolesource", + constraint=models.UniqueConstraint(fields=["role", "source"], name="authz_role_source_uniq"), + ), + migrations.AddConstraint( + model_name="authzrolepermissionsource", + constraint=models.UniqueConstraint( + fields=["role_permission", "source"], name="authz_role_permission_source_uniq" + ), + ), + ] diff --git a/src/openedx_authz/models/__init__.py b/src/openedx_authz/models/__init__.py index 06b5d003..6f3a3b22 100644 --- a/src/openedx_authz/models/__init__.py +++ b/src/openedx_authz/models/__init__.py @@ -17,5 +17,6 @@ from openedx_authz.models.authz_migration import * from openedx_authz.models.core import * +from openedx_authz.models.schema import * from openedx_authz.models.scopes import * from openedx_authz.models.subjects import * diff --git a/src/openedx_authz/models/schema.py b/src/openedx_authz/models/schema.py new file mode 100644 index 00000000..9adbecec --- /dev/null +++ b/src/openedx_authz/models/schema.py @@ -0,0 +1,358 @@ +"""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 + +from openedx_authz.constants import SchemaOriginKind + +__all__ = [ + "OriginKind", + "AuthzSchemaSource", + "AuthzPermissionCategory", + "AuthzPermissionDefinition", + "AuthzRoleDefinition", + "AuthzRolePermission", + "AuthzCategorySource", + "AuthzPermissionSource", + "AuthzRoleSource", + "AuthzRolePermissionSource", + "origins_for_role", + "origins_for_permission", + "origins_for_category", + "origin_for_role_permission", +] + + +class OriginKind(models.TextChoices): + """Whether a contribution is a base definition or an extension (ADR 0023/0025). + + Values are sourced from + :class:`openedx_authz.constants.SchemaOriginKind` so the persistence layer + and the (Django-free) engine schema types share a single source of truth; + the second tuple element is the human-readable label. + """ + + BASE = SchemaOriginKind.BASE.value, "Base" + EXTENSION = SchemaOriginKind.EXTENSION.value, "Extension" + + +class 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/src/openedx_authz/tests/schema/test_schema_models.py b/src/openedx_authz/tests/schema/test_schema_models.py new file mode 100644 index 00000000..47480af6 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_schema_models.py @@ -0,0 +1,145 @@ +"""Unit tests for the authz schema definition models (``openedx_authz.models.schema``). + +These cover the model layer in isolation — the string representations, the +derived-identifier properties, and the ``origins_*`` query helpers — by writing +rows directly through the ORM. The applier that populates these tables from a +compiled schema is exercised separately. +""" + +import pytest + +from openedx_authz.constants import SchemaOriginKind +from openedx_authz.models.schema import ( + AuthzCategorySource, + AuthzPermissionCategory, + AuthzPermissionDefinition, + AuthzPermissionSource, + AuthzRoleDefinition, + AuthzRolePermission, + AuthzRolePermissionSource, + AuthzRoleSource, + AuthzSchemaSource, + OriginKind, + origin_for_role_permission, + origins_for_category, + origins_for_permission, + origins_for_role, +) + +pytestmark = pytest.mark.django_db + + +def _source(distribution="openedx-authz", module="openedx_authz.authz"): + return AuthzSchemaSource.objects.create(distribution=distribution, module=module) + + +def _permission(namespace="courses", name="view_course", category=None): + return AuthzPermissionDefinition.objects.create( + namespace=namespace, + name=name, + display_name=name.replace("_", " ").title(), + category=category, + ) + + +class TestOriginKind: + """OriginKind mirrors the Django-free SchemaOriginKind constant.""" + + def test_values_match_the_shared_constant(self): + """The Django enum values mirror the Django-free ``SchemaOriginKind``.""" + assert OriginKind.BASE.value == SchemaOriginKind.BASE.value + assert OriginKind.EXTENSION.value == SchemaOriginKind.EXTENSION.value + + def test_labels_are_human_readable(self): + """Each origin kind exposes a human-readable label.""" + assert OriginKind.BASE.label == "Base" + assert OriginKind.EXTENSION.label == "Extension" + + +class TestStringRepresentations: + """Every model's ``__str__`` and derived-id property.""" + + def test_source_id_replaces_dots_with_slashes(self): + """A source's id renders the dotted module as a slash path.""" + source = _source(module="openedx_authz.authz") + assert source.source_id == "openedx-authz:openedx_authz/authz" + assert str(source) == "openedx-authz:openedx_authz/authz" + + def test_category_str_is_its_id(self): + """A category stringifies to its stable id.""" + category = AuthzPermissionCategory.objects.create(category_id="content", display_name="Content") + assert str(category) == "content" + + def test_permission_identifier_is_namespace_dot_name(self): + """A permission's identifier and str are its ``namespace.name``.""" + permission = _permission(namespace="courses", name="view_course") + assert permission.identifier == "courses.view_course" + assert str(permission) == "courses.view_course" + + def test_role_str_is_its_id(self): + """A role stringifies to its stable role id.""" + role = AuthzRoleDefinition.objects.create(role_id="course_editor", display_name="Course Editor") + assert str(role) == "course_editor" + + def test_role_permission_str_traverses_to_stable_identifiers(self): + """A grant stringifies as ``role -> permission @ scope`` using stable ids.""" + role = AuthzRoleDefinition.objects.create(role_id="course_editor", display_name="Course Editor") + permission = _permission() + grant = AuthzRolePermission.objects.create(role=role, permission=permission, scope="course-v1") + assert str(grant) == "course_editor -> courses.view_course @ course-v1" + + +class TestOriginHelpers: + """The four ``origins_*`` query helpers resolve contributing distributions.""" + + def test_origins_for_role_returns_sorted_distinct_distributions(self): + """``origins_for_role`` returns each contributing distribution once, sorted.""" + role = AuthzRoleDefinition.objects.create(role_id="course_editor", display_name="Course Editor") + core = _source(distribution="openedx-authz") + plugin = _source(distribution="my-plugin", module="my_plugin.authz") + # Two sources, added out of order, to prove sorting and distinctness. + AuthzRoleSource.objects.create(role=role, source=plugin, origin_kind=OriginKind.EXTENSION) + AuthzRoleSource.objects.create(role=role, source=core, origin_kind=OriginKind.BASE) + assert origins_for_role("course_editor") == ["my-plugin", "openedx-authz"] + + def test_origins_for_role_empty_when_unknown(self): + """``origins_for_role`` returns an empty list for an unknown role.""" + assert origins_for_role("does_not_exist") == [] + + def test_origins_for_permission_matches_by_complete_id(self): + """``origins_for_permission`` matches on the full ``namespace.name`` id.""" + permission = _permission(namespace="courses", name="view_course") + source = _source() + AuthzPermissionSource.objects.create(permission=permission, source=source) + assert origins_for_permission("courses.view_course") == ["openedx-authz"] + # A different namespace with the same name must not match. + assert origins_for_permission("libraries.view_course") == [] + + def test_origins_for_category_matches_by_id(self): + """``origins_for_category`` matches on the category id.""" + category = AuthzPermissionCategory.objects.create(category_id="content", display_name="Content") + source = _source() + AuthzCategorySource.objects.create(category=category, source=source) + assert origins_for_category("content") == ["openedx-authz"] + assert origins_for_category("missing") == [] + + def test_origin_for_role_permission_isolates_one_grant(self): + """``origin_for_role_permission`` reports sources for one grant, not sibling grants.""" + role = AuthzRoleDefinition.objects.create(role_id="course_editor", display_name="Course Editor") + granted = _permission(namespace="courses", name="view_course") + other = _permission(namespace="courses", name="edit_course_content") + core = _source(distribution="openedx-authz") + plugin = _source(distribution="my-plugin", module="my_plugin.authz") + + granted_row = AuthzRolePermission.objects.create(role=role, permission=granted, scope="course-v1") + other_row = AuthzRolePermission.objects.create(role=role, permission=other, scope="course-v1") + # The queried grant is contributed by both distributions; the other by core only. + AuthzRolePermissionSource.objects.create(role_permission=granted_row, source=core, origin_kind=OriginKind.BASE) + AuthzRolePermissionSource.objects.create( + role_permission=granted_row, source=plugin, origin_kind=OriginKind.EXTENSION + ) + AuthzRolePermissionSource.objects.create(role_permission=other_row, source=core, origin_kind=OriginKind.BASE) + + assert origin_for_role_permission("course_editor", "courses.view_course") == ["my-plugin", "openedx-authz"] + # The same role, a different permission, is not swept in. + assert origin_for_role_permission("course_editor", "courses.edit_course_content") == ["openedx-authz"] From 18141fca4129c0bb6b118baeecb3f2da6876e145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20M=C3=A9ndez?= Date: Mon, 5 Oct 2026 12:46:19 -0600 Subject: [PATCH 2/4] squash!: Attend PR comments --- src/openedx_authz/models/schema.py | 90 +++++++++++++++++++----------- 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/src/openedx_authz/models/schema.py b/src/openedx_authz/models/schema.py index 9adbecec..852f3c16 100644 --- a/src/openedx_authz/models/schema.py +++ b/src/openedx_authz/models/schema.py @@ -49,6 +49,47 @@ class OriginKind(models.TextChoices): EXTENSION = SchemaOriginKind.EXTENSION.value, "Extension" +class AuthzSchemaSourceQuerySet(models.QuerySet): + """QuerySet for AuthzSchemaSource with provenance lookups. + + The filter methods narrow to the sources that contribute to a given role, + permission, category, or single role-permission grant; they return a + queryset so callers can refine further. :meth:`distributions` is the + terminal that renders the sorted, de-duplicated distribution names the + ``origins_*`` helpers expose. + """ + + def contributing_to_role(self, role_id: str) -> "AuthzSchemaSourceQuerySet": + """Return sources that contribute to a role (base definition + extensions).""" + return self.filter(roles__role_id=role_id) + + def defining_permission(self, identifier: str) -> "AuthzSchemaSourceQuerySet": + """Return sources that define a permission, matched on its complete ``namespace.name`` id.""" + namespace, _, name = identifier.partition(".") + return self.filter(permissions__namespace=namespace, permissions__name=name) + + def defining_category(self, category_id: str) -> "AuthzSchemaSourceQuerySet": + """Return sources that define a category.""" + return self.filter(categories__category_id=category_id) + + def contributing_role_permission(self, role_id: str, permission_identifier: str) -> "AuthzSchemaSourceQuerySet": + """Return sources that contribute one specific role-permission grant. + + This isolates a single grant on a role, so grants provided by different + modules to the same role stay distinguishable from one another. + """ + namespace, _, name = permission_identifier.partition(".") + return self.filter( + role_permissions__role__role_id=role_id, + role_permissions__permission__namespace=namespace, + role_permissions__permission__name=name, + ) + + def distributions(self) -> list[str]: + """Return the sorted, de-duplicated distribution names for the current filter.""" + return sorted(self.values_list("distribution", flat=True).distinct()) + + class AuthzSchemaSource(models.Model): """A distinct schema contribution, identified by distribution and module. @@ -60,6 +101,8 @@ class AuthzSchemaSource(models.Model): diagnostics); change detection relies on diffing compiled definitions. """ + objects = AuthzSchemaSourceQuerySet.as_manager() + distribution = models.CharField( max_length=255, help_text="Installed distribution that shipped the contribution (e.g. 'openedx-authz').", @@ -188,12 +231,14 @@ def __str__(self): class AuthzRolePermission(models.Model): - """A single role-permission-scope grant (one per rendered Casbin ``p`` row). + """A single role-permission-scope association (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. + This records the association declared by the schema; Casbin still manages + the actual permission check. It is the atomic unit of attribution: a base + association and an extension association on the same role are distinct rows + with distinct sources. """ role = models.ForeignKey(AuthzRoleDefinition, on_delete=models.CASCADE, related_name="role_permissions") @@ -294,9 +339,10 @@ class AuthzRolePermissionSource(_BaseSourceLink): .. 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``). + This is where the extension case is recorded: a grant defined together with + the role links to that role's source (``origin_kind=base``), while a grant + added via a ``role_extension`` links to the extending source + (``origin_kind=extension``). """ role_permission = models.ForeignKey(AuthzRolePermission, on_delete=models.CASCADE) @@ -316,43 +362,23 @@ class Meta: 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() - ) + return AuthzSchemaSource.objects.contributing_to_role(role_id).distributions() def origins_for_permission(identifier: str) -> list[str]: """Return the distributions that define a permission, by complete id.""" - namespace, _, name = identifier.partition(".") - return sorted( - AuthzSchemaSource.objects.filter(permissions__namespace=namespace, permissions__name=name) - .values_list("distribution", flat=True) - .distinct() - ) + return AuthzSchemaSource.objects.defining_permission(identifier).distributions() 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() - ) + return AuthzSchemaSource.objects.defining_category(category_id).distributions() def origin_for_role_permission(role_id: str, permission_identifier: str) -> list[str]: """Return the distributions that contribute a specific role-permission grant. - This distinguishes, for one role, the core-provided grants from a grant a - module added, even though both live in the same role. + This distinguishes, for one role, the grants provided by one module vs another, + 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() - ) + return AuthzSchemaSource.objects.contributing_role_permission(role_id, permission_identifier).distributions() From 6e784a9d33598442b239553bf71f27c324fcef89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20M=C3=A9ndez?= Date: Mon, 5 Oct 2026 12:59:08 -0600 Subject: [PATCH 3/4] squash!: Refactor models to extend an abstract model for timestamps --- src/openedx_authz/models/base.py | 37 ++++++++++++++++++++++++++++++ src/openedx_authz/models/schema.py | 21 +++++------------ 2 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 src/openedx_authz/models/base.py diff --git a/src/openedx_authz/models/base.py b/src/openedx_authz/models/base.py new file mode 100644 index 00000000..bf6569f2 --- /dev/null +++ b/src/openedx_authz/models/base.py @@ -0,0 +1,37 @@ +"""Shared abstract base models for the authorization framework. + +These are reusable building blocks meant to remove field-level duplication +across the models package. They carry no table of their own (``abstract = +True``) and add no PII. +""" + +from __future__ import annotations + +from django.db import models + +__all__ = [ + "TimeStampedModel", +] + + +class TimeStampedModel(models.Model): + """Abstract base adding self-managed ``created_at`` / ``updated_at`` timestamps. + + .. no_pii: + + Mirrors the ``created_at`` / ``updated_at`` convention already used across + this repo (see :mod:`openedx_authz.models.core` and + :mod:`openedx_authz.models.authz_migration`) rather than the ``created`` / + ``modified`` names of :class:`model_utils.models.TimeStampedModel`, so + adopting it needs no column renames. New models should inherit this instead + of repeating the two fields; existing models can migrate to it over time. + + ``created_at`` is set once on insert (``auto_now_add``); ``updated_at`` is + refreshed on every save (``auto_now``). + """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True diff --git a/src/openedx_authz/models/schema.py b/src/openedx_authz/models/schema.py index 852f3c16..9ca99dfa 100644 --- a/src/openedx_authz/models/schema.py +++ b/src/openedx_authz/models/schema.py @@ -17,6 +17,7 @@ from django.db import models from openedx_authz.constants import SchemaOriginKind +from openedx_authz.models.base import TimeStampedModel __all__ = [ "OriginKind", @@ -90,7 +91,7 @@ def distributions(self) -> list[str]: return sorted(self.values_list("distribution", flat=True).distinct()) -class AuthzSchemaSource(models.Model): +class AuthzSchemaSource(TimeStampedModel): """A distinct schema contribution, identified by distribution and module. .. no_pii: @@ -120,8 +121,6 @@ class AuthzSchemaSource(models.Model): ) 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" @@ -139,7 +138,7 @@ def __str__(self): return self.source_id -class AuthzPermissionCategory(models.Model): +class AuthzPermissionCategory(TimeStampedModel): """A display/grouping category for permissions (grants no access). .. no_pii: @@ -150,8 +149,6 @@ class AuthzPermissionCategory(models.Model): 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" @@ -161,7 +158,7 @@ def __str__(self): return self.category_id -class AuthzPermissionDefinition(models.Model): +class AuthzPermissionDefinition(TimeStampedModel): """A compiled permission definition. .. no_pii: @@ -183,8 +180,6 @@ class AuthzPermissionDefinition(models.Model): 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" @@ -202,7 +197,7 @@ def __str__(self): return self.identifier -class AuthzRoleDefinition(models.Model): +class AuthzRoleDefinition(TimeStampedModel): """A compiled role definition. .. no_pii: @@ -219,8 +214,6 @@ class AuthzRoleDefinition(models.Model): 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" @@ -230,7 +223,7 @@ def __str__(self): return self.role_id -class AuthzRolePermission(models.Model): +class AuthzRolePermission(TimeStampedModel): """A single role-permission-scope association (one per rendered Casbin ``p`` row). .. no_pii: @@ -250,8 +243,6 @@ class AuthzRolePermission(models.Model): 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" From c04342d27a20f1ee7d5134d42c4addb7fd28e70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20M=C3=A9ndez?= Date: Mon, 5 Oct 2026 13:01:46 -0600 Subject: [PATCH 4/4] squash!: Document model naming convention --- src/openedx_authz/models/schema.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/openedx_authz/models/schema.py b/src/openedx_authz/models/schema.py index 9ca99dfa..f9e51bee 100644 --- a/src/openedx_authz/models/schema.py +++ b/src/openedx_authz/models/schema.py @@ -10,6 +10,11 @@ Casbin ``p`` rows remain the enforcement representation; these tables are the definition/provenance record written alongside them in the same transaction. + +By convention, all models in this package are prefixed with ``Authz`` (e.g. +:class:`AuthzSchemaSource`, :class:`AuthzRoleDefinition`) so the project's +tables are easy to identify at a glance in the shared database and admin, and +to avoid name collisions with models from other installed apps. """ from __future__ import annotations