Skip to content

feat: remove the legacy DomainOrg authorization system (PULP-2124) - #1555

Merged
dkliban merged 1 commit into
pulp:mainfrom
CryptoRodeo:feat/pulp-2124
Oct 28, 2026
Merged

dkliban merged 1 commit into
pulp:mainfrom
CryptoRodeo:feat/pulp-2124

Conversation

@CryptoRodeo

@CryptoRodeo CryptoRodeo commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

RBAC (PulpServiceAccessPolicy) has been the default authorization in production for a while, with DomainOrg kept alongside it only as a dual-write safety net during the switchover. With that proven out, this removes the old system for good: the DomainOrg model and table, the now-dead DomainBasedPermission class (its last consumer, the registry re-root patch, was dropped in #1535), the domain-create dual-write, and the DomainOrg backfill/report tooling and debug endpoints.

The one place DomainOrg was still read on the request path, the domain-owner bypass for content-guarded PyPI reads, now checks the equivalent RBAC grant (core.view_content on the domain) that domain creation already assigns, so the behavior is preserved. A migration drops the table, and two historical data migrations that imported the model or its backfill helpers are made self-contained so the migration graph still loads after the model is gone.

Note: org_id handling in the create-domain flow stays. It feeds the rh-org-<org_id> role grant, which is RBAC, not DomainOrg.

Summary by Sourcery

Remove the legacy DomainOrg authorization system and complete the transition to RBAC-based domain access.

Bug Fixes:

  • Preserve domain-owner access to content-guarded PyPI reads by using the equivalent RBAC domain permission.

Enhancements:

  • Remove the legacy DomainOrg authorization model, permission class, dual-write behavior, and associated backfill/report tooling and debug endpoints.
  • Rely exclusively on RBAC roles for domain-scoped authorization while retaining organization-based role grants during domain creation.
  • Keep historical migrations self-contained after removing the DomainOrg model and update documentation and tests to reflect RBAC-only authorization.

Documentation:

  • Update architecture, development guidance, and PyPI monitor documentation to describe RBAC-based domain access.

Tests:

  • Remove legacy DomainOrg authorization and reporting tests and update coverage for RBAC role-based access.

@sourcery-ai

sourcery-ai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Completes the migration to RBAC by deleting the DomainOrg model, authorization class, dual-write and reporting tooling, while preserving domain-owner PyPI content-guard bypasses through the equivalent core.view_content grant and keeping historical migrations self-contained.

Sequence diagram for RBAC domain creation and PyPI access

sequenceDiagram
    participant Client
    participant AccessPolicy
    participant DomainCreate
    participant Signal
    participant RBAC
    participant PyPI
    participant ContentGuard

    Client->>AccessPolicy: has_permission(request, view)
    AccessPolicy->>AccessPolicy: set_domain_create_context(request)
    Client->>DomainCreate: create domain
    DomainCreate->>Signal: post_create_domain(sender, ...)
    Signal->>RBAC: _assign_domain_roles(user_or_group, domain)
    Signal->>RBAC: _assign_domain_roles(org_group, domain)

    Client->>PyPI: SAFE method request
    PyPI->>AccessPolicy: has_permission(request, view)
    AccessPolicy->>RBAC: user.has_perm(core.view_content, domain)
    alt RBAC grant exists
        RBAC-->>AccessPolicy: permission granted
        AccessPolicy-->>PyPI: allow content-guard bypass
    else no RBAC grant
        AccessPolicy->>ContentGuard: permit(request)
        ContentGuard-->>AccessPolicy: allow or deny
    end
Loading

File-Level Changes

Change Details Files
Remove the legacy DomainOrg authorization data model and all runtime dependencies.
  • Delete the DomainOrg model and table via migration.
  • Remove DomainBasedPermission and its DomainOrg-based access checks.
  • Stop creating DomainOrg records during domain creation while retaining RBAC role assignment.
  • Delete backfill/report implementations, management commands, debug endpoints, and obsolete tests.
pulp_service/pulp_service/app/models.py
pulp_service/pulp_service/app/authorization.py
pulp_service/pulp_service/app/signals.py
pulp_service/pulp_service/app/migrations/0024_delete_domainorg.py
pulp_service/pulp_service/app/domainorg_backfill.py
pulp_service/pulp_service/app/domainorg_rbac_report.py
pulp_service/pulp_service/app/management/commands/domainorg_backfill_report.py
pulp_service/pulp_service/app/management/commands/domainorg_rbac_report.py
pulp_service/pulp_service/app/tasks/domainorg_backfill_report.py
pulp_service/pulp_service/app/tasks/domainorg_rbac_report.py
pulp_service/pulp_service/app/urls.py
pulp_service/pulp_service/app/viewsets.py
pulp_service/pulp_service/tests/functional/test_domain_based_permissions.py
pulp_service/pulp_service/tests/functional/test_domainorg_backfill_report.py
pulp_service/pulp_service/tests/functional/test_domainorg_rbac_report.py
pulp_service/pulp_service/tests/unit/test_domainorg_backfill.py
pulp_service/pulp_service/tests/unit/test_domainorg_rbac_report.py
Preserve domain-owner behavior for content-guarded PyPI reads through RBAC.
  • Replace DomainOrg membership queries with an object-level core.view_content permission check.
  • Keep content-guard evaluation for principals without the domain role.
  • Update functional coverage for domain-member bypass, denied access, and entitled-org behavior.
pulp_service/pulp_service/app/access_policy.py
pulp_service/pulp_service/tests/functional/test_access_policy.py
pulp_service/pulp_service/tests/functional/test_content_guard_permission.py
pulp_service/pulp_service/tests/functional/test_lightwell_content_listing_permission.py
Keep historical migrations loadable after removing the live model and helper modules.
  • Remove the runtime model import from the domain-copy migration.
  • Inline org ID normalization and derivation helpers into the RBAC backfill migration.
  • Continue using prior migration role-assignment primitives for compatible backfill results.
pulp_service/pulp_service/app/migrations/0009_copy_domain_to_domains.py
pulp_service/pulp_service/app/migrations/0022_backfill_org_group_roles.py
Update documentation and tests to describe RBAC-only domain access.
  • Replace DomainOrg references with RBAC role terminology.
  • Revise fixtures and assertions for role assignment rather than dual-write behavior.
  • Remove obsolete create-flow and historical DomainOrg tests.
docs/ARCHITECTURE.md
docs/pypi-yank-monitor.md
pulp_service/pulp_service/tests/functional/conftest.py
pulp_service/pulp_service/tests/functional/constants.py
pulp_service/pulp_service/tests/functional/test_domain_dual_write.py
pulp_service/pulp_service/tests/functional/test_org_member_rbac_access.py
pulp_service/pulp_service/tests/unit/test_domain_create_detection.py
pulp_service/pulp_service/tests/unit/test_feature_content_guard.py

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 found 4 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="pulp_service/pulp_service/app/access_policy.py" line_range="206" />
<code_context>
+        # service.domain_admin and service.domain_viewer, covering every principal with a role on the
+        # domain.
+        check_domain = domain or Domain.objects.filter(pk=get_domain_pk()).first()
+        if user.is_authenticated and check_domain is not None and user.has_perm("core.view_content", obj=check_domain):
             _logger.info(
-                "Content-guarded PyPI access GRANTED via DomainOrg: user=%s org_id=%s",
</code_context>
<issue_to_address>
**Read-only members bypass PyPI feature guards**

When an unentitled user belongs to a group granted `core.view_content` on a domain that has a guarded PyPI distribution, the `core.view_content` check accepts a principal with that permission on the domain before `_evaluate_content_guard` runs, so an unentitled member of the lightwell read-only group can fetch guarded PyPI content. The existing read-only-group test expects those requests to remain denied.

Restrict the bypass to the domain-owner principals that previously had a DomainOrg association, rather than every principal with `core.view_content`.
</issue_to_address>

### Comment 2
<location path="pulp_service/pulp_service/app/migrations/0024_delete_domainorg.py" line_range="10" />
<code_context>
+    ]
+
+    operations = [
+        migrations.DeleteModel(
+            name="DomainOrg",
+        ),
</code_context>
<issue_to_address>
**Old workers fail after table removal**

When the migration runs while old-version pods or workers are still serving requests, `PulpServiceAccessPolicy`’s DomainOrg-based PyPI access checks query the dropped table, and the old `post_create_domain` worker inserts into it. Those requests raise database errors, so guarded reads fail and domain creation fails or rolls back.

Defer dropping the table until all old-version pods and workers have drained.

Also at `pulp_service/pulp_service/app/migrations/0024_delete_domainorg.py:11`.
</issue_to_address>

### Comment 3
<location path="docs/pypi-yank-monitor.md" line_range="170" />
<code_context>
 | `pypi_yank_monitor/{id}/check/` | `POST` | Trigger an on-demand yank check (returns 202 with task) |
 | `pypi_yank_monitor/{id}/report/` | `GET` | Retrieve the latest yank report (returns 404 if no report exists) |

-All endpoints require **DomainOrg membership** for the target domain.
+All endpoints require **an RBAC role on the target domain**.
</code_context>
<issue_to_address>
**Monitor access docs overstate role requirement**

When an authenticated caller without a role on the target domain lists monitors, the monitor list policy allows any authenticated principal, so a caller without a role on the target domain can still call `GET pypi_yank_monitor/` and receive a response; the guide incorrectly says every endpoint requires that role.

Clarify that listing is available to authenticated users and document the role checks and scope of each other action.
</issue_to_address>

### Comment 4
<location path="pulp_service/pulp_service/app/models.py" line_range="22" />
<code_context>
 _logger = logging.getLogger(__name__)


-class DomainOrg(models.Model):
-    """
-    One-to-many relationship between org ids and Domains.
</code_context>
<issue_to_address>
**Contributor guidance names removed authorization**

When contributors use the repository’s architecture or agent guidance after this removal, cLAUDE.md and the Pulp developer agent guide still describe DomainOrg and DomainBasedPermission as current, so contributors following them work from an authorization design that no longer exists.

Update the contributor guides to describe the RBAC-based authorization flow and remove references to the deleted model and permission class.

Also at `pulp_service/pulp_service/app/authorization.py:70`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and the 0024 migration permanently deletes the DomainOrg authorization mappings, so reverting the code would not restore the table or its data. Authorization and content-guard bypasses also switch to RBAC grants; if those grants are incomplete or overly broad, access could be denied or exposed across domains with no automatic signal.

Blocking findings: pulp_service/pulp_service/app/access_policy.py:206, pulp_service/pulp_service/app/migrations/0024_delete_domainorg.py:10


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread docs/pypi-yank-monitor.md Resolved
Comment thread pulp_service/pulp_service/app/models.py Resolved
@CryptoRodeo CryptoRodeo changed the title feat!: remove the legacy DomainOrg authorization system (PULP-2124) feat: remove the legacy DomainOrg authorization system (PULP-2124) Oct 9, 2026
@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All four findings addressed in the amended commit:

  1. Content-guard bypass (read-only members): Bypass now checks core.change_domain instead of core.view_content. The view-only roles (core.domain_viewer/service.domain_viewer) never hold change_domain, so read-only lightwell members no longer bypass guarded PyPI content, while domain owners/admins still do. This matches the old DomainOrg owner-only behavior (same perm already used in MigrateDomainView). The test_readonly_group_member_pypi_still_requires_feature regression test now passes (403).

  2. Migration rolling-deploy safety: 0024 now uses SeparateDatabaseAndState to remove DomainOrg from Django's migration state while keeping the physical table, so old pods/workers in the rolling window don't hit a dropped relation. The actual DROP TABLE will follow in a separate PR once the code removal has fully drained.

  3. Monitor docs: Clarified that listing (GET pypi_yank_monitor/) is open to any authenticated user; the other endpoints require an RBAC role.

  4. Contributor guides: Updated CLAUDE.md and the pulp-developer agent guide to describe the RBAC flow instead of DomainOrg/DomainBasedPermission.

@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

@dkliban Hey, this should be good for review now

@CryptoRodeo
CryptoRodeo force-pushed the feat/pulp-2124 branch 2 times, most recently from de7bfc2 to 73ce546 Compare October 27, 2026 18:57
@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

Rebased onto main to get latest changes

@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

deploy-application keeps failing 👀

@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

Rebased onto main. Lets see if CI passes now.

@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

looks like rebasing helped

@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

nope, same issue....

# held by core.domain_owner / service.domain_admin but not by the view-only roles
# (core.domain_viewer / service.domain_viewer), so read-only members do not bypass the guard.
check_domain = domain or Domain.objects.filter(pk=get_domain_pk()).first()
if user.is_authenticated and check_domain is not None and user.has_perm("core.change_domain", obj=check_domain):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The permission checked here (core.change_domain) is inconsistent with the stated intent. The PR description and the test docstrings (e.g. test_domain_member_bypasses_content_guard) both say this bypass checks core.view_content, but the code checks core.change_domain. These are not equivalent — they grant the bypass to different principals.

Looking at the role model:

Role Assigned at create change_domain view_content
core.domain_owner (object-level) yes ✅ ❌
service.domain_admin (domain-scoped, = all plugin perms incl. core.*) yes ✅ ✅
service.domain_viewer (domain-scoped, = view_* perms) no ❌ ✅

So the comment's factual claim is correct — change_domain is held by owner/admin but not the viewer role. But that means the only case where change_domain and view_content disagree is a read-only domain member (service.domain_viewer): change_domain denies them the bypass, view_content allows them. Every principal created by _assign_domain_roles holds both owner + admin, so the happy-path test passes under either permission and doesn't pin the semantics down.

This needs an explicit decision:

  • Owners/writers only bypass the guard → keep change_domain, and fix the PR description + test docstrings that say view_content.
  • Any content reader (incl. read-only members) bypasses → switch to view_content. Note view_content is granted via domain-scoped roles, not object-level on the Domain, so has_perm("core.view_content", obj=domain) won't resolve the way the current object-level change_domain check does — you'd want the domain-scoped form (has_domain_perms:core.view_content, as used elsewhere in settings.py).

Either way, worth adding a service.domain_viewer test case (asserted denied or allowed per the decision) so the intended behavior is locked in rather than incidental.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch on the code/docs mismatch. Decision: kept core.change_domain, fixed the docs.

Rationale: the bypass this replaced (DomainOrg._has_domain_access) granted to the creator, team group, and org members, who under RBAC all hold core.domain_owner + service.domain_admin (both have change_domain). Read-only members (service.domain_viewer) are an RBAC-only concept that never existed under DomainOrg, so excluding them from the bypass is what preserves the old behavior. view_content would have expanded the bypass to read-only members.

Changes pushed:

  • Kept change_domain in the bypass check (no code change).
  • Fixed the stale view_content wording in the commit message, CHANGES/2124.removal, and the test_domain_member_bypasses_content_guard docstring to say change_domain.
  • Added test_domain_viewer_does_not_bypass_content_guard: a service.domain_viewer member (has view_content, lacks change_domain, no feature) gets 403 on the guarded PyPI simple API, pinning the semantics.

Left the content list/retrieve view_content references (scope_queryset / settings access policies) untouched; those are the separate gating mechanism and are correct.

@dkliban

dkliban commented Oct 28, 2026

Copy link
Copy Markdown
Member

Minor test-coverage gap — AuthenticationError content-guard branch (access_policy.py::_evaluate_content_guard, the except AuthenticationError branch ~L232)

This is the one content-guard error branch left without a dedicated unit test. test_access_policy.py ports unit tests for the sibling branches — test_content_guard_permit_permission_error_denies (PermissionError) and test_content_guard_permit_unexpected_error_denies_and_logs (generic Exception) — but there's no equivalent for AuthenticationError.

The deleted tests/unit/test_authentication_error_content_guard.py used to guard exactly this: it asserted an AuthenticationError from permit() is treated as a denial and is not logged as "unexpected error evaluating content guard". Deleting it was correct (it targeted the removed DomainBasedPermission), but the behavior wasn't re-added for PulpServiceAccessPolicy.

The functional test_unauthenticated_denied_on_guarded_pypi_simple_api only asserts the outcome (401), so it wouldn't catch a regression where this branch is removed and AuthenticationError falls through to except Exception — same denial, but now logged at exception level as "Unexpected error evaluating content guard permit" (log noise / false alerting). The branch ordering is what the missing unit test would lock in.

Suggest a one-line sibling mirroring the PermissionError test:

def test_content_guard_permit_authentication_error_denies(self, caplog):
    # guard.permit() raises AuthenticationError -> _evaluate_content_guard returns False,
    # and it is NOT logged as "unexpected error evaluating content guard".

Low severity — just restoring parity with the other two error branches.

RBAC (PulpServiceAccessPolicy) has been the default authorization in production
for a while, with DomainOrg kept alongside it only as a dual-write safety net
during the switchover. With that proven out, this removes the old system for
good: the DomainOrg model and table, the now-dead DomainBasedPermission class
(its last consumer, the registry re-root patch, was dropped in pulp#1535), the
domain-create dual-write, and the DomainOrg backfill/report tooling and debug
endpoints.

The one place DomainOrg was still read on the request path, the domain-owner
bypass for content-guarded PyPI reads, now checks the equivalent RBAC grant
(core.change_domain on the domain) that domain creation already assigns to the
creator, team group, and org group, so the behavior is preserved. core.change_domain
is held by core.domain_owner / service.domain_admin but not by the view-only roles
(core.domain_viewer / service.domain_viewer), so read-only members do not bypass the
guard, matching the old DomainOrg behavior. A migration drops the table, and two
historical data migrations that imported the model or its backfill helpers are made
self-contained so the migration graph still loads after the model is gone.

Note: org_id handling in the create-domain flow stays. It feeds the
rh-org-<org_id> role grant, which is RBAC, not DomainOrg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Bryan ramos <bramos@redhat.com>
@CryptoRodeo

Copy link
Copy Markdown
Contributor Author

Added test_content_guard_permit_authentication_error_denies in test_access_policy.py, mirroring the PermissionError sibling. It asserts _evaluate_content_guard returns False when permit() raises AuthenticationError and that it is NOT logged as an unexpected error, which locks in the branch ordering so a regression that falls through to except Exception would be caught: https://github.com/pulp/pulp-service/compare/61b3be42fcbe06f31712e3c9db460eabc9f2982f..1b7831aa7229984d9becf74d27c3997aec57cc98

@dkliban
dkliban merged commit 297e64f into pulp:main Oct 28, 2026
6 checks passed
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.

2 participants