Skip to content

feat: convert DomainOrg entries to RBAC role assignments via data migration - #1413

Open
CryptoRodeo wants to merge 1 commit into
pulp:mainfrom
CryptoRodeo:feat/pulp-1893
Open

feat: convert DomainOrg entries to RBAC role assignments via data migration#1413
CryptoRodeo wants to merge 1 commit into
pulp:mainfrom
CryptoRodeo:feat/pulp-1893

Conversation

@CryptoRodeo

@CryptoRodeo CryptoRodeo commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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-<org_id>, 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.

Summary by Sourcery

Convert legacy DomainOrg access controls to equivalent RBAC role assignments during database migration.

New Features:

  • Add a data migration that converts existing DomainOrg access records into equivalent pulpcore RBAC assignments for users, groups, and organization-derived groups.
  • Create configured read-only RBAC assignments for policy-defined domains and groups.

Enhancements:

  • Make the conversion idempotent while preserving DomainOrg data for rollback and leaving the reverse migration as a no-op.

Deployment:

  • Run the RBAC data conversion as part of the database migration process, including creation and synchronization of required service roles.

@sourcery-ai

sourcery-ai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds a data migration that backfills pulpcore RBAC user/group role assignments from existing DomainOrg entries and configured domain access policies, ensuring object-level domain owner/viewer and domain-scoped service admin/viewer roles are created and aligned with previous access semantics.

Entity relationship diagram for DomainOrg to RBAC role assignments

erDiagram
    DomainOrg {
        int org_id
    }
    User {
        int id
    }
    Group {
        int id
        string name
    }
    Domain {
        int id
        string name
    }
    Role {
        int id
        string name
    }
    UserRole {
        int id
    }
    GroupRole {
        int id
    }

    DomainOrg }o--|| User : user
    DomainOrg }o--|| Group : group
    DomainOrg ||--o{ Domain : domains

    User ||--o{ UserRole : has_user_roles
    Group ||--o{ GroupRole : has_group_roles

    Role ||--o{ UserRole : user_role_type
    Role ||--o{ GroupRole : group_role_type

    Domain ||--o{ UserRole : object_or_scoped
    Domain ||--o{ GroupRole : object_or_scoped

    Group ||--o{ DomainOrg : org_id_group
    Group ||--o{ GroupRole : readonly_group_role
Loading

Flow diagram for DomainOrg to RBAC role migration

flowchart TD
    start[convert_domainorgs_to_roles] --> ensure[_ensure_service_roles]
    ensure --> svc_admin[Role service.domain_admin]
    ensure --> svc_viewer[Role service.domain_viewer]

    start --> core_roles[Role core.domain_owner / core.domain_viewer]
    core_roles --> domain_ct[ContentType core.domain]

    start --> qset[Query DomainOrg with domains, user, group]
    qset --> loop_domainorg[for each DomainOrg]

    loop_domainorg --> has_domains{has domains?}
    has_domains -->|no| next_domainorg[skip entry]
    has_domains -->|yes| user_group_check{user/group present?}

    user_group_check -->|user or group| assign_existing[assign roles for existing user/group]
    user_group_check -->|org_id only| create_org_group[Group get_or_create rh-org-<org_id>]

    assign_existing --> loop_domains[for each domain]
    create_org_group --> loop_domains

    loop_domains --> user_role[_assign_pair to UserRole]
    loop_domains --> group_role[_assign_pair to GroupRole]

    start --> policies[iterate DOMAIN_ACCESS_POLICIES]
    policies --> readonly_check{readonly_group set?}
    readonly_check -->|no| next_policy[skip policy]
    readonly_check -->|yes| find_domain[Domain filter by name]

    find_domain --> domain_exists{domain found?}
    domain_exists -->|no| next_policy
    domain_exists -->|yes| ro_group[Group get_or_create readonly_group]

    ro_group --> ro_assign[_assign_pair to GroupRole with core.domain_viewer / service.domain_viewer]

    ro_assign --> migration_end[migration completes]
Loading

File-Level Changes

Change Details Files
Inline creation of service-level domain admin/viewer roles to be available during the data migration.
  • Implements _ensure_service_roles to mirror the post_migrate service role population logic.
  • Discovers plugin app labels via PulpPluginAppConfig and collects all plugin permissions.
  • Creates or retrieves service.domain_admin and service.domain_viewer roles and assigns appropriate permission sets (all vs view-only).
pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py
Conversion of DomainOrg entries into equivalent RBAC UserRole/GroupRole assignments for each associated domain.
  • Defines _assign_pair helper to create object-level and domain-scoped role assignments idempotently via get_or_create.
  • Fetches DomainOrg, Role, UserRole, GroupRole, Group, Domain, and ContentType models through the migration apps registry.
  • Ensures core.domain_owner and core.domain_viewer roles exist via get_or_create, and obtains/creates the Domain ContentType.
  • Iterates DomainOrg rows in a streaming fashion with select_related/prefetch_related to avoid N+1 and large memory usage.
  • Creates rh-org-<org_id> groups for org-id-only DomainOrg entries and applies role pairs per domain for associated users and groups.
pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py
Application of read-only domain access policies to RBAC via viewer role assignments for configured groups.
  • Reads DOMAIN_ACCESS_POLICIES from settings and processes per-domain readonly_group mappings.
  • Looks up domains by name and conditionally creates or retrieves the corresponding readonly group.
  • Assigns core.domain_viewer and service.domain_viewer role pairs to readonly groups using _assign_pair.
pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py
Registration of the data migration and documentation of the feature in the changelog.
  • Adds RunPython operation convert_domainorgs_to_roles with a no-op reverse function to the 0019_convert_domainorg_to_roles migration.
  • Documents the feature and behavior in CHANGES/1893.feature, including role mappings and rh-org-<org_id> group creation.
pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py
CHANGES/1893.feature

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • The DomainOrg.objects.prefetch_related("domains").all() loop may load a large number of rows into memory during migration; consider using .iterator() to reduce memory usage for large deployments.
  • The migration logic that derives readonly groups from settings.DOMAIN_ACCESS_POLICIES bakes the current configuration into the database; if policies are expected to change over time, consider whether this behavior should instead be driven by runtime configuration rather than a one-time migration.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `DomainOrg.objects.prefetch_related("domains").all()` loop may load a large number of rows into memory during migration; consider using `.iterator()` to reduce memory usage for large deployments.
- The migration logic that derives readonly groups from `settings.DOMAIN_ACCESS_POLICIES` bakes the current configuration into the database; if policies are expected to change over time, consider whether this behavior should instead be driven by runtime configuration rather than a one-time migration.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

@sourcery-ai Thanks for the review. Addressed in the amended commit (8d77033):

  1. .iterator() for memory - Done. The DomainOrg loop now uses
    .prefetch_related("domains").iterator(chunk_size=500) so rows stream instead of
    loading all at once. chunk_size is passed explicitly since Django 4.1+ only observes
    prefetch_related on .iterator() when a chunk size is provided (we run Django 4.2 via
    pulpcore 3.116).

  2. Deriving readonly groups from DOMAIN_ACCESS_POLICIES - Left as-is by design.
    This is a one-time backfill migration: it only replicates the access state that the
    existing DomainOrg + DOMAIN_ACCESS_POLICIES checks already grant, so the new
    ObjectRolePermission backend grants equivalent access after cutover. It does not freeze
    the configuration - runtime behavior is still driven by DOMAIN_ACCESS_POLICIES through
    DomainRBACAuthorization._check_domain_policy (authorization.py), so future policy changes
    continue to take effect at runtime. The migration reverse is a no-op and DomainOrg rows are
    preserved for rollback.

@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

@sourcery-ai review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Consider adding select_related("user", "group") to the DomainOrg queryset to avoid extra queries for those FK lookups while iterating over many rows.
  • The migration currently imports django.apps and PulpPluginAppConfig and inspects live app configs; if plugin labels or app config types change in future this could break the migration, so it may be safer to derive the relevant app labels from a more stable source (e.g. settings or a fixed list used elsewhere).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider adding `select_related("user", "group")` to the `DomainOrg` queryset to avoid extra queries for those FK lookups while iterating over many rows.
- The migration currently imports `django.apps` and `PulpPluginAppConfig` and inspects live app configs; if plugin labels or app config types change in future this could break the migration, so it may be safer to derive the relevant app labels from a more stable source (e.g. settings or a fixed list used elsewhere).

## Individual Comments

### Comment 1
<location path="pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py" line_range="80-82" />
<code_context>
+    # 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.
+    for domain_org in DomainOrg.objects.prefetch_related("domains").iterator(chunk_size=500):
+        domains = list(domain_org.domains.all())
+        if not domains:
</code_context>
<issue_to_address>
**issue (performance):** `iterator()` causes `prefetch_related()` to be ignored, leading to N+1 queries.

Because `QuerySet.iterator()` disables `prefetch_related()`, this loop will issue a separate query for each `domain_org.domains.all()` call instead of using the prefetched data, creating an N+1 pattern on large datasets. If you need streaming, consider removing `iterator()` so prefetch works, or implement custom batching that preserves `prefetch_related()`.
</issue_to_address>

### Comment 2
<location path="pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py" line_range="103-111" />
<code_context>
+
+    # 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).
+    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 = Domain.objects.filter(name=domain_name).first()
+        if domain is None:
+            continue
+        group, _ = Group.objects.get_or_create(name=readonly_group_name)
+        _assign_pair(GroupRole, {"group": group}, domain_viewer_role, viewer_role, domain, domain_ct)
+
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Relying on `settings.DOMAIN_ACCESS_POLICIES` inside a migration can make it non-deterministic over time.

Because migrations must be reproducible, tying behavior to `settings.DOMAIN_ACCESS_POLICIES` (which can differ by environment or change over time) risks inconsistent DB state across deployments. If these policies need to influence data at migration time, either embed a fixed snapshot of the relevant data into the migration, or move this logic into a post-migrate/bootstrap step where runtime configuration is expected.

Suggested implementation:

```python
        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)

    # NOTE: Logic that depends on runtime configuration such as settings.DOMAIN_ACCESS_POLICIES
    # has been intentionally excluded from this migration to keep it deterministic. Any behavior
    # that uses DOMAIN_ACCESS_POLICIES to create readonly groups or assign roles should be
    # implemented in a post-migrate/bootstrap step instead of here.


def _ensure_service_roles(apps):

```

To fully implement the suggestion, you should:
1. Add a post-migrate or bootstrap hook (e.g., in `pulp_service/pulp_service/app/apps.py` or a dedicated `bootstrap.py`) that:
   - Reads `settings.DOMAIN_ACCESS_POLICIES`.
   - For each domain/policy, creates the readonly group and assigns the appropriate roles using the same `_assign_pair` semantics, but via the runtime ORM (not migration `apps`).
2. Ensure that hook is idempotent (safe to run multiple times) and documented so operators know that `DOMAIN_ACCESS_POLICIES` affect runtime bootstrap, not historical migrations.
3. If you need historical behavior to be frozen, capture a fixed snapshot of policies and implement that as a separate data migration that does **not** read `settings`, but uses hard-coded values.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py Outdated
@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Addressed in a4b5e58 (amended):

N+1 on FK lookups - added select_related("user", "group") to the DomainOrg queryset. It composes with iterator(chunk_size=500), and note that on Django 5.2 (what pulpcore 3.116 pins) iterator(chunk_size=...) already honors prefetch_related("domains"), so the only missing piece was the user/group FKs.

PulpPluginAppConfig / live app-config inspection - kept intentionally. _ensure_service_roles deliberately mirrors the runtime _populate_service_roles post_migrate handler (app/__init__.py), which uses the identical get_app_configs() + PulpPluginAppConfig pattern to derive plugin labels. Keeping the same logic keeps the migration consistent with the source of truth it converts from; a divergent static list would be more likely to drift.

settings.DOMAIN_ACCESS_POLICIES - kept intentionally; see the inline reply. This is a one-time conversion of current runtime state, is idempotent (get_or_create, guarded on the domain existing), and mirrors how the runtime handlers already consume live config.

@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

@sourcery-ai review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • Consider avoiding direct use of settings.DOMAIN_ACCESS_POLICIES inside the migration, or at least documenting that the migration’s behavior depends on the runtime configuration at migration time and will not automatically reflect future policy changes.
  • In _ensure_service_roles, you reach into django.apps and PulpPluginAppConfig instead of using only the historical models provided by apps; if plugin registration or app configs change, this migration may behave differently than expected, so it may be worth constraining or snapshotting the plugin set used here.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider avoiding direct use of `settings.DOMAIN_ACCESS_POLICIES` inside the migration, or at least documenting that the migration’s behavior depends on the runtime configuration at migration time and will not automatically reflect future policy changes.
- In `_ensure_service_roles`, you reach into `django.apps` and `PulpPluginAppConfig` instead of using only the historical models provided by `apps`; if plugin registration or app configs change, this migration may behave differently than expected, so it may be worth constraining or snapshotting the plugin set used here.

## Individual Comments

### Comment 1
<location path="pulp_service/pulp_service/app/migrations/0019_convert_domainorg_to_roles.py" line_range="105-109" />
<code_context>
+
+    # 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).
+    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 = Domain.objects.filter(name=domain_name).first()
+        if domain is None:
+            continue
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Silently taking the first domain by name can hide configuration issues when multiple domains share a name or a name is misconfigured.

Using `Domain.objects.filter(name=domain_name).first()` means roles may be attached to an arbitrary domain when multiple share the same name, and misconfigured `DOMAIN_ACCESS_POLICIES` entries (typos/non-existent names) are silently ignored. Consider using `get()` to fail fast on bad config, or at least logging when multiple domains match or none are found, so these issues are visible instead of silently skipped.

Suggested implementation:

```python
    # 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).
    for domain_name, policy in getattr(settings, "DOMAIN_ACCESS_POLICIES", {}).items():
        readonly_group_name = policy.get("readonly_group")
        if not readonly_group_name:
            continue
        try:
            domain = Domain.objects.get(name=domain_name)
        except Domain.DoesNotExist:
            raise RuntimeError(
                f"DOMAIN_ACCESS_POLICIES readonly_group entry refers to non-existent domain "
                f'"{domain_name}". Please fix your DOMAIN_ACCESS_POLICIES configuration.'
            )
        except Domain.MultipleObjectsReturned:
            raise RuntimeError(
                f"DOMAIN_ACCESS_POLICIES readonly_group entry refers to domain name "
                f'"{domain_name}" which matches multiple Domain rows. Domain names must be '
                f"unique or DOMAIN_ACCESS_POLICIES must be updated to disambiguate."
            )


        user = domain_org.user

```

If this migration later uses `readonly_group_name` and `domain` to create roles or groups, ensure that logic appears after this loop and uses the now-guaranteed single `domain` instance. No further structural changes are required; this edit will cause the migration to fail fast with clear errors when `DOMAIN_ACCESS_POLICIES` is misconfigured or when multiple domains share the same name.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

…ration

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-<org_id>, 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 <bramos@redhat.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

Re: _ensure_service_roles reaching into django.apps / PulpPluginAppConfig instead of the historical apps (addressed in 95a35b5, documented):

This is intentional and required. The role permission set must match exactly what the runtime post_migrate handler _populate_service_roles grants, which is derived from the live installed plugins ({ac.label for ac in django_apps.get_app_configs() if isinstance(ac, PulpPluginAppConfig)}). The historical migration-state apps exposes no PulpPluginAppConfig instances, so it cannot supply that set; snapshotting a fixed plugin list would drift from the runtime handler and defeat the purpose of mirroring it. Added a comment explaining this so the reliance on the live registry is explicit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant