From 95a35b5c6b7ca18e3075715389cbbdfef5b56a37 Mon Sep 17 00:00:00 2001 From: Bryan ramos Date: Thu, 20 Aug 2026 12:13:11 -0400 Subject: [PATCH] feat: convert DomainOrg entries to RBAC role assignments via data migration Add data migration 0019_convert_domainorg_to_roles that backfills pulpcore RBAC role assignments from existing DomainOrg rows, so the ObjectRolePermission backend grants the same access the DomainOrg checks used to. Runs in the migrate-db pod at deploy time. For each DomainOrg, per domain, it creates the two-role pair: - object-level core.domain_owner ON the Domain (view/manage the domain, list it) - domain-scoped service.domain_admin (manage repos/remotes/distributions within) Rules honored: - user-only -> UserRole pair; group-only -> GroupRole pair - org_id-only -> create group rh-org-, then GroupRole pair - both user and group -> both the UserRole pair and the GroupRole pair - Lightwell-ReadOnly -> object-level core.domain_viewer + domain-scoped service.domain_viewer on the lightwell domain (driven by DOMAIN_ACCESS_POLICIES) The service.domain_admin/service.domain_viewer roles are created inline via Role.objects.get_or_create() + permissions.set(), because post_migrate (which normally creates them) only fires after all migrations complete. The post_migrate handler remains the maintenance path for keeping those roles in sync as plugins change. Idempotent (get_or_create on the full unique_together tuple), reverse is a no-op, and DomainOrg data is preserved for rollback. Signed-off-by: Bryan ramos Co-Authored-By: Claude Opus 4.8 --- CHANGES/1893.feature | 4 + .../0019_convert_domainorg_to_roles.py | 140 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 CHANGES/1893.feature create mode 100644 pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py diff --git a/CHANGES/1893.feature b/CHANGES/1893.feature new file mode 100644 index 00000000..c037af53 --- /dev/null +++ b/CHANGES/1893.feature @@ -0,0 +1,4 @@ +Added a data migration that converts existing DomainOrg entries into pulpcore RBAC role +assignments (object-level core.domain_owner/core.domain_viewer on the domain plus +domain-scoped service.domain_admin/service.domain_viewer), creating rh-org- groups +for org-id-only entries. diff --git a/pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py b/pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py new file mode 100644 index 00000000..05613b5b --- /dev/null +++ b/pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py @@ -0,0 +1,140 @@ +import logging + +from django.conf import settings +from django.db import migrations + +_logger = logging.getLogger(__name__) + + +def _ensure_service_roles(apps): + """Create service.domain_admin / service.domain_viewer inline. + + Mirrors pulp_service.app._populate_service_roles. We cannot rely on that + post_migrate handler here: post_migrate runs only after ALL migrations are + complete, so roles do not exist while this data migration runs. + """ + Role = apps.get_model("core", "Role") + Permission = apps.get_model("auth", "Permission") + + # Intentionally read the LIVE app registry, not the historical `apps` passed in: the + # permission set granted to these roles must match what the runtime post_migrate handler + # (_populate_service_roles) grants, which is derived from the installed plugins. Historical + # migration state exposes no PulpPluginAppConfig instances, so it cannot supply that set. + from django.apps import apps as django_apps + from pulpcore.plugin import PulpPluginAppConfig + + plugin_labels = {ac.label for ac in django_apps.get_app_configs() if isinstance(ac, PulpPluginAppConfig)} + all_permissions = Permission.objects.filter(content_type__app_label__in=plugin_labels) + + admin_role, _ = Role.objects.get_or_create( + name="service.domain_admin", + defaults={"locked": False, "description": "Admin role for all domain-level plugin permissions."}, + ) + admin_role.permissions.set(all_permissions) + + viewer_role, _ = Role.objects.get_or_create( + name="service.domain_viewer", + defaults={"locked": False, "description": "Viewer role for all domain-level view permissions."}, + ) + viewer_role.permissions.set(all_permissions.filter(codename__startswith="view")) + + return admin_role, viewer_role + + +def _assign_pair(role_model, entity_kwargs, object_role, scoped_role, domain, domain_ct): + """Create the object-level + domain-scoped role rows for one entity on one domain. + + Idempotent: get_or_create keys on the full unique_together tuple + (entity, role, content_type, object_id, domain). + """ + # Object-level: role asserted ON the domain object itself (view/manage/list domains) + role_model.objects.get_or_create( + role=object_role, + content_type=domain_ct, + object_id=str(domain.pk), + domain=None, + **entity_kwargs, + ) + # Domain-scoped: role asserted on every object that lives INSIDE the domain + role_model.objects.get_or_create( + role=scoped_role, + content_type=None, + object_id=None, + domain=domain, + **entity_kwargs, + ) + + +def convert_domainorgs_to_roles(apps, schema_editor): + DomainOrg = apps.get_model("service", "DomainOrg") + Role = apps.get_model("core", "Role") + UserRole = apps.get_model("core", "UserRole") + GroupRole = apps.get_model("core", "GroupRole") + Group = apps.get_model("core", "Group") + Domain = apps.get_model("core", "Domain") + ContentType = apps.get_model("contenttypes", "ContentType") + + admin_role, viewer_role = _ensure_service_roles(apps) + + # pulpcore locked roles: exist on upgraded DBs. get_or_create keeps the FK + # safe on a fresh/edge DB. pulpcore's post_migrate populates their permissions after migrate-db. + domain_owner_role, _ = Role.objects.get_or_create(name="core.domain_owner") + domain_viewer_role, _ = Role.objects.get_or_create(name="core.domain_viewer") + + # get_or_create (not get): ContentType rows are created in post_migrate and + # may be absent on a fresh DB. Creating it early is harmless, a no-op for rows that already exist. + domain_ct, _ = ContentType.objects.get_or_create(app_label="core", model="domain") + + # Convert every DomainOrg entry. iterator(chunk_size=...) streams rows instead of + # loading them all at once; chunk_size is required for prefetch_related to be observed + # (Django 4.1+). select_related pulls the user/group FKs in the same query to avoid N+1. + queryset = DomainOrg.objects.select_related("user", "group").prefetch_related("domains") + for domain_org in queryset.iterator(chunk_size=500): + domains = list(domain_org.domains.all()) + if not domains: + continue + + user = domain_org.user + group = domain_org.group + # org_id-only entry (no user or group). Create an rh-org- group. + if user is None and group is None and domain_org.org_id: + group, _ = Group.objects.get_or_create(name=f"rh-org-{domain_org.org_id}") + + for domain in domains: + # user-set + if user is not None: + _assign_pair(UserRole, {"user": user}, domain_owner_role, admin_role, domain, domain_ct) + # group-set + if group is not None: + _assign_pair(GroupRole, {"group": group}, domain_owner_role, admin_role, domain, domain_ct) + + # readonly groups (Lightwell-ReadOnly) for DOMAIN_ACCESS_POLICIES. Runs once total, + # independent of DomainOrg rows (must still fire on a DB with no DomainOrg entries). + # This is a one-time conversion of the CURRENT runtime state: it reflects the policy + # config present at migration time and does not track later changes to DOMAIN_ACCESS_POLICIES. + for domain_name, policy in getattr(settings, "DOMAIN_ACCESS_POLICIES", {}).items(): + readonly_group_name = policy.get("readonly_group") + if not readonly_group_name: + continue + # Domain.name is unique, so this matches at most one row. A configured policy may + # reference a domain that does not exist in this environment (e.g. lightwell not yet + # provisioned); skip it but log so a typo/misconfig is visible rather than silent. + domain = Domain.objects.filter(name=domain_name).first() + if domain is None: + _logger.warning( + "DOMAIN_ACCESS_POLICIES references domain %r which does not exist; " + "skipping readonly role assignment for group %r.", + domain_name, + readonly_group_name, + ) + continue + group, _ = Group.objects.get_or_create(name=readonly_group_name) + _assign_pair(GroupRole, {"group": group}, domain_viewer_role, viewer_role, domain, domain_ct) + + +class Migration(migrations.Migration): + dependencies = [ + ("service", "0018_add_rbac_permissions"), + ] + + operations = [migrations.RunPython(convert_domainorgs_to_roles, migrations.RunPython.noop)]