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:
@@ -304,3 +304,51 @@ class AuditLog(Base):
|
|||||||
Index("idx_audit_logs_resource_timestamp", "resource", "timestamp"),
|
Index("idx_audit_logs_resource_timestamp", "resource", "timestamp"),
|
||||||
Index("idx_audit_logs_user_timestamp", "user_id", "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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
|
ProjectUpdate,
|
||||||
ProjectResponse,
|
ProjectResponse,
|
||||||
PackageCreate,
|
PackageCreate,
|
||||||
|
PackageUpdate,
|
||||||
PackageResponse,
|
PackageResponse,
|
||||||
PackageDetailResponse,
|
PackageDetailResponse,
|
||||||
TagSummary,
|
TagSummary,
|
||||||
@@ -540,6 +542,60 @@ def get_project(project_name: str, db: Session = Depends(get_db)):
|
|||||||
return project
|
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)
|
@router.delete("/api/v1/projects/{project_name}", status_code=204)
|
||||||
def delete_project(
|
def delete_project(
|
||||||
project_name: str,
|
project_name: str,
|
||||||
@@ -921,6 +977,90 @@ def create_package(
|
|||||||
return db_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(
|
@router.delete(
|
||||||
"/api/v1/project/{project_name}/packages/{package_name}",
|
"/api/v1/project/{project_name}/packages/{package_name}",
|
||||||
status_code=204,
|
status_code=204,
|
||||||
@@ -1567,6 +1707,23 @@ def download_artifact(
|
|||||||
|
|
||||||
filename = sanitize_filename(artifact.original_name or f"{artifact.id}")
|
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)
|
# Determine download mode (query param overrides server default)
|
||||||
download_mode = mode or settings.download_mode
|
download_mode = mode or settings.download_mode
|
||||||
|
|
||||||
@@ -1994,15 +2151,17 @@ def get_tag(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/v1/project/{project_name}/{package_name}/tags/{tag_name}/history",
|
"/api/v1/project/{project_name}/{package_name}/tags/{tag_name}/history",
|
||||||
response_model=List[TagHistoryResponse],
|
response_model=PaginatedResponse[TagHistoryDetailResponse],
|
||||||
)
|
)
|
||||||
def get_tag_history(
|
def get_tag_history(
|
||||||
project_name: str,
|
project_name: str,
|
||||||
package_name: str,
|
package_name: str,
|
||||||
tag_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),
|
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()
|
project = db.query(Project).filter(Project.name == project_name).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
@@ -2021,13 +2180,53 @@ def get_tag_history(
|
|||||||
if not tag:
|
if not tag:
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
raise HTTPException(status_code=404, detail="Tag not found")
|
||||||
|
|
||||||
history = (
|
# Get total count
|
||||||
db.query(TagHistory)
|
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)
|
.filter(TagHistory.tag_id == tag.id)
|
||||||
.order_by(TagHistory.changed_at.desc())
|
.order_by(TagHistory.changed_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
.all()
|
.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(
|
@router.delete(
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ class ProjectResponse(BaseModel):
|
|||||||
from_attributes = True
|
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 format and platform enums
|
||||||
PACKAGE_FORMATS = [
|
PACKAGE_FORMATS = [
|
||||||
"generic",
|
"generic",
|
||||||
@@ -87,6 +94,14 @@ class PackageResponse(BaseModel):
|
|||||||
from_attributes = True
|
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):
|
class TagSummary(BaseModel):
|
||||||
"""Lightweight tag info for embedding in package responses"""
|
"""Lightweight tag info for embedding in package responses"""
|
||||||
|
|
||||||
|
|||||||
98
migrations/004_history_tables.sql
Normal file
98
migrations/004_history_tables.sql
Normal 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();
|
||||||
Reference in New Issue
Block a user