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:
Mondo Diaz
2026-01-06 15:16:32 -06:00
parent b81c69118f
commit 3056747f39
4 changed files with 365 additions and 5 deletions

View File

@@ -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"),
)

View File

@@ -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(

View File

@@ -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"""

View File

@@ -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();