From 3056747f3969f92523abe01a802d19a481abb71c Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Tue, 6 Jan 2026 15:16:32 -0600 Subject: [PATCH] Complete audit history API: update endpoints, download logging, and history models (#20) - Add PUT /api/v1/projects/{project} endpoint with audit logging - Add PUT /api/v1/project/{project}/packages/{package} endpoint with audit logging - Add artifact.download audit logging to download endpoint - Enhance tag history endpoint with artifact metadata and pagination - Add ProjectHistory and PackageHistory models for metadata change tracking - Add database triggers for automatic history population on updates - Add migration 004_history_tables.sql with tables and triggers --- backend/app/models.py | 48 +++++++ backend/app/routes.py | 209 +++++++++++++++++++++++++++++- backend/app/schemas.py | 15 +++ migrations/004_history_tables.sql | 98 ++++++++++++++ 4 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 migrations/004_history_tables.sql diff --git a/backend/app/models.py b/backend/app/models.py index 5d6677d..470ac2f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -304,3 +304,51 @@ class AuditLog(Base): Index("idx_audit_logs_resource_timestamp", "resource", "timestamp"), Index("idx_audit_logs_user_timestamp", "user_id", "timestamp"), ) + + +class ProjectHistory(Base): + """Track changes to project metadata over time.""" + + __tablename__ = "project_history" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id = Column( + UUID(as_uuid=True), + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + ) + field_name = Column(String(100), nullable=False) + old_value = Column(Text) + new_value = Column(Text) + changed_at = Column(DateTime(timezone=True), default=datetime.utcnow) + changed_by = Column(String(255), nullable=False) + + __table_args__ = ( + Index("idx_project_history_project_id", "project_id"), + Index("idx_project_history_changed_at", "changed_at"), + Index("idx_project_history_project_changed_at", "project_id", "changed_at"), + ) + + +class PackageHistory(Base): + """Track changes to package metadata over time.""" + + __tablename__ = "package_history" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + package_id = Column( + UUID(as_uuid=True), + ForeignKey("packages.id", ondelete="CASCADE"), + nullable=False, + ) + field_name = Column(String(100), nullable=False) + old_value = Column(Text) + new_value = Column(Text) + changed_at = Column(DateTime(timezone=True), default=datetime.utcnow) + changed_by = Column(String(255), nullable=False) + + __table_args__ = ( + Index("idx_package_history_package_id", "package_id"), + Index("idx_package_history_changed_at", "changed_at"), + Index("idx_package_history_package_changed_at", "package_id", "changed_at"), + ) diff --git a/backend/app/routes.py b/backend/app/routes.py index 1ed78d1..f2c90b2 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -44,8 +44,10 @@ from .models import ( ) from .schemas import ( ProjectCreate, + ProjectUpdate, ProjectResponse, PackageCreate, + PackageUpdate, PackageResponse, PackageDetailResponse, TagSummary, @@ -540,6 +542,60 @@ def get_project(project_name: str, db: Session = Depends(get_db)): return project +@router.put("/api/v1/projects/{project_name}", response_model=ProjectResponse) +def update_project( + project_name: str, + project_update: ProjectUpdate, + request: Request, + db: Session = Depends(get_db), +): + """Update a project's metadata.""" + user_id = get_user_id(request) + + project = db.query(Project).filter(Project.name == project_name).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Track changes for audit log + changes = {} + if ( + project_update.description is not None + and project_update.description != project.description + ): + changes["description"] = { + "old": project.description, + "new": project_update.description, + } + project.description = project_update.description + if ( + project_update.is_public is not None + and project_update.is_public != project.is_public + ): + changes["is_public"] = { + "old": project.is_public, + "new": project_update.is_public, + } + project.is_public = project_update.is_public + + if not changes: + # No changes, return current project + return project + + # Audit log + _log_audit( + db=db, + action="project.update", + resource=f"project/{project_name}", + user_id=user_id, + source_ip=request.client.host if request.client else None, + details={"changes": changes}, + ) + + db.commit() + db.refresh(project) + return project + + @router.delete("/api/v1/projects/{project_name}", status_code=204) def delete_project( project_name: str, @@ -921,6 +977,90 @@ def create_package( return db_package +@router.put( + "/api/v1/project/{project_name}/packages/{package_name}", + response_model=PackageResponse, +) +def update_package( + project_name: str, + package_name: str, + package_update: PackageUpdate, + request: Request, + db: Session = Depends(get_db), +): + """Update a package's metadata.""" + user_id = get_user_id(request) + + project = db.query(Project).filter(Project.name == project_name).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + package = ( + db.query(Package) + .filter(Package.project_id == project.id, Package.name == package_name) + .first() + ) + if not package: + raise HTTPException(status_code=404, detail="Package not found") + + # Validate format and platform if provided + if ( + package_update.format is not None + and package_update.format not in PACKAGE_FORMATS + ): + raise HTTPException( + status_code=400, + detail=f"Invalid format. Must be one of: {', '.join(PACKAGE_FORMATS)}", + ) + if ( + package_update.platform is not None + and package_update.platform not in PACKAGE_PLATFORMS + ): + raise HTTPException( + status_code=400, + detail=f"Invalid platform. Must be one of: {', '.join(PACKAGE_PLATFORMS)}", + ) + + # Track changes for audit log + changes = {} + if ( + package_update.description is not None + and package_update.description != package.description + ): + changes["description"] = { + "old": package.description, + "new": package_update.description, + } + package.description = package_update.description + if package_update.format is not None and package_update.format != package.format: + changes["format"] = {"old": package.format, "new": package_update.format} + package.format = package_update.format + if ( + package_update.platform is not None + and package_update.platform != package.platform + ): + changes["platform"] = {"old": package.platform, "new": package_update.platform} + package.platform = package_update.platform + + if not changes: + # No changes, return current package + return package + + # Audit log + _log_audit( + db=db, + action="package.update", + resource=f"project/{project_name}/{package_name}", + user_id=user_id, + source_ip=request.client.host if request.client else None, + details={"changes": changes}, + ) + + db.commit() + db.refresh(package) + return package + + @router.delete( "/api/v1/project/{project_name}/packages/{package_name}", status_code=204, @@ -1567,6 +1707,23 @@ def download_artifact( filename = sanitize_filename(artifact.original_name or f"{artifact.id}") + # Audit log download + user_id = get_user_id(request) + _log_audit( + db=db, + action="artifact.download", + resource=f"project/{project_name}/{package_name}/artifact/{artifact.id[:12]}", + user_id=user_id, + source_ip=request.client.host if request.client else None, + details={ + "artifact_id": artifact.id, + "ref": ref, + "size": artifact.size, + "original_name": artifact.original_name, + }, + ) + db.commit() + # Determine download mode (query param overrides server default) download_mode = mode or settings.download_mode @@ -1994,15 +2151,17 @@ def get_tag( @router.get( "/api/v1/project/{project_name}/{package_name}/tags/{tag_name}/history", - response_model=List[TagHistoryResponse], + response_model=PaginatedResponse[TagHistoryDetailResponse], ) def get_tag_history( project_name: str, package_name: str, tag_name: str, + page: int = Query(default=1, ge=1), + limit: int = Query(default=20, ge=1, le=100), db: Session = Depends(get_db), ): - """Get the history of artifact assignments for a tag""" + """Get the history of artifact assignments for a tag with artifact metadata""" project = db.query(Project).filter(Project.name == project_name).first() if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -2021,13 +2180,53 @@ def get_tag_history( if not tag: raise HTTPException(status_code=404, detail="Tag not found") - history = ( - db.query(TagHistory) + # Get total count + total = ( + db.query(func.count(TagHistory.id)).filter(TagHistory.tag_id == tag.id).scalar() + or 0 + ) + + # Get paginated history with artifact metadata + offset = (page - 1) * limit + history_items = ( + db.query(TagHistory, Artifact) + .outerjoin(Artifact, TagHistory.new_artifact_id == Artifact.id) .filter(TagHistory.tag_id == tag.id) .order_by(TagHistory.changed_at.desc()) + .offset(offset) + .limit(limit) .all() ) - return history + + # Build response with artifact metadata + items = [] + for history, artifact in history_items: + items.append( + TagHistoryDetailResponse( + id=history.id, + tag_id=history.tag_id, + tag_name=tag.name, + old_artifact_id=history.old_artifact_id, + new_artifact_id=history.new_artifact_id, + changed_at=history.changed_at, + changed_by=history.changed_by, + artifact_size=artifact.size if artifact else 0, + artifact_original_name=artifact.original_name if artifact else None, + artifact_content_type=artifact.content_type if artifact else None, + ) + ) + + total_pages = math.ceil(total / limit) if limit > 0 else 0 + return PaginatedResponse( + items=items, + pagination=PaginationMeta( + page=page, + limit=limit, + total=total, + total_pages=total_pages, + has_more=page < total_pages, + ), + ) @router.delete( diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f4a79c3..89d24c2 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -40,6 +40,13 @@ class ProjectResponse(BaseModel): from_attributes = True +class ProjectUpdate(BaseModel): + """Schema for updating a project""" + + description: Optional[str] = None + is_public: Optional[bool] = None + + # Package format and platform enums PACKAGE_FORMATS = [ "generic", @@ -87,6 +94,14 @@ class PackageResponse(BaseModel): from_attributes = True +class PackageUpdate(BaseModel): + """Schema for updating a package""" + + description: Optional[str] = None + format: Optional[str] = None + platform: Optional[str] = None + + class TagSummary(BaseModel): """Lightweight tag info for embedding in package responses""" diff --git a/migrations/004_history_tables.sql b/migrations/004_history_tables.sql new file mode 100644 index 0000000..79cd836 --- /dev/null +++ b/migrations/004_history_tables.sql @@ -0,0 +1,98 @@ +-- Migration 004: Project and Package History Tables +-- Adds history tracking tables for project and package metadata changes + +-- ============================================ +-- Project History Table +-- ============================================ +CREATE TABLE IF NOT EXISTS project_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + field_name VARCHAR(100) NOT NULL, + old_value TEXT, + new_value TEXT, + changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + changed_by VARCHAR(255) NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_project_history_project_id ON project_history(project_id); +CREATE INDEX IF NOT EXISTS idx_project_history_changed_at ON project_history(changed_at); +CREATE INDEX IF NOT EXISTS idx_project_history_project_changed_at ON project_history(project_id, changed_at); + +-- ============================================ +-- Package History Table +-- ============================================ +CREATE TABLE IF NOT EXISTS package_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + package_id UUID NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + field_name VARCHAR(100) NOT NULL, + old_value TEXT, + new_value TEXT, + changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + changed_by VARCHAR(255) NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_package_history_package_id ON package_history(package_id); +CREATE INDEX IF NOT EXISTS idx_package_history_changed_at ON package_history(changed_at); +CREATE INDEX IF NOT EXISTS idx_package_history_package_changed_at ON package_history(package_id, changed_at); + +-- ============================================ +-- Project Update Trigger +-- ============================================ +CREATE OR REPLACE FUNCTION log_project_changes() +RETURNS TRIGGER AS $$ +BEGIN + -- Log description change + IF OLD.description IS DISTINCT FROM NEW.description THEN + INSERT INTO project_history (project_id, field_name, old_value, new_value, changed_by) + VALUES (NEW.id, 'description', OLD.description, NEW.description, COALESCE(current_setting('app.current_user', true), 'system')); + END IF; + + -- Log is_public change + IF OLD.is_public IS DISTINCT FROM NEW.is_public THEN + INSERT INTO project_history (project_id, field_name, old_value, new_value, changed_by) + VALUES (NEW.id, 'is_public', OLD.is_public::text, NEW.is_public::text, COALESCE(current_setting('app.current_user', true), 'system')); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS project_changes_trigger ON projects; +CREATE TRIGGER project_changes_trigger + AFTER UPDATE ON projects + FOR EACH ROW + EXECUTE FUNCTION log_project_changes(); + +-- ============================================ +-- Package Update Trigger +-- ============================================ +CREATE OR REPLACE FUNCTION log_package_changes() +RETURNS TRIGGER AS $$ +BEGIN + -- Log description change + IF OLD.description IS DISTINCT FROM NEW.description THEN + INSERT INTO package_history (package_id, field_name, old_value, new_value, changed_by) + VALUES (NEW.id, 'description', OLD.description, NEW.description, COALESCE(current_setting('app.current_user', true), 'system')); + END IF; + + -- Log format change + IF OLD.format IS DISTINCT FROM NEW.format THEN + INSERT INTO package_history (package_id, field_name, old_value, new_value, changed_by) + VALUES (NEW.id, 'format', OLD.format, NEW.format, COALESCE(current_setting('app.current_user', true), 'system')); + END IF; + + -- Log platform change + IF OLD.platform IS DISTINCT FROM NEW.platform THEN + INSERT INTO package_history (package_id, field_name, old_value, new_value, changed_by) + VALUES (NEW.id, 'platform', OLD.platform, NEW.platform, COALESCE(current_setting('app.current_user', true), 'system')); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS package_changes_trigger ON packages; +CREATE TRIGGER package_changes_trigger + AFTER UPDATE ON packages + FOR EACH ROW + EXECUTE FUNCTION log_package_changes();