-
Bug
-
Resolution: Unresolved
-
Critical
-
None
-
quay-v3.17.0
Problem Statement
Immutable tags (marked with lock icon) can have expiration dates, creating a logical contradiction:
- Immutable = Tag cannot be deleted or modified (permanent)
- Expiration = Tag will be auto-deleted after expiration time
These properties are mutually exclusive but the system currently allows both to be set simultaneously.
Quay Build: quay.io/redhat-user-workloads/quay-eng-tenant/stable-3-17-v4-20@sha256:ebd38d2e196a047d662be9e5c053b92a2606ffea1e16edde4d8c23ef111fd32dĀ
Visual Evidence
Tag stable in repository quayqe/demo shows:
- š Lock icon (immutable)
- ā° "9 hours" expiration warning

Root Cause Analysis
Frontend (UI) - ā CORRECT
The new React UI correctly disables the "Change expiration" dropdown action for immutable tags.
File: web/src/routes/RepositoryDetails/Tags/TagsActions.tsx:59-64
Backend API - ā MISSING VALIDATION
File: endpoints/api/tag.py:217-252
The PUT /api/v1/repository/{namespace}/{repository}/tag/{tag} endpoint processes "expiration" field without checking if tag is immutable.
Database Layer - ā MISSING VALIDATION
File: data/model/oci/tag.py:651-679
The change_tag_expiration() function does not validate immutability.
How This Bug Occurs
Scenario 1: Expiration Set Before Immutability
- Tag created with expiration (e.g., 30 days)
- Immutability policy applied or tag manually made immutable
- Tag now has BOTH properties (contradictory state)
Scenario 2: Direct API Bypass
- Tag made immutable via UI or policy
- User calls API directly with "expiration": <timestamp>
- Backend accepts request without validation
- Tag now has contradictory state
Impact Assessment
Severity: MAJOR (Potentially CRITICAL)
Data Loss Risk
- If garbage collection worker honors expiration over immutability, production images may be auto-deleted
- Violates immutability guarantee
Compliance Risk
- Immutable tags often used for compliance (audit trails, signed releases, regulatory requirements)
- Unexpected deletion could violate SOC2, HIPAA, or other compliance standards
Security Risk
- Immutability is a security feature (prevents tampering)
- Circumventing it via expiration could enable supply chain attacks
Steps to Reproduce
Method 1: UI Workflow
- Create repository and push tag with expiration (e.g., 7 days)
- Create immutability policy for tag pattern
- Tag now shows lock icon AND expiration date (contradiction!)
Method 2: API Direct Call
- Make tag immutable
- Call API: PUT /api/v1/repository/test/demo/tag/v1.0.0 with "expiration": 1739000000
- Tag now has both immutable=true AND lifetime_end_ms set
Expected Behavior
- API should return HTTP 400 with error: "Cannot change expiration on immutable tag"
- Database should enforce mutual exclusivity
Actual Behavior
- API accepts expiration change on immutable tags (bug)
- Database allows contradictory state (bug)
Affected Code Files
Backend API:
- endpoints/api/tag.py - Lines 217-252 (expiration handling)
Data Layer:
- data/model/oci/tag.py - Lines 651-679 (change_tag_expiration)
- data/model/oci/tag.py - set_tag_immutable function
Frontend (Already Correct):
- web/src/routes/RepositoryDetails/Tags/TagsActions.tsx - Lines 59-64
Recommended Fixes
Fix 1: Backend API Validation (CRITICAL)
Add validation in endpoints/api/tag.py:217:
if "expiration" in request.get_json(): tag_ref = registry_model.get_repo_tag(repo_ref, tag) # ā ADD VALIDATION if features.IMMUTABLE_TAGS and tag_ref.immutable: raise InvalidRequest( "Cannot change expiration on immutable tag." )
Fix 2: Database Layer Validation (CRITICAL)
Add validation in data/model/oci/tag.py:change_tag_expiration:
def change_tag_expiration(tag_id, expiration_datetime): tag = Tag.get(id=tag_id) # ā ADD VALIDATION if features.IMMUTABLE_TAGS and tag.immutable: raise ImmutableTagException(tag.name, "change_expiration", tag.repository_id)
Add reverse validation in set_tag_immutable:
def set_tag_immutable(repository_id, tag_name, immutable): tag = get_tag(repository_id, tag_name) # ā ADD VALIDATION if immutable and tag.lifetime_end_ms is not None: raise InvalidRequest( "Cannot make tag immutable while it has expiration." )
Fix 3: Data Migration (HIGH)
Clean up existing conflicting tags:
def fix_immutable_tags_with_expiration(): conflicting_tags = Tag.select().where( Tag.immutable == True, Tag.lifetime_end_ms.is_null(False) ) for tag in conflicting_tags: # Remove expiration (immutability takes precedence) Tag.update(lifetime_end_ms=None).where(Tag.id == tag.id).execute()
Testing Requirements
Unit Tests
def test_cannot_set_expiration_on_immutable_tag(): tag = create_tag(immutable=True) with pytest.raises(ImmutableTagException): change_tag_expiration(tag.id, expiration_date) def test_cannot_make_tag_with_expiration_immutable(): tag = create_tag_with_expiration() with pytest.raises(InvalidRequest): set_tag_immutable(repo.id, tag.name, immutable=True)
API Tests
def test_api_rejects_expiration_on_immutable_tag(): response = put_tag_expiration(immutable_tag, expiration) assert response.status_code == 400
Database Query to Find Affected Tags
SELECT n.username, r.name, t.name, t.immutable, to_timestamp(t.lifetime_end_ms / 1000) as expires_at FROM tag t JOIN repository r ON t.repository = r.id JOIN namespace n ON r.namespace_user = n.id WHERE t.immutable = true AND t.lifetime_end_ms IS NOT NULL AND t.lifetime_end_ms > (EXTRACT(EPOCH FROM NOW()) * 1000);
Workaround
Until fixed, manually remove expiration from immutable tags:
# Remove immutability curl -X PUT ".../tag/stable" -d '{"immutable": false}' # Remove expiration curl -X PUT ".../tag/stable" -d '{"expiration": null}' # Re-apply immutability curl -X PUT ".../tag/stable" -d '{"immutable": true}'
Acceptance Criteria
- API returns HTTP 400 when setting expiration on immutable tag
- API returns HTTP 400 when making tag with expiration immutable
- Database raises ImmutableTagException for invalid operations
- Existing conflicting tags cleaned up via migration
- Unit tests and API tests added
- No tags have contradictory state after deployment
Generated by: Claude Code Automated Bug Analysis
Discovery: Manual testing + Screenshot analysis