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
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user