Uploaded image for project: 'Project Quay'
  1. Project Quay
  2. PROJQUAY-10835

Quay 3.17 Global Readonly Superuser can't read Organization Mirror configurations for auditing

XMLWordPrintable

    • Icon: Bug Bug
    • Resolution: Unresolved
    • Icon: Major Major
    • None
    • quay-v3.17.0
    • quay
    • False
    • Hide

      None

      Show
      None
    • False

      Overview

      Global Readonly Superusers are blocked from reading organization mirror configurations, preventing them from auditing mirror settings across the registry. This violates the global readonly design principle of "read access to all resources" and creates inconsistent behavior compared to immutability policies.

      Severity: Medium (Inconsistency, Reduced Auditing Capability)
      Component: Organization Mirroring API
      Affects: Quay 3.17+

      Problem Statement

      When a Global Readonly Superuser attempts to read organization mirror configurations via the API, they receive a 403 Forbidden response, even though they can successfully read immutability policies and other configurations.

      Expected Behavior: Global readonly superusers should have read-only access to ALL configurations for auditing purposes, including organization mirror settings.

      Actual Behavior: Global readonly superusers are completely blocked from accessing organization mirror configurations.

      Current Behavior

      Configuration:

      GLOBAL_READONLY_SUPER_USERS:   - audit-user
        - monitoring-bot
      

      Test Case:

      # Global readonly user attempts to read mirror config
      curl -X GET \
        -H "Authorization: Bearer ${GLOBAL_READONLY_TOKEN}" \
        "https://quay.io/api/v1/organization/normaluser-org/mirror"
      
      # Result: 403 Forbidden ❌
      

      Comparison: Immutability policies work correctly:

      # Same user can read immutability policies
      curl -X GET \
        -H "Authorization: Bearer ${GLOBAL_READONLY_TOKEN}" \
        "https://quay.io/api/v1/organization/normaluser-org/immutabilitypolicy/"
      
      # Result: 200 OK ✅
      

      Root Cause Analysis

      File: endpoints/api/org_mirror.py
      Function: require_org_admin() (lines 73-80)

      def require_org_admin(orgname):
          """
          Check if the current user has admin permission on the organization.
          Raises Unauthorized if not.
          """
          permission = AdministerOrganizationPermission(orgname)
          if not (permission.can() or allow_if_superuser_with_full_access()):
              raise Unauthorized()
          # ❌ BUG: Missing allow_if_global_readonly_superuser() check!
      

      Issue: The require_org_admin() helper function only checks:
      1. Is user organization admin? (permission.can())
      2. Is user superuser with full access? (allow_if_superuser_with_full_access())

      It does NOT check for global readonly superusers, unlike immutability policy endpoints.

      All Endpoints Affected:

      • GET /v1/organization/<orgname>/mirror - Line 207 (read config)
      • POST /v1/organization/<orgname>/mirror - Line 267 (create config)
      • PUT /v1/organization/<orgname>/mirror - Line 383 (update config)
      • DELETE /v1/organization/<orgname>/mirror - Line 509 (delete config)

      All four operations use require_org_admin(), blocking global readonly from even READ operations.

      Inconsistency Evidence

      Immutability Policies (Correct Implementation):

      File: endpoints/api/immutability_policy.py

      # List org immutability policies (lines 74-84)
      @require_scope(scopes.ORG_ADMIN)
      @nickname("listOrgImmutabilityPolicies")
      def get(self, orgname):
          permission = AdministerOrganizationPermission(orgname)
          if (
              not permission.can()
              and not allow_if_global_readonly_superuser()  # ✅ CORRECT
              and not (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
          ):
              raise Unauthorized()
          # ... returns policies
      

      Result: Global readonly CAN read immutability policies ✅

      Organization Mirror (Broken Implementation):

      File: endpoints/api/org_mirror.py

      # Get org mirror config (line 207)
      @require_scope(scopes.ORG_ADMIN)
      @nickname("getOrgMirrorConfig")
      def get(self, orgname):
          require_org_admin(orgname)  # ❌ No global readonly check
          # ...
      

      Result: Global readonly CANNOT read mirror configs ❌

      Impact Assessment

      Severity: Medium

      Functional Impact:

      • Global readonly superusers cannot audit organization mirror configurations
      • Compliance/security teams cannot verify mirror settings without admin access
      • Inconsistent behavior confuses users

      Security Impact:

      • ✅ No privilege escalation (global readonly still blocked from writes)
      • ✅ No unintended credential exposure (currently blocked from reading)
      • ⚠️ Reduced auditing capability

      Affected Users:

      • Audit teams using global readonly superuser for compliance
      • Monitoring systems checking mirror configurations
      • Security scanners validating registry settings

      Expected Behavior

      Global readonly superusers should be able to:

      Read Operations (GET):

      • View organization mirror configurations
      • See mirror source registry URLs
      • Check sync schedules and intervals
      • View repository filters
      • Monitor sync status

      Write Operations (POST/PUT/DELETE):

      • Create mirror configurations (should remain blocked)
      • Update mirror settings (should remain blocked)
      • Delete mirror configurations (should remain blocked)

      Recommended Fix

      Update the require_org_admin() function to support global readonly superusers for read-only operations.

      Option 1: Modify Helper Function (Simplest)

      File: endpoints/api/org_mirror.py:73-80

      def require_org_admin(orgname, allow_readonly=False):
          """
          Check if the current user has admin permission on the organization.
          
          Args:
              orgname: Organization name
              allow_readonly: If True, allow global readonly superusers
          """
          permission = AdministerOrganizationPermission(orgname)
          if not (
              permission.can() 
              or allow_if_superuser_with_full_access()
              or (allow_readonly and allow_if_global_readonly_superuser())  # ← ADD THIS
          ):
              raise Unauthorized()
      

      Then update GET endpoint (line 207):

      @require_scope(scopes.ORG_ADMIN)
      @nickname("getOrgMirrorConfig")
      def get(self, orgname):
          require_org_admin(orgname, allow_readonly=True)  # ← Enable for GET
          # ... rest of implementation
      

      Leave POST/PUT/DELETE endpoints unchanged (allow_readonly=False by default).

      Option 2: Inline Check (More Consistent with Immutability)

      Replace require_org_admin() call in GET endpoint with inline check:

      @require_scope(scopes.ORG_ADMIN)
      @nickname("getOrgMirrorConfig")
      def get(self, orgname):
          permission = AdministerOrganizationPermission(orgname)
          if (
              not permission.can()
              and not allow_if_global_readonly_superuser()  # ← ADD THIS
              and not allow_if_superuser_with_full_access()
          ):
              raise Unauthorized()
          
          try:
              org = model.organization.get_organization(orgname)
          except InvalidOrganizationException:
              raise NotFound()
          # ... rest of implementation
      

      Keep require_org_admin() for POST/PUT/DELETE to maintain write blocking.

      Security Considerations

      Credential Exposure:

      Organization mirror configs contain sensitive data:

      • external_registry_username: Registry username
      • external_registry_password: Encrypted password
      • external_registry_url: Source registry URL

      Recommended: Mask passwords for global readonly users:

      def get(self, orgname):
          # ... permission checks ...
          
          is_global_readonly = allow_if_global_readonly_superuser()
          
          try:
              username = self._decrypt_username(mirror.external_registry_username)
          except DecryptionFailureException:
              username = "(invalid. please re-enter)"
          
          # Mask password for global readonly users
          if is_global_readonly:
              password = "***REDACTED***"  # Don't expose credentials
          else:
              # Show actual password for org admins and superusers with full access
              password = username  # Current implementation returns username
          
          return {
              "external_registry_username": username,
              "external_registry_password": password,  # Masked for global readonly
              # ... other fields
          }
      

      Testing Requirements

      Unit Tests:

      File: endpoints/api/test/test_org_mirror.py

      class TestOrgMirrorGlobalReadonly:
          """Tests for global readonly superuser access to org mirror."""
      
          def test_global_readonly_can_read_org_mirror_config(self, app):
              """Global readonly superuser can read mirror config."""
              # Setup: Create org mirror config
              _create_org_mirror_config("buynlarge")
      
              with client_with_identity("globalreadonlysuperuser", app) as cl:
                  resp = conduct_api_call(
                      cl, OrgMirrorConfig, "GET", {"orgname": "buynlarge"}, None, 200
                  )
                  assert resp.json["external_registry_url"] == "https://harbor.example.com"
                  assert resp.json["external_namespace"] == "my-project"
      
          def test_global_readonly_password_is_masked(self, app):
              """Global readonly superuser sees masked password."""
              _create_org_mirror_config("buynlarge")
      
              with client_with_identity("globalreadonlysuperuser", app) as cl:
                  resp = conduct_api_call(
                      cl, OrgMirrorConfig, "GET", {"orgname": "buynlarge"}, None, 200
                  )
                  # Password should be masked
                  assert resp.json["external_registry_password"] == "***REDACTED***"
      
          def test_global_readonly_blocked_from_create(self, app):
              """Global readonly blocked from creating mirror config."""
              with client_with_identity("globalreadonlysuperuser", app) as cl:
                  conduct_api_call(
                      cl,
                      OrgMirrorConfig,
                      "POST",
                      {"orgname": "buynlarge"},
                      {"external_registry_url": "https://example.com", ...},
                      403,
                  )
      
          def test_global_readonly_blocked_from_update(self, app):
              """Global readonly blocked from updating mirror config."""
              _create_org_mirror_config("buynlarge")
      
              with client_with_identity("globalreadonlysuperuser", app) as cl:
                  conduct_api_call(
                      cl,
                      OrgMirrorConfig,
                      "PUT",
                      {"orgname": "buynlarge"},
                      {"is_enabled": False},
                      403,
                  )
      
          def test_global_readonly_blocked_from_delete(self, app):
              """Global readonly blocked from deleting mirror config."""
              _create_org_mirror_config("buynlarge")
      
              with client_with_identity("globalreadonlysuperuser", app) as cl:
                  conduct_api_call(
                      cl, OrgMirrorConfig, "DELETE", {"orgname": "buynlarge"}, None, 403
                  )
      

      Integration Tests:

      File: endpoints/api/test/test_global_readonly_superuser.py

      def test_global_readonly_org_mirror_read_only_access(app):
          """Global readonly can read but not write org mirror configs."""
          # Create config as org admin
          with client_with_identity("devtable", app) as cl:
              conduct_api_call(
                  cl, OrgMirrorConfig, "POST", {"orgname": "buynlarge"}, {...}, 201
              )
      
          # Global readonly can read
          with client_with_identity("globalreadonlysuperuser", app) as cl:
              conduct_api_call(
                  cl, OrgMirrorConfig, "GET", {"orgname": "buynlarge"}, None, 200
              )
      
              # But cannot modify
              conduct_api_call(
                  cl, OrgMirrorConfig, "PUT", {"orgname": "buynlarge"}, {...}, 403
              )
      

      Comparison with Immutability Policies

      Why This Works:

      Feature GET Permission Check Result
      --------- --------------------- --------
      Immutability Policies permission.can() || allow_if_global_readonly_superuser() || allow_if_superuser_with_full_access() ✅ Global readonly allowed
      Organization Mirror permission.can() || allow_if_superuser_with_full_access() ❌ Global readonly blocked

      Both features should have identical permission patterns for GET operations.

      Acceptance Criteria

      AC1: Global readonly superuser can GET organization mirror configurations

      AC2: Passwords are masked as **REDACTED** for global readonly users

      AC3: Global readonly superuser is blocked from POST/PUT/DELETE operations (403 Forbidden)

      AC4: Organization admins still have full access (no regression)

      AC5: Superusers with full access still have full access (no regression)

      AC6: Unit tests added for all permission scenarios

      AC7: Documentation updated to reflect global readonly access to mirror configs

      Related Issues

      • PROJQUAY-10040 - Organization-level repository mirroring (Original feature)
      • PROJQUAY-10691 - N+1 query performance in org mirror
      • PROJQUAY-10426 - Global readonly superuser LDAP caching

      Workaround

      Until fixed, users requiring audit access to mirror configurations must:

      1. Use organization admin account (not global readonly)
      2. Use superuser with full access enabled (FEATURE_SUPERUSERS_FULL_ACCESS=true)
      3. Grant temporary admin permissions for auditing

      None of these are ideal for security/compliance teams who should have read-only access.

      Additional Context

      Global Readonly Superuser Design Principle (from agent_docs/global_readonly_superuser.md):

      Global Read Only Superusers have read access to all repositories and resources across the registry, but cannot perform any write operations. This is useful for auditing and monitoring.

      Configuration Example:

      # conf/stack/config.yaml
      GLOBAL_READONLY_SUPER_USERS:   - quayadmin
        - readonly
        - audit-bot
      

      Current Working Examples:

      • Repository access ✅
      • Organization lists ✅
      • Audit logs ✅
      • Immutability policies ✅
      • App tokens (read-only) ✅

      Broken:

      • Organization mirror configurations ❌

      Files Requiring Changes

      Code Changes:
      1. endpoints/api/org_mirror.py:73-80 - Modify require_org_admin() function
      2. endpoints/api/org_mirror.py:207-249 - Update GET endpoint permission check

      Test Changes:
      3. endpoints/api/test/test_org_mirror.py - Add global readonly test cases
      4. endpoints/api/test/test_global_readonly_superuser.py - Add org mirror integration tests

      Documentation Changes:
      5. agent_docs/global_readonly_superuser.md - Document mirror config access
      6. CHANGELOG.md - Add entry for bug fix

      Effort Estimate

      • Code changes: 2 hours (simple permission check addition)
      • Password masking: 1 hour
      • Unit tests: 3 hours
      • Integration tests: 2 hours
      • Documentation: 1 hour

      Total: 1 day

      Priority: Medium (Functional issue, not security vulnerability)

      Discovery Date: 2026-03-05
      Discovered By: Comprehensive permission audit

              rh-ee-shossain Shaon Hossain
              lzha1981 luffy zhang
              Votes:
              0 Vote for this issue
              Watchers:
              1 Start watching this issue

                Created:
                Updated: