Skip to content

PULP-1179: Enable public domain API access for GET/HEAD/OPTIONS requests (using no plugins or MCP at all)#928

Open
decko wants to merge 2 commits intopulp:mainfrom
decko:PULP-1179-public-domain-api-access
Open

PULP-1179: Enable public domain API access for GET/HEAD/OPTIONS requests (using no plugins or MCP at all)#928
decko wants to merge 2 commits intopulp:mainfrom
decko:PULP-1179-public-domain-api-access

Conversation

@decko
Copy link
Member

@decko decko commented Feb 6, 2026

Summary

Implements PULP-1179 to enable unauthenticated GET/HEAD/OPTIONS requests to any API endpoint for domains with names starting with public- (case-insensitive).

Changes

Core Implementation

  • New Permission Classes (pulp_service/pulp_service/app/authorization.py):
    • PublicDomainReadOnlyPermission: Allows unauthenticated GET/HEAD/OPTIONS to public- domains
    • PublicDomainOrAuthenticatedPermission: Combined permission class (public domain access OR authenticated access)

Configuration

  • Default Permission Class (deploy/clowdapp.yaml):
    • Updated PULP_REST_FRAMEWORK__DEFAULT_PERMISSION_CLASSES to use PublicDomainOrAuthenticatedPermission
    • Applies system-wide to all API endpoints

Patches

  • PyPI Endpoints (images/assets/patches/0038-readonly-pypi-endpoints.patch):
    • Updated to use new PublicDomainOrAuthenticatedPermission class for consistency

Testing

  • Comprehensive Tests (pulp_service/pulp_service/tests/functional/test_authentication.py):
    • test_get_requests_to_public_domains_without_auth: Verifies GET/HEAD/OPTIONS work without auth on public- domains
    • test_get_requests_to_private_domains_require_auth: Verifies private domains require auth
    • test_case_insensitive_public_domain_matching: Tests case-insensitive matching (public-, Public-, PUBLIC-)

Documentation

  • ARCHITECTURE.md: Added "Public Domain Access" section with:
    • Feature description and examples
    • Security considerations
    • Implementation details
    • Testing information

Security Features

Prefix-only matching: Uses startswith("public-") to prevent accidental matches
Case-insensitive: Handles public-, Public-, PUBLIC-, etc.
Safe methods only: Only GET/HEAD/OPTIONS allowed without auth
Write operations protected: POST/PUT/PATCH/DELETE still require authentication
Fail-safe: Returns False on any exceptions
Audit logging: All public access is logged with domain name

Behavior Changes

Before:

  • ALL domains allowed unauthenticated GET requests (via AllowUnauthPull)

After:

  • ONLY domains starting with "public-" allow unauthenticated GET/HEAD/OPTIONS requests
  • Private domains now require authentication for all requests

Testing Instructions

Unit Tests

pytest pulp_service/pulp_service/tests/functional/test_authentication.py::test_get_requests_to_public_domains_without_auth -v
pytest pulp_service/pulp_service/tests/functional/test_authentication.py::test_get_requests_to_private_domains_require_auth -v
pytest pulp_service/pulp_service/tests/functional/test_authentication.py::test_case_insensitive_public_domain_matching -v

Manual Testing

# Should succeed (200 OK) - public domain
curl -I https://your-api-host/api/pypi/public-test/packages/simple/

# Should fail (401/403) - private domain
curl -I https://your-api-host/api/pypi/private-test/packages/simple/

Deployment Notes

  1. Pre-deployment: Audit existing domains with "public-" prefix to ensure they should be publicly accessible
  2. Deployment: Changes apply automatically when pods restart
  3. Monitoring: Look for log messages: "Allowing unauthenticated {method} to public domain: {name}"
  4. Rollback: Revert PULP_REST_FRAMEWORK__DEFAULT_PERMISSION_CLASSES to DomainBasedPermission if needed

Files Changed

  • pulp_service/pulp_service/app/authorization.py (+50 lines)
  • deploy/clowdapp.yaml (+2/-2 lines)
  • images/assets/patches/0038-readonly-pypi-endpoints.patch (+7/-7 lines)
  • pulp_service/pulp_service/tests/functional/test_authentication.py (+189/-56 lines)
  • docs/ARCHITECTURE.md (+27 lines)

Total: 284 lines added, 56 lines removed

🤖 Generated with Claude Code

Summary by Sourcery

Restrict unauthenticated API access to read-only requests on explicitly public domains while updating permissions, configuration, and tests accordingly.

New Features:

  • Introduce public-domain read-only access that allows unauthenticated GET/HEAD/OPTIONS requests to API endpoints for domains whose names start with public- (case-insensitive).
  • Add a combined permission that permits either unauthenticated public-domain reads or authenticated domain-based access, and configure it as the default for the REST API.
  • Extend the Python content endpoint with a contains filter via a new patch to the upstream PyPI plugin.

Enhancements:

  • Update PyPI-related endpoint configuration to rely on the new public-domain permission behavior for consistency across APIs.
  • Document the public domain access behavior, including examples, security constraints, implementation notes, and test coverage in ARCHITECTURE.md.

Tests:

  • Expand functional authentication tests to cover public-domain unauthenticated read access, private-domain access restrictions, and case-insensitive domain prefix handling.

decko and others added 2 commits February 6, 2026 11:31
…179)

Add support for unauthenticated GET/HEAD/OPTIONS requests to API endpoints
for domains with names starting with "public-" (case-insensitive). This
allows public read-only access to repository content and metadata while
maintaining security for private domains and write operations.

Changes:
- Add PublicDomainReadOnlyPermission and PublicDomainOrAuthenticatedPermission
  classes to authorization.py
- Update default permission class in clowdapp.yaml to apply system-wide
- Update PyPI endpoints patch to use new permission class
- Add comprehensive tests for public domain access, private domain
  restrictions, and case-insensitive matching
- Document the feature in ARCHITECTURE.md with security considerations

Security:
- Only safe HTTP methods (GET, HEAD, OPTIONS) allowed without auth
- POST/PUT/PATCH/DELETE still require authentication on public domains
- Case-insensitive prefix matching (public-, Public-, PUBLIC-)
- All unauthenticated access is logged for audit purposes

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 6, 2026

Reviewer's Guide

Implements domain-based public read-only access by introducing new permission classes that allow unauthenticated GET/HEAD/OPTIONS requests only for domains prefixed with public-, wiring this into the global DRF permission configuration, updating PyPI endpoint patches to use the new permission, and adding focused tests and documentation around the new behavior and security constraints.

Sequence diagram for request authorization with PublicDomainOrAuthenticatedPermission

sequenceDiagram
    actor Client
    participant DRFView
    participant PublicDomainOrAuthenticatedPermission as PublicDomainOrAuth
    participant PublicDomainReadOnlyPermission as PublicReadOnly
    participant DomainBasedPermission as DomainPerm
    participant Domain

    Client->>DRFView: HTTP request
    DRFView->>PublicDomainOrAuth: has_permission(request, view)
    activate PublicDomainOrAuth

    PublicDomainOrAuth->>PublicReadOnly: has_permission(request, view)
    activate PublicReadOnly
    alt method not safe
        PublicReadOnly-->>PublicDomainOrAuth: False
    else method is safe
        PublicReadOnly->>Domain: get(pk=get_domain_pk())
        alt domain name startswith public- (case-insensitive)
            Domain-->>PublicReadOnly: Domain instance
            PublicReadOnly-->>PublicDomainOrAuth: True
        else domain missing or not public- or error
            Domain-->>PublicReadOnly: not found or error
            PublicReadOnly-->>PublicDomainOrAuth: False
        end
    end
    deactivate PublicReadOnly

    alt PublicReadOnly returned True
        PublicDomainOrAuth-->>DRFView: True
        DRFView-->>Client: 2xx response (unauthenticated allowed)
    else PublicReadOnly returned False
        PublicDomainOrAuth->>DomainPerm: has_permission(request, view)
        activate DomainPerm
        DomainPerm-->>PublicDomainOrAuth: True or False
        deactivate DomainPerm
        PublicDomainOrAuth-->>DRFView: True or False
        DRFView-->>Client: 2xx or 401/403 based on result
    end
    deactivate PublicDomainOrAuth
Loading

Class diagram for new public domain permission classes

classDiagram
    class BasePermission {
        <<abstract>>
        +has_permission(request, view) bool
    }

    class Domain {
        +pk
        +name
    }

    class DomainBasedPermission {
        +has_permission(request, view) bool
    }

    class PublicDomainReadOnlyPermission {
        +has_permission(request, view) bool
        -get_domain_pk() int
        -SAFE_METHODS
        -_logger
    }

    class PublicDomainOrAuthenticatedPermission {
        +has_permission(request, view) bool
        -PublicDomainReadOnlyPermission public_check
        -DomainBasedPermission domain_check
    }

    BasePermission <|-- DomainBasedPermission
    BasePermission <|-- PublicDomainReadOnlyPermission
    BasePermission <|-- PublicDomainOrAuthenticatedPermission

    Domain "1" <-- PublicDomainReadOnlyPermission : uses
    PublicDomainOrAuthenticatedPermission --> PublicDomainReadOnlyPermission : delegates
    PublicDomainOrAuthenticatedPermission --> DomainBasedPermission : delegates
Loading

Flow diagram for PublicDomainReadOnlyPermission decision logic

flowchart TD
    A[Start has_permission] --> B{Is request.method in SAFE_METHODS?}
    B -- No --> Z[Return False]
    B -- Yes --> C[domain_pk = get_domain_pk]
    C --> D{domain_pk exists?}
    D -- No --> Z
    D -- Yes --> E[Fetch Domain by pk]
    E --> F{Domain found and domain.name.lower startswith public-?}
    F -- Yes --> G[Log allowing unauthenticated access]
    G --> H[Return True]
    F -- No --> Z
    E -->|Domain.DoesNotExist| I[Log domain not found]
    I --> Z
    E -->|Exception| J[Log warning about error]
    J --> Z
    Z --> K[End has_permission]
    H --> K
Loading

File-Level Changes

Change Details Files
Introduce public-domain-aware read-only permission classes and compose them with existing domain-based permissions.
  • Add PublicDomainReadOnlyPermission to allow unauthenticated safe-method access only when the resolved domain name starts with public- (case-insensitive).
  • Add PublicDomainOrAuthenticatedPermission that first evaluates PublicDomainReadOnlyPermission and otherwise falls back to DomainBasedPermission for authenticated/authorized access.
  • Include defensive error handling and logging for domain resolution failures and audit logging for granted public access.
pulp_service/pulp_service/app/authorization.py
Switch the default DRF permission class to use the new public-domain-or-authenticated permission globally.
  • Update PULP_REST_FRAMEWORK__DEFAULT_PERMISSION_CLASSES to point to PublicDomainOrAuthenticatedPermission instead of DomainBasedPermission.
  • Clarify the configuration description to mention unauthenticated GET/HEAD/OPTIONS to public- domains.
deploy/clowdapp.yaml
Align PyPI endpoint behavior with the new public-domain permission model and add a new patch for python content filtering.
  • Update readonly PyPI endpoints patch to reference the new PublicDomainOrAuthenticatedPermission for consistency with the global default.
  • Add Dockerfile wiring for a new 0042-Add-contains-filter-python-content-endpoint.patch and copy/apply it in the image build.
images/assets/patches/0038-readonly-pypi-endpoints.patch
images/assets/patches/0042-Add-contains-filter-python-content-endpoint.patch
Dockerfile
Expand authentication functional tests to cover public vs private domain behaviors and case-insensitive public- matching.
  • Replace the previous all-domains-allow-GET test with a scenario that provisions a public- domain, repository, and distribution, then validates unauthenticated GET/HEAD/OPTIONS success and write-method denial.
  • Add tests that ensure private domains now require authentication even for GET and that authenticated GET still succeeds.
  • Add parametrized-style coverage of case variants (public-, Public-, PUBLIC-) to confirm case-insensitive prefix matching and unauthenticated GET access.
pulp_service/pulp_service/tests/functional/test_authentication.py
Document the public domain access model, configuration, and tests in the architecture docs.
  • Add a Public Domain Access section describing behavior, examples, and security constraints (safe methods only, prefix-only, logging).
  • Reference the default permission class and configuration knob used to enable the feature.
  • Point to the associated tests that validate the behavior.
docs/ARCHITECTURE.md

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

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

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:

  • The new PublicDomainReadOnlyPermission performs a Domain.objects.get and logs on every allowed public request; consider caching the domain lookup for the lifetime of the request and downgrading or structuring the log level to avoid excessive overhead/volume on high-traffic public domains.
  • The setup logic for creating domains, repositories, distributions, and building URLs is duplicated across the new authentication tests; extracting a small helper or fixture to encapsulate this would make the tests shorter and easier to maintain.
  • The Dockerfile change adding and applying 0042-Add-contains-filter-python-content-endpoint.patch seems orthogonal to the public-domain permission work described in the PR; consider moving this patch into a separate, focused PR to keep changes scoped and reviewable.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `PublicDomainReadOnlyPermission` performs a `Domain.objects.get` and logs on every allowed public request; consider caching the domain lookup for the lifetime of the request and downgrading or structuring the log level to avoid excessive overhead/volume on high-traffic public domains.
- The setup logic for creating domains, repositories, distributions, and building URLs is duplicated across the new authentication tests; extracting a small helper or fixture to encapsulate this would make the tests shorter and easier to maintain.
- The Dockerfile change adding and applying `0042-Add-contains-filter-python-content-endpoint.patch` seems orthogonal to the public-domain permission work described in the PR; consider moving this patch into a separate, focused PR to keep changes scoped and reviewable.

## Individual Comments

### Comment 1
<location> `pulp_service/pulp_service/app/authorization.py:135-144` </location>
<code_context>
+class PublicDomainReadOnlyPermission(BasePermission):
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Consider tightening the broad `except Exception` and logging with traceback for easier debugging.

At this level, a broad catch makes it difficult to separate expected operational errors (e.g. transient DB issues) from genuine bugs, and the current logging loses the traceback. Please either narrow the exception type to the specific errors you expect (e.g. `DatabaseError`) or, if you must keep it broad, log with `exc_info=True` (e.g. `logger.warning("Error checking domain for public access", exc_info=True)`) so the traceback is preserved and issues aren’t silently masked.

Suggested implementation:

```python
except Exception:
    # Preserve full traceback for easier debugging while still denying access on error
    logger.warning("Error checking domain for public access", exc_info=True)
    return False

```

Because I only see part of the file, please also:
1. Verify that the above `except Exception` block is inside `PublicDomainReadOnlyPermission.has_permission`; if the log message is slightly different, adjust the SEARCH text to match the existing message exactly.
2. If your codebase defines more specific operational error types (e.g., `DatabaseError`, `OperationalError` or a custom domain check exception), consider replacing `Exception` with a tuple of those specific types while keeping `exc_info=True`:
   - `except (DatabaseError, OperationalError) as exc:`
3. Ensure that `logger` is defined in this module, typically via:
   - `import logging`
   - `logger = logging.getLogger(__name__)`
   If the logger variable or import names differ, adapt the code to the existing logging conventions.
</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 on lines +135 to +144
class PublicDomainReadOnlyPermission(BasePermission):
"""
A Permission Class that grants permission to make GET/HEAD/OPTIONS requests
without authentication ONLY to domains with 'public-' prefix.
"""

def has_permission(self, request, view):
# Only allow safe methods
if request.method not in SAFE_METHODS:
return False
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): Consider tightening the broad except Exception and logging with traceback for easier debugging.

At this level, a broad catch makes it difficult to separate expected operational errors (e.g. transient DB issues) from genuine bugs, and the current logging loses the traceback. Please either narrow the exception type to the specific errors you expect (e.g. DatabaseError) or, if you must keep it broad, log with exc_info=True (e.g. logger.warning("Error checking domain for public access", exc_info=True)) so the traceback is preserved and issues aren’t silently masked.

Suggested implementation:

except Exception:
    # Preserve full traceback for easier debugging while still denying access on error
    logger.warning("Error checking domain for public access", exc_info=True)
    return False

Because I only see part of the file, please also:

  1. Verify that the above except Exception block is inside PublicDomainReadOnlyPermission.has_permission; if the log message is slightly different, adjust the SEARCH text to match the existing message exactly.
  2. If your codebase defines more specific operational error types (e.g., DatabaseError, OperationalError or a custom domain check exception), consider replacing Exception with a tuple of those specific types while keeping exc_info=True:
    • except (DatabaseError, OperationalError) as exc:
  3. Ensure that logger is defined in this module, typically via:
    • import logging
    • logger = logging.getLogger(__name__)
      If the logger variable or import names differ, adapt the code to the existing logging conventions.

@decko
Copy link
Member Author

decko commented Feb 6, 2026

❯ /cost
  ⎿  Total cost:            $3.60
     Total duration (API):  15m 39s
     Total duration (wall): 19m 30s
     Total code changes:    279 lines added, 51 lines removed
     Usage by model:
         claude-haiku-4-5:  68.0k input, 17.6k output, 2.6m cache read, 154.7k cache write ($0.61)
        claude-sonnet-4-5:  6.3k input, 29.2k output, 1.9m cache read, 202.4k cache write ($2.99)

@decko decko changed the title PULP-1179: Enable public domain API access for GET/HEAD/OPTIONS requests PULP-1179: Enable public domain API access for GET/HEAD/OPTIONS requests (using no plugins or MCP at all) Feb 6, 2026
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