-
Bug
-
Resolution: Unresolved
-
Major
-
None
-
quay-v3.17.0
-
False
-
-
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