Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,37 @@ Create a share link for a saved analysis, then load it back by ID for seven days

---

### Compliance Reports — `POST /reports/generate`, `POST /reports/preview`, `GET /reports/audit`

Generate compliance reports and audit trails from your saved analysis history. **All report endpoints require a Bearer token** (`Authorization: Bearer <token>`) and are scoped to the authenticated user — a report only ever covers your own data.

The report builder is **format-agnostic**: it assembles one structured report model (compliance metadata + summary statistics + records), and pluggable exporters render it to **PDF**, **CSV**, or **JSON**. PDF generation is fully self-contained — no external libraries required.

**Request body** (all filters optional):
```json
{
"format": "pdf",
"filters": {
"start_date": "2025-01-01T00:00:00",
"end_date": "2025-06-30T23:59:59",
"actions": ["analyze"],
"languages": ["python"],
"severities": ["error", "warning"],
"min_score": 0,
"max_score": 100,
"search": "def "
}
}
```

- `POST /reports/generate` — returns the report as a downloadable file (`Content-Disposition: attachment`) in the requested `format` (`json` \| `csv` \| `pdf`). The response carries an `X-Report-Id` header.
- `POST /reports/preview` — returns the structured report model as JSON (for UIs to render before choosing an export format).
- `GET /reports/audit?limit=100` — returns the user's audit trail (newest first). Every report generation is recorded with the report ID, applied filters, record count, and export format.

Each report embeds compliance metadata: a unique `report_id`, generation timestamp, requesting user, applied filters, and the analysis source/version that produced the underlying data.

---

## Project Structure

```
Expand Down
12 changes: 12 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
debugging,
explanation,
history,
reports,
share,
subscribe,
suggestions,
Expand All @@ -29,6 +30,7 @@
)
from .routers import health as health_router
from .routers import metrics as metrics_router
from .database import Base, engine
from .services import database
from .services.scheduler import start_scheduler, stop_scheduler
from .observability import (
Expand Down Expand Up @@ -69,6 +71,10 @@ def rate_limit_headers(remaining: int) -> dict[str, str]:
@asynccontextmanager
async def lifespan(app: FastAPI):
await database.init_db()
# Create the relational (SQLAlchemy) tables — users, history, favorites,
# shares, audit_logs — so auth and compliance reporting work on a fresh DB
# without waiting for a /share call to lazily bootstrap the schema.
Base.metadata.create_all(bind=engine)
print("🚀 QyverixAI backend starting…")
# Static info gauge so dashboards can pin version / provider labels.
initialise_app_info(version="3.0.0", ai_provider=os.getenv("AI_PROVIDER", "rule-based"))
Expand Down Expand Up @@ -96,6 +102,9 @@ async def lifespan(app: FastAPI):
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
# Expose download/report headers so a cross-origin frontend can read the
# attachment filename and report id from report downloads.
expose_headers=["Content-Disposition", "X-Report-Id"],
)


Expand Down Expand Up @@ -160,6 +169,7 @@ async def add_cache_header(request: Request, call_next):
app.include_router(share.router)
app.include_router(user_data.router)
app.include_router(upload_file.router, prefix="/upload", tags=['Upload File'] )
app.include_router(reports.router)


# Operational endpoints: /healthz/live, /healthz/ready, /metrics
Expand Down Expand Up @@ -192,6 +202,8 @@ async def root():
"/subscribe/",
"/share/",
"/history/",
"/reports/generate",
"/reports/audit",
],
}

Expand Down
23 changes: 23 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,26 @@ class SharedSnippet(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(UTC)
)


class AuditLog(Base):
"""Append-only audit trail for compliance-relevant actions.

Rows are never updated or deleted in normal operation — each entry records
one action (e.g. generating a compliance report) together with the actor,
the affected resource, and a JSON blob of contextual detail such as the
applied filters and the resulting report identifier.
"""

__tablename__ = "audit_logs"

id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), index=True, nullable=True
)
action: Mapped[str] = mapped_column(String(100), index=True)
resource: Mapped[str] = mapped_column(String(100), default="")
detail_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(UTC), index=True
)
106 changes: 106 additions & 0 deletions backend/app/routers/reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Compliance reporting router.

Exposes an authenticated workflow for generating compliance reports and
exporting them as PDF, CSV or JSON, plus read access to the audit trail. Every
endpoint is scoped to the authenticated user — a report only ever covers the
caller's own analysis history — and every report generation is recorded as an
audit event.
"""

from __future__ import annotations

import json

from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.orm import Session

from ..database import get_db
from ..models import User
from ..schemas import AuditLogRecord, ReportRequest
from ..security import get_current_user
from ..services import audit
from ..services.report_builder import build_report
from ..services.report_exporters import export_report

router = APIRouter(prefix="/reports", tags=["Compliance Reports"])


def _audit_detail(report: dict, *, fmt: str | None = None) -> dict:
detail = {
"filters": report["metadata"]["filters"],
"record_count": report["metadata"]["record_count"],
}
if fmt is not None:
detail["format"] = fmt
return detail


@router.post("/preview")
def preview_report(
payload: ReportRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""Build and return the structured report model without downloading a file.

Intended for the UI to render filter results before the user commits to an
export format.
"""
report = build_report(db, current_user, payload.filters)
audit.record_event(
db,
user_id=current_user.id,
action="report.preview",
resource=report["metadata"]["report_id"],
detail=_audit_detail(report),
)
return report


@router.post("/generate")
def generate_report(
payload: ReportRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Response:
"""Generate a compliance report and return it as a downloadable file."""
report = build_report(db, current_user, payload.filters)
content, media_type, extension = export_report(report, payload.format)

audit.record_event(
db,
user_id=current_user.id,
action="report.generate",
resource=report["metadata"]["report_id"],
detail=_audit_detail(report, fmt=payload.format),
)

filename = f"compliance-report-{report['metadata']['report_id']}.{extension}"
return Response(
content=content,
media_type=media_type,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"X-Report-Id": report["metadata"]["report_id"],
},
)


@router.get("/audit", response_model=list[AuditLogRecord])
def list_audit_trail(
limit: int = Query(100, ge=1, le=500),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[AuditLogRecord]:
"""Return the authenticated user's audit trail, newest first."""
events = audit.list_events(db, current_user.id, limit=limit)
return [
AuditLogRecord(
id=event.id,
action=event.action,
resource=event.resource,
detail=json.loads(event.detail_json or "{}"),
created_at=event.created_at.isoformat(),
)
for event in events
]
79 changes: 78 additions & 1 deletion backend/app/schemas.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Pydantic request / response models for QyverixAI."""

from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, field_validator, model_validator
import json
from typing import Any
from typing import Any, Literal

from .config import settings
from .schema_validators import (
Expand Down Expand Up @@ -378,3 +379,79 @@ class AnalyzeResponse(BaseModel):
debugging: DebuggingResponse
suggestions: SuggestionsResponse
analysis_time_ms: float | None = None


# ── Compliance reporting ──────────────────────────────────────────────────────
ReportFormat = Literal["json", "csv", "pdf"]
Severity = Literal["error", "warning", "info"]


class ReportFilters(BaseModel):
"""Filtering options applied when building a compliance report.

Every field is optional; an empty filter set selects the full audit history
available to the requesting user. The applied filters are echoed back in the
report metadata so a reviewer can reproduce exactly which slice of data the
report covers.
"""

start_date: datetime | None = Field(
default=None, description="Inclusive lower bound on record timestamp (UTC)."
)
end_date: datetime | None = Field(
default=None, description="Inclusive upper bound on record timestamp (UTC)."
)
actions: list[str] | None = Field(
default=None, description="Restrict to these analysis actions (e.g. analyze)."
)
languages: list[str] | None = Field(
default=None, description="Restrict to these detected languages."
)
severities: list[Severity] | None = Field(
default=None,
description="Keep only records containing at least one issue of these severities.",
)
min_score: int | None = Field(default=None, ge=0, le=100)
max_score: int | None = Field(default=None, ge=0, le=100)
search: str | None = Field(
default=None, max_length=200, description="Case-insensitive substring of the code."
)

@field_validator("actions", "languages")
@classmethod
def _normalise_lists(cls, v: list[str] | None) -> list[str] | None:
if v is None:
return None
cleaned = [item.strip().lower() for item in v if item and item.strip()]
return cleaned or None

@model_validator(mode="after")
def _check_bounds(self) -> "ReportFilters":
if (
self.start_date is not None
and self.end_date is not None
and self.start_date > self.end_date
):
raise ValueError("start_date must not be after end_date")
if (
self.min_score is not None
and self.max_score is not None
and self.min_score > self.max_score
):
raise ValueError("min_score must not be greater than max_score")
return self


class ReportRequest(BaseModel):
"""Request to generate a compliance report in a chosen export format."""

format: ReportFormat = "json"
filters: ReportFilters = Field(default_factory=ReportFilters)


class AuditLogRecord(BaseModel):
id: int
action: str
resource: str
detail: dict[str, Any]
created_at: str
51 changes: 51 additions & 0 deletions backend/app/services/audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Audit-trail service — append-only recording of compliance-relevant events.

Centralising writes here keeps the serialisation of the ``detail`` payload and
the commit semantics in one place, so routers only have to describe *what*
happened, not how it is persisted.
"""

from __future__ import annotations

import json
from typing import Any

from sqlalchemy import select
from sqlalchemy.orm import Session

from ..models import AuditLog


def record_event(
db: Session,
*,
user_id: int | None,
action: str,
resource: str = "",
detail: dict[str, Any] | None = None,
) -> AuditLog:
"""Append one event to the audit trail and return the persisted row."""
entry = AuditLog(
user_id=user_id,
action=action,
resource=resource,
detail_json=json.dumps(detail or {}, default=str),
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry


def list_events(db: Session, user_id: int, limit: int = 100) -> list[AuditLog]:
"""Return the most recent audit events for ``user_id``, newest first."""
return list(
db.execute(
select(AuditLog)
.where(AuditLog.user_id == user_id)
.order_by(AuditLog.id.desc())
.limit(limit)
)
.scalars()
.all()
)
Loading