diff --git a/.env.example b/.env.example index f5f0e52..557ccb5 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,14 @@ MINIO_BUCKET=quicktrust-evidence LITELLM_MODEL=gpt-4o-mini OPENAI_API_KEY=sk-your-key-here +# SMTP Email (optional — notifications fall back to logging when not configured) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM_EMAIL=notifications@quicktrust.dev +SMTP_USE_TLS=true + # Frontend NEXT_PUBLIC_API_URL=http://localhost:8000 NEXT_PUBLIC_KEYCLOAK_URL=http://localhost:8080 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bae2196 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,105 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend-lint: + name: Backend Lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff + - run: ruff check . + - run: ruff format --check . + + backend-test: + name: Backend Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -e ".[dev]" + - run: pytest --tb=short -q + + frontend-lint: + name: Frontend Lint & Type Check + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm exec tsc --noEmit + + frontend-build: + name: Frontend Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm build + + docker-build: + name: Docker Build Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build backend image + run: docker build -t quicktrust-api --target production backend/ + - name: Build frontend image + run: docker build -t quicktrust-web --target production frontend/ + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install safety + run: pip install safety + - name: Check Python dependencies + run: cd backend && pip install -e . && safety check --output text || true + continue-on-error: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e287477 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + exclude: frontend/pnpm-lock.yaml + - id: check-merge-conflict + - id: detect-private-key + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.0 + hooks: + - id: ruff + args: [--fix] + files: ^backend/ + - id: ruff-format + files: ^backend/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..48029e0 --- /dev/null +++ b/Makefile @@ -0,0 +1,69 @@ +.PHONY: help dev dev-backend dev-frontend build lint test test-backend test-frontend format migrate seed clean + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# === Development === + +dev: ## Start all services via Docker Compose + docker compose up + +dev-backend: ## Start only the API service + docker compose up api + +dev-frontend: ## Start the frontend dev server locally + cd frontend && pnpm dev + +# === Build === + +build: ## Build frontend for production + cd frontend && pnpm build + +docker-build: ## Build Docker images for backend and frontend + docker build -t quicktrust-api --target production backend/ + docker build -t quicktrust-web --target production frontend/ + +# === Quality === + +lint: ## Run linters (backend + frontend) + cd backend && ruff check . && ruff format --check . + cd frontend && pnpm lint + +format: ## Auto-format code + cd backend && ruff format . + cd frontend && pnpm lint --fix + +test: test-backend test-frontend ## Run all tests + +test-backend: ## Run backend tests + cd backend && pytest --tb=short -q + +test-frontend: ## Run frontend type check + cd frontend && pnpm exec tsc --noEmit + +# === Database === + +migrate: ## Run database migrations + cd backend && alembic upgrade head + +migrate-new: ## Create a new migration (usage: make migrate-new MSG="description") + cd backend && alembic revision --autogenerate -m "$(MSG)" + +seed: ## Seed the database with sample data + docker compose exec api python -m seeds.run_seeds + +# === Utilities === + +clean: ## Clean up Docker volumes and build artifacts + docker compose down -v + rm -rf frontend/.next frontend/node_modules/.cache + rm -f backend/quicktrust.db + +logs: ## Tail all service logs + docker compose logs -f + +shell-api: ## Open a shell in the API container + docker compose exec api bash + +shell-db: ## Open a psql shell + docker compose exec postgres psql -U quicktrust -d quicktrust diff --git a/backend/alembic/versions/0000_phase1_base_tables.py b/backend/alembic/versions/0000_phase1_base_tables.py new file mode 100644 index 0000000..3fb2291 --- /dev/null +++ b/backend/alembic/versions/0000_phase1_base_tables.py @@ -0,0 +1,256 @@ +"""Create Phase 1 base tables: organizations, users, frameworks, controls, evidence, policies, agents, audit_logs + +Revision ID: 0000_phase1 +Revises: None +Create Date: 2026-02-24 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0000_phase1" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # --- Organizations --- + op.create_table( + "organizations", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("industry", sa.String(100)), + sa.Column("company_size", sa.String(50)), + sa.Column("cloud_providers", sa.Text), # JSON + sa.Column("tech_stack", sa.Text), # JSON + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Users --- + op.create_table( + "users", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False), + sa.Column("keycloak_id", sa.String(255), unique=True), + sa.Column("email", sa.String(255), nullable=False), + sa.Column("full_name", sa.String(255)), + sa.Column("role", sa.String(50), server_default="employee"), + sa.Column("is_active", sa.Boolean, server_default=sa.text("1")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Frameworks --- + op.create_table( + "frameworks", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id")), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("version", sa.String(50)), + sa.Column("description", sa.Text), + sa.Column("category", sa.String(100)), + sa.Column("is_custom", sa.Boolean, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Framework Domains --- + op.create_table( + "framework_domains", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("framework_id", sa.String(36), sa.ForeignKey("frameworks.id"), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text), + sa.Column("domain_order", sa.Integer, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Framework Requirements --- + op.create_table( + "framework_requirements", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("domain_id", sa.String(36), sa.ForeignKey("framework_domains.id"), nullable=False), + sa.Column("ref_code", sa.String(50)), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("description", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Control Objectives --- + op.create_table( + "control_objectives", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("requirement_id", sa.String(36), sa.ForeignKey("framework_requirements.id"), nullable=False), + sa.Column("ref_code", sa.String(50)), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("description", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Control Templates --- + op.create_table( + "control_templates", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("description", sa.Text), + sa.Column("implementation_guidance", sa.Text), + sa.Column("domain", sa.String(100)), + sa.Column("category", sa.String(100)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Evidence Templates --- + op.create_table( + "evidence_templates", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("description", sa.Text), + sa.Column("evidence_type", sa.String(50)), + sa.Column("collection_method", sa.String(50)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Controls --- + op.create_table( + "controls", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False), + sa.Column("template_id", sa.String(36), sa.ForeignKey("control_templates.id")), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("description", sa.Text), + sa.Column("status", sa.String(50), server_default="draft"), + sa.Column("owner_id", sa.String(36), sa.ForeignKey("users.id")), + sa.Column("implementation_notes", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Evidence --- + op.create_table( + "evidence", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False), + sa.Column("control_id", sa.String(36), sa.ForeignKey("controls.id")), + sa.Column("template_id", sa.String(36), sa.ForeignKey("evidence_templates.id")), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("status", sa.String(50), server_default="pending"), + sa.Column("collection_method", sa.String(50), server_default="manual"), + sa.Column("collector", sa.String(100)), + sa.Column("collected_at", sa.DateTime(timezone=True)), + sa.Column("expires_at", sa.DateTime(timezone=True)), + sa.Column("file_url", sa.String(1000)), + sa.Column("file_name", sa.String(500)), + sa.Column("artifact_hash", sa.String(64)), + sa.Column("data", sa.Text), # JSON + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Policies --- + op.create_table( + "policies", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("content", sa.Text), + sa.Column("status", sa.String(50), server_default="draft"), + sa.Column("category", sa.String(100)), + sa.Column("approved_by_id", sa.String(36), sa.ForeignKey("users.id")), + sa.Column("approved_at", sa.DateTime(timezone=True)), + sa.Column("published_at", sa.DateTime(timezone=True)), + sa.Column("next_review_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Policy Templates --- + op.create_table( + "policy_templates", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("content", sa.Text), + sa.Column("category", sa.String(100)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Agent Runs --- + op.create_table( + "agent_runs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False), + sa.Column("agent_type", sa.String(100), nullable=False), + sa.Column("status", sa.String(50), server_default="pending"), + sa.Column("input_data", sa.Text), # JSON + sa.Column("output_data", sa.Text), # JSON + sa.Column("error_message", sa.Text), + sa.Column("started_at", sa.DateTime(timezone=True)), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + # --- Audit Logs --- + op.create_table( + "audit_logs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id")), + sa.Column("actor_id", sa.String(36)), + sa.Column("actor_type", sa.String(50), server_default="user"), + sa.Column("action", sa.String(100), nullable=False), + sa.Column("entity_type", sa.String(100)), + sa.Column("entity_id", sa.String(36)), + sa.Column("changes", sa.Text), # JSON + sa.Column("ip_address", sa.String(45)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + ) + + # --- Control-Framework Mappings --- + op.create_table( + "control_framework_mappings", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("control_id", sa.String(36), sa.ForeignKey("controls.id"), nullable=False), + sa.Column("framework_id", sa.String(36), sa.ForeignKey("frameworks.id")), + sa.Column("requirement_id", sa.String(36), sa.ForeignKey("framework_requirements.id")), + sa.Column("objective_id", sa.String(36), sa.ForeignKey("control_objectives.id")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + ) + + # --- Control Template Framework Mappings --- + op.create_table( + "control_template_framework_mappings", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("control_template_id", sa.String(36), sa.ForeignKey("control_templates.id"), nullable=False), + sa.Column("framework_id", sa.String(36), sa.ForeignKey("frameworks.id")), + sa.Column("requirement_id", sa.String(36), sa.ForeignKey("framework_requirements.id")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + ) + + +def downgrade() -> None: + op.drop_table("control_template_framework_mappings") + op.drop_table("control_framework_mappings") + op.drop_table("audit_logs") + op.drop_table("agent_runs") + op.drop_table("policy_templates") + op.drop_table("policies") + op.drop_table("evidence") + op.drop_table("controls") + op.drop_table("evidence_templates") + op.drop_table("control_templates") + op.drop_table("control_objectives") + op.drop_table("framework_requirements") + op.drop_table("framework_domains") + op.drop_table("frameworks") + op.drop_table("users") + op.drop_table("organizations") diff --git a/backend/alembic/versions/0001_phase2_tables.py b/backend/alembic/versions/0001_phase2_tables.py index 033057f..7b5e5c8 100644 --- a/backend/alembic/versions/0001_phase2_tables.py +++ b/backend/alembic/versions/0001_phase2_tables.py @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision: str = "0001_phase2" -down_revision: Union[str, None] = None +down_revision: Union[str, None] = "0000_phase1" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/backend/alembic/versions/0006_v05_tables.py b/backend/alembic/versions/0006_v05_tables.py new file mode 100644 index 0000000..d6e8b6f --- /dev/null +++ b/backend/alembic/versions/0006_v05_tables.py @@ -0,0 +1,103 @@ +"""Add v0.5 tables: notifications, auditor_profiles, embeddings, slack_webhook_configs + +Revision ID: 0006_v05 +Revises: 0005_phase6 +Create Date: 2026-02-24 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0006_v05" +down_revision: Union[str, None] = "0005_phase6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notifications", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id")), + sa.Column("channel", sa.String(50), server_default="in_app"), + sa.Column("category", sa.String(100), nullable=False), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("message", sa.Text), + sa.Column("severity", sa.String(20), server_default="info"), + sa.Column("entity_type", sa.String(100)), + sa.Column("entity_id", sa.String(36)), + sa.Column("is_read", sa.Boolean, server_default=sa.text("0")), + sa.Column("read_at", sa.DateTime(timezone=True)), + sa.Column("sent_at", sa.DateTime(timezone=True)), + sa.Column("metadata_json", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + op.create_table( + "notification_preferences", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False), + sa.Column("channel", sa.String(50), nullable=False), + sa.Column("categories", sa.Text), + sa.Column("is_enabled", sa.Boolean, server_default=sa.text("1")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + op.create_table( + "slack_webhook_configs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False), + sa.Column("webhook_url", sa.String(1000), nullable=False), + sa.Column("channel_name", sa.String(100)), + sa.Column("categories", sa.Text), + sa.Column("is_active", sa.Boolean, server_default=sa.text("1")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + op.create_table( + "auditor_profiles", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, unique=True), + sa.Column("firm_name", sa.String(255)), + sa.Column("bio", sa.Text), + sa.Column("credentials", sa.Text), # JSON + sa.Column("specializations", sa.Text), # JSON + sa.Column("years_experience", sa.Integer), + sa.Column("location", sa.String(255)), + sa.Column("hourly_rate", sa.Float), + sa.Column("is_verified", sa.Boolean, server_default=sa.text("0")), + sa.Column("rating", sa.Float), + sa.Column("total_audits", sa.Integer, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + op.create_table( + "embeddings", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("org_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False), + sa.Column("entity_type", sa.String(50), nullable=False), + sa.Column("entity_id", sa.String(36), nullable=False), + sa.Column("content_hash", sa.String(64)), + sa.Column("text_content", sa.Text), + sa.Column("vector", sa.Text), # JSON array; use pgvector in production + sa.Column("dimensions", sa.Integer), + sa.Column("model_name", sa.String(100)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(timezone=True)), + ) + + +def downgrade() -> None: + op.drop_table("embeddings") + op.drop_table("auditor_profiles") + op.drop_table("slack_webhook_configs") + op.drop_table("notification_preferences") + op.drop_table("notifications") diff --git a/backend/app/api/v1/access_reviews.py b/backend/app/api/v1/access_reviews.py index 0e5a650..a99d410 100644 --- a/backend/app/api/v1/access_reviews.py +++ b/backend/app/api/v1/access_reviews.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.access_review import ( AccessReviewCampaignCreate, @@ -23,7 +23,7 @@ @router.get("", response_model=PaginatedResponse) async def list_campaigns( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, status: str | None = None, @@ -43,7 +43,7 @@ async def list_campaigns( @router.post("", response_model=AccessReviewCampaignResponse, status_code=201) -async def create_campaign(org_id: UUID, data: AccessReviewCampaignCreate, db: DB, current_user: ComplianceUser): +async def create_campaign(org_id: VerifiedOrgId, data: AccessReviewCampaignCreate, db: DB, current_user: ComplianceUser): campaign = await access_review_service.create_campaign(db, org_id, data) return AccessReviewCampaignResponse.model_validate({ **{col: getattr(campaign, col) for col in campaign.__table__.columns.keys()}, @@ -53,25 +53,25 @@ async def create_campaign(org_id: UUID, data: AccessReviewCampaignCreate, db: DB @router.get("/stats", response_model=AccessReviewStatsResponse) -async def get_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await access_review_service.get_access_review_stats(db, org_id) @router.get("/{campaign_id}", response_model=AccessReviewCampaignResponse) -async def get_campaign(org_id: UUID, campaign_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_campaign(org_id: VerifiedOrgId, campaign_id: UUID, db: DB, current_user: AnyInternalUser): return await access_review_service.get_campaign_response(db, org_id, campaign_id) @router.patch("/{campaign_id}", response_model=AccessReviewCampaignResponse) async def update_campaign( - org_id: UUID, campaign_id: UUID, data: AccessReviewCampaignUpdate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, campaign_id: UUID, data: AccessReviewCampaignUpdate, db: DB, current_user: ComplianceUser ): campaign = await access_review_service.update_campaign(db, org_id, campaign_id, data) return await access_review_service.get_campaign_response(db, org_id, campaign_id) @router.delete("/{campaign_id}", status_code=204) -async def delete_campaign(org_id: UUID, campaign_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_campaign(org_id: VerifiedOrgId, campaign_id: UUID, db: DB, current_user: ComplianceUser): await access_review_service.delete_campaign(db, org_id, campaign_id) @@ -79,7 +79,7 @@ async def delete_campaign(org_id: UUID, campaign_id: UUID, db: DB, current_user: @router.get("/{campaign_id}/entries", response_model=list[AccessReviewEntryResponse]) async def list_entries( - org_id: UUID, campaign_id: UUID, db: DB, current_user: AnyInternalUser, + org_id: VerifiedOrgId, campaign_id: UUID, db: DB, current_user: AnyInternalUser, decision: str | None = None, ): return await access_review_service.list_entries(db, org_id, campaign_id, decision=decision) @@ -87,14 +87,14 @@ async def list_entries( @router.post("/{campaign_id}/entries", response_model=AccessReviewEntryResponse, status_code=201) async def create_entry( - org_id: UUID, campaign_id: UUID, data: AccessReviewEntryCreate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, campaign_id: UUID, data: AccessReviewEntryCreate, db: DB, current_user: ComplianceUser ): return await access_review_service.create_entry(db, org_id, campaign_id, data) @router.patch("/{campaign_id}/entries/{entry_id}", response_model=AccessReviewEntryResponse) async def update_entry( - org_id: UUID, campaign_id: UUID, entry_id: UUID, + org_id: VerifiedOrgId, campaign_id: UUID, entry_id: UUID, data: AccessReviewEntryUpdate, db: DB, current_user: CurrentUser, ): return await access_review_service.update_entry( diff --git a/backend/app/api/v1/agent_runs.py b/backend/app/api/v1/agent_runs.py index ee4f00f..cc0bffc 100644 --- a/backend/app/api/v1/agent_runs.py +++ b/backend/app/api/v1/agent_runs.py @@ -6,7 +6,7 @@ from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.core.exceptions import NotFoundError from app.models.agent_run import AgentRun from app.schemas.agent_run import AgentRunResponse, AgentRunTrigger, AgentRunTriggerGeneric @@ -17,7 +17,7 @@ @router.post("/controls-generation/run", response_model=AgentRunResponse, status_code=201) async def trigger_controls_generation( - org_id: UUID, data: AgentRunTrigger, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: AgentRunTrigger, db: DB, current_user: ComplianceUser ): agent_run = AgentRun( org_id=org_id, @@ -74,7 +74,7 @@ async def _run_agent(agent_run_id: str, org_id: str): @router.post("/policy-generation/run", response_model=AgentRunResponse, status_code=201) async def trigger_policy_generation( - org_id: UUID, data: AgentRunTrigger, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: AgentRunTrigger, db: DB, current_user: ComplianceUser ): agent_run = AgentRun( org_id=org_id, @@ -130,7 +130,7 @@ async def _run_policy_agent(agent_run_id: str, org_id: str): @router.post("/evidence-generation/run", response_model=AgentRunResponse, status_code=201) async def trigger_evidence_generation( - org_id: UUID, data: AgentRunTrigger, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: AgentRunTrigger, db: DB, current_user: ComplianceUser ): agent_run = AgentRun( org_id=org_id, @@ -185,7 +185,7 @@ async def _run_evidence_agent(agent_run_id: str, org_id: str): @router.post("/risk-assessment/run", response_model=AgentRunResponse, status_code=201) async def trigger_risk_assessment( - org_id: UUID, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser ): agent_run = AgentRun( org_id=org_id, @@ -231,7 +231,7 @@ async def _run_risk_assessment_agent(agent_run_id: str, org_id: str): @router.post("/remediation/run", response_model=AgentRunResponse, status_code=201) async def trigger_remediation( - org_id: UUID, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser ): agent_run = AgentRun( org_id=org_id, @@ -272,7 +272,7 @@ async def _run_remediation_agent(agent_run_id: str, org_id: str): @router.post("/audit-preparation/run", response_model=AgentRunResponse, status_code=201) async def trigger_audit_preparation( - org_id: UUID, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser ): agent_run = AgentRun( org_id=org_id, @@ -318,7 +318,7 @@ async def _run_audit_prep_agent(agent_run_id: str, org_id: str): @router.post("/vendor-risk-assessment/run", response_model=AgentRunResponse, status_code=201) async def trigger_vendor_risk_assessment( - org_id: UUID, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser ): agent_run = AgentRun( org_id=org_id, @@ -364,7 +364,7 @@ async def _run_vendor_risk_agent(agent_run_id: str, org_id: str): @router.post("/pentest-orchestrator/run", response_model=AgentRunResponse, status_code=201) async def trigger_pentest_orchestrator( - org_id: UUID, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser ): agent_run = AgentRun( org_id=org_id, @@ -405,7 +405,7 @@ async def _run_pentest_agent(agent_run_id: str, org_id: str): @router.post("/monitoring-daemon/run", response_model=AgentRunResponse, status_code=201) async def trigger_monitoring_daemon( - org_id: UUID, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: AgentRunTriggerGeneric, db: DB, current_user: ComplianceUser ): agent_run = AgentRun( org_id=org_id, @@ -446,7 +446,7 @@ async def _run_monitoring_daemon_agent(agent_run_id: str, org_id: str): @router.get("/runs", response_model=PaginatedResponse) async def list_runs( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, page: int = Query(1, ge=1), @@ -475,7 +475,7 @@ async def list_runs( @router.get("/runs/{run_id}", response_model=AgentRunResponse) -async def get_run(org_id: UUID, run_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_run(org_id: VerifiedOrgId, run_id: UUID, db: DB, current_user: AnyInternalUser): result = await db.execute( select(AgentRun).where(AgentRun.id == run_id, AgentRun.org_id == org_id) ) diff --git a/backend/app/api/v1/audit_logs.py b/backend/app/api/v1/audit_logs.py index f5762c9..ca36bd9 100644 --- a/backend/app/api/v1/audit_logs.py +++ b/backend/app/api/v1/audit_logs.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, AnyInternalUser, AdminUser +from app.core.dependencies import DB, AnyInternalUser, AdminUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.audit_log import AuditLogResponse, AuditLogStatsResponse from app.services import audit_log_service @@ -15,7 +15,7 @@ @router.get("", response_model=PaginatedResponse) async def list_audit_logs( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AdminUser, entity_type: str | None = None, @@ -41,5 +41,5 @@ async def list_audit_logs( @router.get("/stats", response_model=AuditLogStatsResponse) -async def get_stats(org_id: UUID, db: DB, current_user: AdminUser): +async def get_stats(org_id: VerifiedOrgId, db: DB, current_user: AdminUser): return await audit_log_service.get_audit_log_stats(db, org_id) diff --git a/backend/app/api/v1/audits.py b/backend/app/api/v1/audits.py index 263a449..d8095a9 100644 --- a/backend/app/api/v1/audits.py +++ b/backend/app/api/v1/audits.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.audit import ( AuditCreate, AuditUpdate, AuditResponse, @@ -20,7 +20,7 @@ @router.get("", response_model=PaginatedResponse) async def list_audits( - org_id: UUID, db: DB, current_user: AnyInternalUser, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), ): items, total = await audit_service.list_audits(db, org_id, page, page_size) @@ -32,44 +32,44 @@ async def list_audits( @router.post("", response_model=AuditResponse, status_code=201) -async def create_audit(org_id: UUID, data: AuditCreate, db: DB, current_user: ComplianceUser): +async def create_audit(org_id: VerifiedOrgId, data: AuditCreate, db: DB, current_user: ComplianceUser): return await audit_service.create_audit(db, org_id, data) @router.get("/readiness", response_model=ReadinessScoreResponse) -async def get_readiness_score(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_readiness_score(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await audit_service.compute_readiness_score(db, org_id) @router.get("/{audit_id}", response_model=AuditResponse) -async def get_audit(org_id: UUID, audit_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_audit(org_id: VerifiedOrgId, audit_id: UUID, db: DB, current_user: AnyInternalUser): return await audit_service.get_audit(db, org_id, audit_id) @router.patch("/{audit_id}", response_model=AuditResponse) async def update_audit( - org_id: UUID, audit_id: UUID, data: AuditUpdate, + org_id: VerifiedOrgId, audit_id: UUID, data: AuditUpdate, db: DB, current_user: ComplianceUser, ): return await audit_service.update_audit(db, org_id, audit_id, data) @router.delete("/{audit_id}", status_code=204) -async def delete_audit(org_id: UUID, audit_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_audit(org_id: VerifiedOrgId, audit_id: UUID, db: DB, current_user: ComplianceUser): await audit_service.delete_audit(db, org_id, audit_id) # --- Findings --- @router.get("/{audit_id}/findings", response_model=list[FindingResponse]) async def list_findings( - org_id: UUID, audit_id: UUID, db: DB, current_user: AnyInternalUser + org_id: VerifiedOrgId, audit_id: UUID, db: DB, current_user: AnyInternalUser ): return await audit_service.list_findings(db, audit_id, org_id) @router.post("/{audit_id}/findings", response_model=FindingResponse, status_code=201) async def create_finding( - org_id: UUID, audit_id: UUID, data: FindingCreate, + org_id: VerifiedOrgId, audit_id: UUID, data: FindingCreate, db: DB, current_user: ComplianceUser, ): return await audit_service.create_finding(db, org_id, audit_id, data) @@ -77,7 +77,7 @@ async def create_finding( @router.patch("/{audit_id}/findings/{finding_id}", response_model=FindingResponse) async def update_finding( - org_id: UUID, audit_id: UUID, finding_id: UUID, + org_id: VerifiedOrgId, audit_id: UUID, finding_id: UUID, data: FindingUpdate, db: DB, current_user: ComplianceUser, ): return await audit_service.update_finding(db, org_id, finding_id, data) @@ -86,14 +86,14 @@ async def update_finding( # --- Access Tokens --- @router.get("/{audit_id}/tokens", response_model=list[TokenResponse]) async def list_tokens( - org_id: UUID, audit_id: UUID, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, audit_id: UUID, db: DB, current_user: ComplianceUser ): return await auditor_access_service.list_tokens(db, audit_id) @router.post("/{audit_id}/tokens", response_model=TokenResponse, status_code=201) async def create_token( - org_id: UUID, audit_id: UUID, data: TokenCreate, + org_id: VerifiedOrgId, audit_id: UUID, data: TokenCreate, db: DB, current_user: ComplianceUser, ): token_model, raw_token = await auditor_access_service.create_access_token( @@ -106,7 +106,7 @@ async def create_token( @router.delete("/{audit_id}/tokens/{token_id}", status_code=204) async def revoke_token( - org_id: UUID, audit_id: UUID, token_id: UUID, + org_id: VerifiedOrgId, audit_id: UUID, token_id: UUID, db: DB, current_user: ComplianceUser, ): await auditor_access_service.revoke_token(db, org_id, audit_id, token_id) @@ -115,7 +115,7 @@ async def revoke_token( # --- Evidence Package --- @router.get("/{audit_id}/evidence-package") async def get_evidence_package( - org_id: UUID, audit_id: UUID, db: DB, current_user: AnyInternalUser + org_id: VerifiedOrgId, audit_id: UUID, db: DB, current_user: AnyInternalUser ): await audit_service.get_audit(db, org_id, audit_id) # verify exists return await audit_service.generate_evidence_package(db, org_id) diff --git a/backend/app/api/v1/controls.py b/backend/app/api/v1/controls.py index ef088b1..81b9b91 100644 --- a/backend/app/api/v1/controls.py +++ b/backend/app/api/v1/controls.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.audit_middleware import log_audit +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse, MessageResponse from app.schemas.control import ( BulkApproveRequest, @@ -30,7 +31,7 @@ def _serialize_control(control) -> ControlResponse: @router.get("", response_model=PaginatedResponse) async def list_controls( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, status: str | None = None, @@ -51,36 +52,42 @@ async def list_controls( @router.post("", response_model=ControlResponse, status_code=201) -async def create_control(org_id: UUID, data: ControlCreate, db: DB, current_user: ComplianceUser): - return await control_service.create_control(db, org_id, data) +async def create_control(org_id: VerifiedOrgId, data: ControlCreate, db: DB, current_user: ComplianceUser): + item = await control_service.create_control(db, org_id, data) + await log_audit(db, current_user, "create", "control", str(item.id), org_id) + return item @router.get("/stats", response_model=ControlStatsResponse) -async def get_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await control_service.get_control_stats(db, org_id) @router.get("/{control_id}", response_model=ControlResponse) -async def get_control(org_id: UUID, control_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_control(org_id: VerifiedOrgId, control_id: UUID, db: DB, current_user: AnyInternalUser): control = await control_service.get_control(db, org_id, control_id) return _serialize_control(control) @router.patch("/{control_id}", response_model=ControlResponse) async def update_control( - org_id: UUID, control_id: UUID, data: ControlUpdate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, control_id: UUID, data: ControlUpdate, db: DB, current_user: ComplianceUser ): - return await control_service.update_control(db, org_id, control_id, data) + item = await control_service.update_control(db, org_id, control_id, data) + await log_audit(db, current_user, "update", "control", str(control_id), org_id) + return item @router.delete("/{control_id}", status_code=204) -async def delete_control(org_id: UUID, control_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_control(org_id: VerifiedOrgId, control_id: UUID, db: DB, current_user: ComplianceUser): await control_service.delete_control(db, org_id, control_id) + await log_audit(db, current_user, "delete", "control", str(control_id), org_id) @router.post("/bulk-approve", response_model=MessageResponse) -async def bulk_approve(org_id: UUID, data: BulkApproveRequest, db: DB, current_user: ComplianceUser): +async def bulk_approve(org_id: VerifiedOrgId, data: BulkApproveRequest, db: DB, current_user: ComplianceUser): count = await control_service.bulk_approve_controls( db, org_id, data.control_ids, data.status ) + await log_audit(db, current_user, "bulk_approve", "control", ",".join(str(i) for i in data.control_ids), org_id) return MessageResponse(message=f"Successfully updated {count} controls") diff --git a/backend/app/api/v1/embeddings.py b/backend/app/api/v1/embeddings.py index dd6634d..6d74010 100644 --- a/backend/app/api/v1/embeddings.py +++ b/backend/app/api/v1/embeddings.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Query from pydantic import BaseModel -from app.core.dependencies import DB, AnyInternalUser, ComplianceUser +from app.core.dependencies import DB, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import MessageResponse from app.services import embedding_service @@ -22,7 +22,7 @@ class SearchRequest(BaseModel): @router.post("/search") async def search_similar( - org_id: UUID, data: SearchRequest, db: DB, current_user: AnyInternalUser, + org_id: VerifiedOrgId, data: SearchRequest, db: DB, current_user: AnyInternalUser, ): """Semantic search across controls, policies, evidence, and risks.""" return await embedding_service.search_similar( @@ -35,7 +35,7 @@ async def search_similar( @router.post("/index/{entity_type}", response_model=MessageResponse) async def index_entities( - org_id: UUID, entity_type: str, db: DB, current_user: ComplianceUser, + org_id: VerifiedOrgId, entity_type: str, db: DB, current_user: ComplianceUser, ): """Bulk-index all entities of a given type for semantic search.""" count = await embedding_service.index_entities(db, org_id, entity_type) diff --git a/backend/app/api/v1/evidence.py b/backend/app/api/v1/evidence.py index 3c3e3f3..bc935a8 100644 --- a/backend/app/api/v1/evidence.py +++ b/backend/app/api/v1/evidence.py @@ -4,7 +4,8 @@ from fastapi import APIRouter, Query, UploadFile, File from fastapi.responses import RedirectResponse -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.audit_middleware import log_audit +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.core.exceptions import BadRequestError from app.schemas.common import PaginatedResponse from app.schemas.evidence import EvidenceCreate, EvidenceResponse @@ -15,7 +16,7 @@ @router.get("", response_model=PaginatedResponse) async def list_evidence( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, control_id: UUID | None = None, @@ -35,18 +36,20 @@ async def list_evidence( @router.post("", response_model=EvidenceResponse, status_code=201) -async def create_evidence(org_id: UUID, data: EvidenceCreate, db: DB, current_user: ComplianceUser): - return await evidence_service.create_evidence(db, org_id, data) +async def create_evidence(org_id: VerifiedOrgId, data: EvidenceCreate, db: DB, current_user: ComplianceUser): + item = await evidence_service.create_evidence(db, org_id, data) + await log_audit(db, current_user, "create", "evidence", str(item.id), org_id) + return item @router.get("/{evidence_id}", response_model=EvidenceResponse) -async def get_evidence(org_id: UUID, evidence_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_evidence(org_id: VerifiedOrgId, evidence_id: UUID, db: DB, current_user: AnyInternalUser): return await evidence_service.get_evidence(db, org_id, evidence_id) @router.post("/{evidence_id}/upload", response_model=EvidenceResponse) async def upload_evidence_file( - org_id: UUID, + org_id: VerifiedOrgId, evidence_id: UUID, db: DB, current_user: ComplianceUser, @@ -83,12 +86,13 @@ async def upload_evidence_file( await db.commit() await db.refresh(evidence) + await log_audit(db, current_user, "upload_file", "evidence", str(evidence_id), org_id) return evidence @router.get("/{evidence_id}/download") async def download_evidence_file( - org_id: UUID, evidence_id: UUID, db: DB, current_user: AnyInternalUser + org_id: VerifiedOrgId, evidence_id: UUID, db: DB, current_user: AnyInternalUser ): """Download an evidence file via presigned URL redirect.""" from app.core.storage import get_presigned_url diff --git a/backend/app/api/v1/gap_analysis.py b/backend/app/api/v1/gap_analysis.py index 1b79080..18d47d2 100644 --- a/backend/app/api/v1/gap_analysis.py +++ b/backend/app/api/v1/gap_analysis.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.core.dependencies import DB, AnyInternalUser +from app.core.dependencies import DB, AnyInternalUser, VerifiedOrgId from app.services import gap_analysis_service router = APIRouter( @@ -13,7 +13,7 @@ @router.get("/framework/{framework_id}") async def get_gap_analysis( - org_id: UUID, framework_id: UUID, db: DB, current_user: AnyInternalUser, + org_id: VerifiedOrgId, framework_id: UUID, db: DB, current_user: AnyInternalUser, ): """Get gap analysis for a specific framework — shows covered, partial, and missing requirements.""" return await gap_analysis_service.get_gap_analysis(db, org_id, framework_id) @@ -21,7 +21,7 @@ async def get_gap_analysis( @router.get("/cross-framework") async def get_cross_framework_matrix( - org_id: UUID, db: DB, current_user: AnyInternalUser, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, ): """Get cross-framework control mapping matrix with deduplication opportunities.""" return await gap_analysis_service.get_cross_framework_matrix(db, org_id) diff --git a/backend/app/api/v1/incidents.py b/backend/app/api/v1/incidents.py index 7866e88..9b2dedf 100644 --- a/backend/app/api/v1/incidents.py +++ b/backend/app/api/v1/incidents.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.audit_middleware import log_audit +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.incident import ( IncidentCreate, @@ -22,7 +23,7 @@ @router.get("", response_model=PaginatedResponse) async def list_incidents( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, status: str | None = None, @@ -43,39 +44,46 @@ async def list_incidents( @router.post("", response_model=IncidentResponse, status_code=201) -async def create_incident(org_id: UUID, data: IncidentCreate, db: DB, current_user: ComplianceUser): - return await incident_service.create_incident(db, org_id, data) +async def create_incident(org_id: VerifiedOrgId, data: IncidentCreate, db: DB, current_user: ComplianceUser): + item = await incident_service.create_incident(db, org_id, data) + await log_audit(db, current_user, "create", "incident", str(item.id), org_id) + return item @router.get("/stats", response_model=IncidentStatsResponse) -async def get_incident_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_incident_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await incident_service.get_incident_stats(db, org_id) @router.get("/{incident_id}", response_model=IncidentResponse) -async def get_incident(org_id: UUID, incident_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_incident(org_id: VerifiedOrgId, incident_id: UUID, db: DB, current_user: AnyInternalUser): return await incident_service.get_incident(db, org_id, incident_id) @router.patch("/{incident_id}", response_model=IncidentResponse) async def update_incident( - org_id: UUID, incident_id: UUID, data: IncidentUpdate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, incident_id: UUID, data: IncidentUpdate, db: DB, current_user: ComplianceUser ): - return await incident_service.update_incident(db, org_id, incident_id, data, actor_id=current_user.id) + item = await incident_service.update_incident(db, org_id, incident_id, data, actor_id=current_user.id) + await log_audit(db, current_user, "update", "incident", str(incident_id), org_id) + return item @router.delete("/{incident_id}", status_code=204) -async def delete_incident(org_id: UUID, incident_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_incident(org_id: VerifiedOrgId, incident_id: UUID, db: DB, current_user: ComplianceUser): await incident_service.delete_incident(db, org_id, incident_id) + await log_audit(db, current_user, "delete", "incident", str(incident_id), org_id) @router.post("/{incident_id}/timeline", response_model=TimelineEventResponse, status_code=201) async def add_timeline_event( - org_id: UUID, incident_id: UUID, data: TimelineEventCreate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, incident_id: UUID, data: TimelineEventCreate, db: DB, current_user: ComplianceUser ): - return await incident_service.add_timeline_event(db, org_id, incident_id, data, actor_id=current_user.id) + item = await incident_service.add_timeline_event(db, org_id, incident_id, data, actor_id=current_user.id) + await log_audit(db, current_user, "add_timeline_event", "incident", str(incident_id), org_id) + return item @router.get("/{incident_id}/timeline", response_model=list[TimelineEventResponse]) -async def get_timeline(org_id: UUID, incident_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_timeline(org_id: VerifiedOrgId, incident_id: UUID, db: DB, current_user: AnyInternalUser): return await incident_service.get_timeline(db, org_id, incident_id) diff --git a/backend/app/api/v1/integrations.py b/backend/app/api/v1/integrations.py index 871588f..e7b9048 100644 --- a/backend/app/api/v1/integrations.py +++ b/backend/app/api/v1/integrations.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.audit_middleware import log_audit +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.integration import ( IntegrationCreate, @@ -42,13 +43,13 @@ @router.get("/providers", response_model=list[ProviderInfo]) -async def list_providers(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def list_providers(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return PROVIDERS @router.get("", response_model=PaginatedResponse) async def list_integrations( - org_id: UUID, db: DB, current_user: AnyInternalUser, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), ): items, total = await integration_service.list_integrations(db, org_id, page, page_size) @@ -61,36 +62,41 @@ async def list_integrations( @router.post("", response_model=IntegrationResponse, status_code=201) async def create_integration( - org_id: UUID, data: IntegrationCreate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: IntegrationCreate, db: DB, current_user: ComplianceUser ): - return await integration_service.create_integration(db, org_id, data) + item = await integration_service.create_integration(db, org_id, data) + await log_audit(db, current_user, "create", "integration", str(item.id), org_id) + return item @router.get("/{integration_id}", response_model=IntegrationResponse) async def get_integration( - org_id: UUID, integration_id: UUID, db: DB, current_user: AnyInternalUser + org_id: VerifiedOrgId, integration_id: UUID, db: DB, current_user: AnyInternalUser ): return await integration_service.get_integration(db, org_id, integration_id) @router.patch("/{integration_id}", response_model=IntegrationResponse) async def update_integration( - org_id: UUID, integration_id: UUID, data: IntegrationUpdate, + org_id: VerifiedOrgId, integration_id: UUID, data: IntegrationUpdate, db: DB, current_user: ComplianceUser, ): - return await integration_service.update_integration(db, org_id, integration_id, data) + item = await integration_service.update_integration(db, org_id, integration_id, data) + await log_audit(db, current_user, "update", "integration", str(integration_id), org_id) + return item @router.delete("/{integration_id}", status_code=204) async def delete_integration( - org_id: UUID, integration_id: UUID, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, integration_id: UUID, db: DB, current_user: ComplianceUser ): await integration_service.delete_integration(db, org_id, integration_id) + await log_audit(db, current_user, "delete", "integration", str(integration_id), org_id) @router.post("/{integration_id}/test") async def test_integration( - org_id: UUID, integration_id: UUID, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, integration_id: UUID, db: DB, current_user: ComplianceUser ): integration = await integration_service.get_integration(db, org_id, integration_id) return {"status": "ok", "provider": integration.provider, "message": "Connection test successful"} @@ -98,15 +104,17 @@ async def test_integration( @router.post("/{integration_id}/collect", response_model=CollectionJobResponse) async def trigger_collection( - org_id: UUID, integration_id: UUID, data: CollectionTrigger, + org_id: VerifiedOrgId, integration_id: UUID, data: CollectionTrigger, db: DB, current_user: ComplianceUser, ): - return await collection_service.trigger_collection(db, org_id, integration_id, data) + job = await collection_service.trigger_collection(db, org_id, integration_id, data) + await log_audit(db, current_user, "trigger_collection", "integration", str(integration_id), org_id) + return job @router.get("/{integration_id}/jobs", response_model=PaginatedResponse) async def list_collection_jobs( - org_id: UUID, integration_id: UUID, db: DB, current_user: AnyInternalUser, + org_id: VerifiedOrgId, integration_id: UUID, db: DB, current_user: AnyInternalUser, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), ): items, total = await collection_service.list_collection_jobs( diff --git a/backend/app/api/v1/monitoring.py b/backend/app/api/v1/monitoring.py index 4ef8506..9cbdb35 100644 --- a/backend/app/api/v1/monitoring.py +++ b/backend/app/api/v1/monitoring.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.audit_middleware import log_audit +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.monitoring import ( MonitorRuleCreate, @@ -24,7 +25,7 @@ @router.get("/rules", response_model=PaginatedResponse) async def list_rules( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, check_type: str | None = None, @@ -45,34 +46,39 @@ async def list_rules( @router.post("/rules", response_model=MonitorRuleResponse, status_code=201) -async def create_rule(org_id: UUID, data: MonitorRuleCreate, db: DB, current_user: ComplianceUser): - return await monitoring_service.create_rule(db, org_id, data) +async def create_rule(org_id: VerifiedOrgId, data: MonitorRuleCreate, db: DB, current_user: ComplianceUser): + item = await monitoring_service.create_rule(db, org_id, data) + await log_audit(db, current_user, "create", "monitor_rule", str(item.id), org_id) + return item @router.get("/rules/{rule_id}", response_model=MonitorRuleResponse) -async def get_rule(org_id: UUID, rule_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_rule(org_id: VerifiedOrgId, rule_id: UUID, db: DB, current_user: AnyInternalUser): return await monitoring_service.get_rule(db, org_id, rule_id) @router.patch("/rules/{rule_id}", response_model=MonitorRuleResponse) async def update_rule( - org_id: UUID, rule_id: UUID, data: MonitorRuleUpdate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, rule_id: UUID, data: MonitorRuleUpdate, db: DB, current_user: ComplianceUser ): - return await monitoring_service.update_rule(db, org_id, rule_id, data) + item = await monitoring_service.update_rule(db, org_id, rule_id, data) + await log_audit(db, current_user, "update", "monitor_rule", str(rule_id), org_id) + return item @router.delete("/rules/{rule_id}", status_code=204) -async def delete_rule(org_id: UUID, rule_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_rule(org_id: VerifiedOrgId, rule_id: UUID, db: DB, current_user: ComplianceUser): await monitoring_service.delete_rule(db, org_id, rule_id) + await log_audit(db, current_user, "delete", "monitor_rule", str(rule_id), org_id) @router.post("/rules/{rule_id}/run", response_model=list[MonitorAlertResponse]) -async def run_rule(org_id: UUID, rule_id: UUID, db: DB, current_user: ComplianceUser): +async def run_rule(org_id: VerifiedOrgId, rule_id: UUID, db: DB, current_user: ComplianceUser): return await monitoring_service.run_checks(db, org_id, rule_id) @router.get("/stats", response_model=MonitoringStatsResponse) -async def get_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await monitoring_service.get_monitoring_stats(db, org_id) @@ -80,7 +86,7 @@ async def get_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): @router.get("/alerts", response_model=PaginatedResponse) async def list_alerts( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, status: str | None = None, @@ -104,6 +110,8 @@ async def list_alerts( @router.patch("/alerts/{alert_id}", response_model=MonitorAlertResponse) async def update_alert( - org_id: UUID, alert_id: UUID, data: MonitorAlertUpdate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, alert_id: UUID, data: MonitorAlertUpdate, db: DB, current_user: ComplianceUser ): - return await monitoring_service.update_alert(db, org_id, alert_id, data, user_id=current_user.id) + item = await monitoring_service.update_alert(db, org_id, alert_id, data, user_id=current_user.id) + await log_audit(db, current_user, "update", "monitor_alert", str(alert_id), org_id) + return item diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py index 1b14d25..c652240 100644 --- a/backend/app/api/v1/notifications.py +++ b/backend/app/api/v1/notifications.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, AnyInternalUser, AdminUser +from app.core.dependencies import DB, AnyInternalUser, AdminUser, VerifiedOrgId from app.schemas.common import PaginatedResponse, MessageResponse from app.schemas.notification import ( NotificationCreate, @@ -21,7 +21,7 @@ @router.get("", response_model=PaginatedResponse) async def list_notifications( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, is_read: bool | None = None, @@ -44,13 +44,13 @@ async def list_notifications( @router.post("", response_model=NotificationResponse, status_code=201) async def create_notification( - org_id: UUID, data: NotificationCreate, db: DB, current_user: AdminUser, + org_id: VerifiedOrgId, data: NotificationCreate, db: DB, current_user: AdminUser, ): return await notification_service.create_notification(db, org_id, data) @router.get("/stats", response_model=NotificationStatsResponse) -async def get_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await notification_service.get_notification_stats( db, org_id, user_id=current_user.id ) @@ -58,13 +58,13 @@ async def get_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): @router.post("/{notification_id}/read", response_model=NotificationResponse) async def mark_read( - org_id: UUID, notification_id: UUID, db: DB, current_user: AnyInternalUser, + org_id: VerifiedOrgId, notification_id: UUID, db: DB, current_user: AnyInternalUser, ): return await notification_service.mark_read(db, org_id, notification_id) @router.post("/read-all", response_model=MessageResponse) -async def mark_all_read(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def mark_all_read(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): count = await notification_service.mark_all_read(db, org_id, user_id=current_user.id) return MessageResponse(message=f"Marked {count} notifications as read") @@ -73,7 +73,7 @@ async def mark_all_read(org_id: UUID, db: DB, current_user: AnyInternalUser): @router.post("/slack", response_model=SlackWebhookResponse, status_code=201) async def configure_slack( - org_id: UUID, data: SlackWebhookCreate, db: DB, current_user: AdminUser, + org_id: VerifiedOrgId, data: SlackWebhookCreate, db: DB, current_user: AdminUser, ): return await notification_service.configure_slack( db, org_id, data.webhook_url, data.channel_name, data.categories @@ -81,5 +81,5 @@ async def configure_slack( @router.get("/slack", response_model=SlackWebhookResponse | None) -async def get_slack_config(org_id: UUID, db: DB, current_user: AdminUser): +async def get_slack_config(org_id: VerifiedOrgId, db: DB, current_user: AdminUser): return await notification_service.get_slack_config(db, org_id) diff --git a/backend/app/api/v1/onboarding.py b/backend/app/api/v1/onboarding.py index 04a3b14..b69ecb0 100644 --- a/backend/app/api/v1/onboarding.py +++ b/backend/app/api/v1/onboarding.py @@ -3,7 +3,7 @@ from fastapi import APIRouter -from app.core.dependencies import DB, CurrentUser, ComplianceUser +from app.core.dependencies import DB, CurrentUser, ComplianceUser, VerifiedOrgId from app.schemas.onboarding import OnboardingWizardInput, OnboardingSessionResponse from app.services import onboarding_service @@ -15,7 +15,7 @@ @router.post("/start", response_model=OnboardingSessionResponse, status_code=201) async def start_onboarding( - org_id: UUID, data: OnboardingWizardInput, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: OnboardingWizardInput, db: DB, current_user: ComplianceUser ): session = await onboarding_service.start_onboarding(db, org_id, data) @@ -35,11 +35,11 @@ async def _run_pipeline(session_id: str, org_id: str): @router.get("/status/{session_id}", response_model=OnboardingSessionResponse) async def get_onboarding_status( - org_id: UUID, session_id: UUID, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, session_id: UUID, db: DB, current_user: ComplianceUser ): return await onboarding_service.get_session(db, org_id, session_id) @router.get("/latest", response_model=OnboardingSessionResponse | None) -async def get_latest_onboarding(org_id: UUID, db: DB, current_user: ComplianceUser): +async def get_latest_onboarding(org_id: VerifiedOrgId, db: DB, current_user: ComplianceUser): return await onboarding_service.get_latest_session(db, org_id) diff --git a/backend/app/api/v1/organizations.py b/backend/app/api/v1/organizations.py index cec4540..6ee85c6 100644 --- a/backend/app/api/v1/organizations.py +++ b/backend/app/api/v1/organizations.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.core.dependencies import DB, CurrentUser, AdminUser +from app.core.dependencies import DB, CurrentUser, AdminUser, VerifiedOrgId from app.schemas.organization import ( OrganizationCreate, OrganizationResponse, @@ -19,10 +19,10 @@ async def create_org(data: OrganizationCreate, db: DB, current_user: AdminUser): @router.get("/{org_id}", response_model=OrganizationResponse) -async def get_org(org_id: UUID, db: DB, current_user: AdminUser): +async def get_org(org_id: VerifiedOrgId, db: DB, current_user: AdminUser): return await organization_service.get_organization(db, org_id) @router.patch("/{org_id}", response_model=OrganizationResponse) -async def update_org(org_id: UUID, data: OrganizationUpdate, db: DB, current_user: AdminUser): +async def update_org(org_id: VerifiedOrgId, data: OrganizationUpdate, db: DB, current_user: AdminUser): return await organization_service.update_organization(db, org_id, data) diff --git a/backend/app/api/v1/policies.py b/backend/app/api/v1/policies.py index 48f8081..c82a5fa 100644 --- a/backend/app/api/v1/policies.py +++ b/backend/app/api/v1/policies.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.audit_middleware import log_audit +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.policy import ( PolicyCreate, @@ -17,7 +18,7 @@ @router.get("", response_model=PaginatedResponse) async def list_policies( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, status: str | None = None, @@ -38,39 +39,44 @@ async def list_policies( @router.post("", response_model=PolicyResponse, status_code=201) async def create_policy( - org_id: UUID, data: PolicyCreate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, data: PolicyCreate, db: DB, current_user: ComplianceUser ): - return await policy_service.create_policy(db, org_id, data) + item = await policy_service.create_policy(db, org_id, data) + await log_audit(db, current_user, "create", "policy", str(item.id), org_id) + return item @router.get("/stats", response_model=PolicyStatsResponse) -async def get_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await policy_service.get_policy_stats(db, org_id) @router.get("/{policy_id}", response_model=PolicyResponse) async def get_policy( - org_id: UUID, policy_id: UUID, db: DB, current_user: AnyInternalUser + org_id: VerifiedOrgId, policy_id: UUID, db: DB, current_user: AnyInternalUser ): return await policy_service.get_policy(db, org_id, policy_id) @router.patch("/{policy_id}", response_model=PolicyResponse) async def update_policy( - org_id: UUID, + org_id: VerifiedOrgId, policy_id: UUID, data: PolicyUpdate, db: DB, current_user: ComplianceUser, ): - return await policy_service.update_policy(db, org_id, policy_id, data) + item = await policy_service.update_policy(db, org_id, policy_id, data) + await log_audit(db, current_user, "update", "policy", str(policy_id), org_id) + return item @router.delete("/{policy_id}", status_code=204) async def delete_policy( - org_id: UUID, policy_id: UUID, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, policy_id: UUID, db: DB, current_user: ComplianceUser ): await policy_service.delete_policy(db, org_id, policy_id) + await log_audit(db, current_user, "delete", "policy", str(policy_id), org_id) # --------------------------------------------------------------------------- @@ -80,31 +86,39 @@ async def delete_policy( @router.post("/{policy_id}/submit-for-review", response_model=PolicyResponse) async def submit_for_review( - org_id: UUID, policy_id: UUID, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, policy_id: UUID, db: DB, current_user: ComplianceUser ): """Submit a draft policy for review.""" - return await policy_service.submit_for_review(db, org_id, policy_id, current_user.id) + item = await policy_service.submit_for_review(db, org_id, policy_id, current_user.id) + await log_audit(db, current_user, "submit_for_review", "policy", str(policy_id), org_id) + return item @router.post("/{policy_id}/approve", response_model=PolicyResponse) async def approve_policy( - org_id: UUID, policy_id: UUID, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, policy_id: UUID, db: DB, current_user: ComplianceUser ): """Approve a policy that is in review.""" - return await policy_service.approve_policy(db, org_id, policy_id, current_user.id) + item = await policy_service.approve_policy(db, org_id, policy_id, current_user.id) + await log_audit(db, current_user, "approve", "policy", str(policy_id), org_id) + return item @router.post("/{policy_id}/publish", response_model=PolicyResponse) async def publish_policy( - org_id: UUID, policy_id: UUID, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, policy_id: UUID, db: DB, current_user: ComplianceUser ): """Publish an approved policy.""" - return await policy_service.publish_policy(db, org_id, policy_id, current_user.id) + item = await policy_service.publish_policy(db, org_id, policy_id, current_user.id) + await log_audit(db, current_user, "publish", "policy", str(policy_id), org_id) + return item @router.post("/{policy_id}/archive", response_model=PolicyResponse) async def archive_policy( - org_id: UUID, policy_id: UUID, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, policy_id: UUID, db: DB, current_user: ComplianceUser ): """Archive a policy.""" - return await policy_service.archive_policy(db, org_id, policy_id) + item = await policy_service.archive_policy(db, org_id, policy_id) + await log_audit(db, current_user, "archive", "policy", str(policy_id), org_id) + return item diff --git a/backend/app/api/v1/questionnaires.py b/backend/app/api/v1/questionnaires.py index cc9f70a..2732b44 100644 --- a/backend/app/api/v1/questionnaires.py +++ b/backend/app/api/v1/questionnaires.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse, MessageResponse from app.schemas.questionnaire import ( QuestionnaireCreate, @@ -23,7 +23,7 @@ @router.get("", response_model=PaginatedResponse) async def list_questionnaires( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, status: str | None = None, @@ -43,48 +43,48 @@ async def list_questionnaires( @router.post("", response_model=QuestionnaireDetailResponse, status_code=201) -async def create_questionnaire(org_id: UUID, data: QuestionnaireCreate, db: DB, current_user: ComplianceUser): +async def create_questionnaire(org_id: VerifiedOrgId, data: QuestionnaireCreate, db: DB, current_user: ComplianceUser): return await questionnaire_service.create_questionnaire(db, org_id, data) @router.get("/stats", response_model=QuestionnaireStatsResponse) -async def get_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await questionnaire_service.get_questionnaire_stats(db, org_id) @router.get("/{questionnaire_id}", response_model=QuestionnaireDetailResponse) -async def get_questionnaire(org_id: UUID, questionnaire_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_questionnaire(org_id: VerifiedOrgId, questionnaire_id: UUID, db: DB, current_user: AnyInternalUser): return await questionnaire_service.get_questionnaire(db, org_id, questionnaire_id) @router.patch("/{questionnaire_id}", response_model=QuestionnaireDetailResponse) async def update_questionnaire( - org_id: UUID, questionnaire_id: UUID, data: QuestionnaireUpdate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, questionnaire_id: UUID, data: QuestionnaireUpdate, db: DB, current_user: ComplianceUser ): return await questionnaire_service.update_questionnaire(db, org_id, questionnaire_id, data) @router.delete("/{questionnaire_id}", status_code=204) -async def delete_questionnaire(org_id: UUID, questionnaire_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_questionnaire(org_id: VerifiedOrgId, questionnaire_id: UUID, db: DB, current_user: ComplianceUser): await questionnaire_service.delete_questionnaire(db, org_id, questionnaire_id) @router.post("/{questionnaire_id}/auto-fill", response_model=MessageResponse) -async def auto_fill(org_id: UUID, questionnaire_id: UUID, db: DB, current_user: ComplianceUser): +async def auto_fill(org_id: VerifiedOrgId, questionnaire_id: UUID, db: DB, current_user: ComplianceUser): count = await questionnaire_service.auto_fill(db, org_id, questionnaire_id) return MessageResponse(message=f"Auto-filled {count} responses") @router.get("/{questionnaire_id}/responses/{question_id}", response_model=QuestionResponseRead) async def get_response( - org_id: UUID, questionnaire_id: UUID, question_id: str, db: DB, current_user: AnyInternalUser + org_id: VerifiedOrgId, questionnaire_id: UUID, question_id: str, db: DB, current_user: AnyInternalUser ): return await questionnaire_service.get_response(db, org_id, questionnaire_id, question_id) @router.put("/{questionnaire_id}/responses/{question_id}", response_model=QuestionResponseRead) async def upsert_response( - org_id: UUID, questionnaire_id: UUID, question_id: str, + org_id: VerifiedOrgId, questionnaire_id: UUID, question_id: str, data: QuestionResponseCreate, db: DB, current_user: ComplianceUser, ): data.question_id = question_id @@ -93,7 +93,7 @@ async def upsert_response( @router.patch("/{questionnaire_id}/responses/{question_id}/approve", response_model=QuestionResponseRead) async def approve_response( - org_id: UUID, questionnaire_id: UUID, question_id: str, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, questionnaire_id: UUID, question_id: str, db: DB, current_user: ComplianceUser ): data = QuestionResponseUpdate(is_approved=True) return await questionnaire_service.update_response( diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py index 17658ac..9a33dbe 100644 --- a/backend/app/api/v1/reports.py +++ b/backend/app/api/v1/reports.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Query from fastapi.responses import RedirectResponse -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.core.exceptions import BadRequestError from app.schemas.common import PaginatedResponse from app.schemas.report import ReportCreate, ReportResponse, ReportStatsResponse @@ -17,7 +17,7 @@ @router.get("", response_model=PaginatedResponse) async def list_reports( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, report_type: str | None = None, @@ -38,27 +38,27 @@ async def list_reports( @router.post("", response_model=ReportResponse, status_code=201) -async def create_report(org_id: UUID, data: ReportCreate, db: DB, current_user: ComplianceUser): +async def create_report(org_id: VerifiedOrgId, data: ReportCreate, db: DB, current_user: ComplianceUser): return await report_service.create_report(db, org_id, data, requested_by_id=current_user.id) @router.get("/stats", response_model=ReportStatsResponse) -async def get_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await report_service.get_report_stats(db, org_id) @router.get("/{report_id}", response_model=ReportResponse) -async def get_report(org_id: UUID, report_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_report(org_id: VerifiedOrgId, report_id: UUID, db: DB, current_user: AnyInternalUser): return await report_service.get_report(db, org_id, report_id) @router.delete("/{report_id}", status_code=204) -async def delete_report(org_id: UUID, report_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_report(org_id: VerifiedOrgId, report_id: UUID, db: DB, current_user: ComplianceUser): await report_service.delete_report(db, org_id, report_id) @router.get("/{report_id}/download") -async def download_report(org_id: UUID, report_id: UUID, db: DB, current_user: AnyInternalUser): +async def download_report(org_id: VerifiedOrgId, report_id: UUID, db: DB, current_user: AnyInternalUser): """Download a rendered report file (PDF/CSV) via presigned URL redirect.""" from app.core.storage import get_presigned_url @@ -85,5 +85,5 @@ async def download_report(org_id: UUID, report_id: UUID, db: DB, current_user: A @router.get("/{report_id}/data") -async def get_report_data(org_id: UUID, report_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_report_data(org_id: VerifiedOrgId, report_id: UUID, db: DB, current_user: AnyInternalUser): return await report_service.generate_report_data(db, org_id, report_id) diff --git a/backend/app/api/v1/risks.py b/backend/app/api/v1/risks.py index 6ce614c..92fc1c5 100644 --- a/backend/app/api/v1/risks.py +++ b/backend/app/api/v1/risks.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.audit_middleware import log_audit +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.risk import ( RiskCreate, @@ -23,7 +24,7 @@ @router.get("", response_model=PaginatedResponse) async def list_risks( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, status: str | None = None, @@ -46,49 +47,57 @@ async def list_risks( @router.post("", response_model=RiskResponse, status_code=201) -async def create_risk(org_id: UUID, data: RiskCreate, db: DB, current_user: ComplianceUser): - return await risk_service.create_risk(db, org_id, data) +async def create_risk(org_id: VerifiedOrgId, data: RiskCreate, db: DB, current_user: ComplianceUser): + item = await risk_service.create_risk(db, org_id, data) + await log_audit(db, current_user, "create", "risk", str(item.id), org_id) + return item @router.get("/stats", response_model=RiskStatsResponse) -async def get_risk_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_risk_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await risk_service.get_risk_stats(db, org_id) @router.get("/matrix", response_model=RiskMatrixResponse) -async def get_risk_matrix(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_risk_matrix(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): cells = await risk_service.get_risk_matrix(db, org_id) return RiskMatrixResponse(cells=cells) @router.get("/{risk_id}", response_model=RiskResponse) -async def get_risk(org_id: UUID, risk_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_risk(org_id: VerifiedOrgId, risk_id: UUID, db: DB, current_user: AnyInternalUser): return await risk_service.get_risk(db, org_id, risk_id) @router.patch("/{risk_id}", response_model=RiskResponse) async def update_risk( - org_id: UUID, risk_id: UUID, data: RiskUpdate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, risk_id: UUID, data: RiskUpdate, db: DB, current_user: ComplianceUser ): - return await risk_service.update_risk(db, org_id, risk_id, data) + item = await risk_service.update_risk(db, org_id, risk_id, data) + await log_audit(db, current_user, "update", "risk", str(risk_id), org_id) + return item @router.delete("/{risk_id}", status_code=204) -async def delete_risk(org_id: UUID, risk_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_risk(org_id: VerifiedOrgId, risk_id: UUID, db: DB, current_user: ComplianceUser): await risk_service.delete_risk(db, org_id, risk_id) + await log_audit(db, current_user, "delete", "risk", str(risk_id), org_id) @router.post("/{risk_id}/controls", response_model=RiskControlMappingResponse, status_code=201) async def add_control_mapping( - org_id: UUID, risk_id: UUID, data: RiskControlMappingCreate, + org_id: VerifiedOrgId, risk_id: UUID, data: RiskControlMappingCreate, db: DB, current_user: ComplianceUser, ): - return await risk_service.add_control_mapping(db, org_id, risk_id, data) + item = await risk_service.add_control_mapping(db, org_id, risk_id, data) + await log_audit(db, current_user, "add_control_mapping", "risk", str(risk_id), org_id) + return item @router.delete("/{risk_id}/controls/{mapping_id}", status_code=204) async def remove_control_mapping( - org_id: UUID, risk_id: UUID, mapping_id: UUID, + org_id: VerifiedOrgId, risk_id: UUID, mapping_id: UUID, db: DB, current_user: ComplianceUser, ): await risk_service.remove_control_mapping(db, org_id, risk_id, mapping_id) + await log_audit(db, current_user, "remove_control_mapping", "risk", str(risk_id), org_id) diff --git a/backend/app/api/v1/training.py b/backend/app/api/v1/training.py index 852362a..a3cd825 100644 --- a/backend/app/api/v1/training.py +++ b/backend/app/api/v1/training.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.audit_middleware import log_audit +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.training import ( TrainingCourseCreate, @@ -25,7 +26,7 @@ @router.get("/courses", response_model=PaginatedResponse) async def list_courses( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, is_active: bool | None = None, @@ -45,32 +46,37 @@ async def list_courses( @router.post("/courses", response_model=TrainingCourseResponse, status_code=201) -async def create_course(org_id: UUID, data: TrainingCourseCreate, db: DB, current_user: ComplianceUser): - return await training_service.create_course(db, org_id, data) +async def create_course(org_id: VerifiedOrgId, data: TrainingCourseCreate, db: DB, current_user: ComplianceUser): + item = await training_service.create_course(db, org_id, data) + await log_audit(db, current_user, "create", "training_course", str(item.id), org_id) + return item @router.get("/courses/{course_id}", response_model=TrainingCourseResponse) -async def get_course(org_id: UUID, course_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_course(org_id: VerifiedOrgId, course_id: UUID, db: DB, current_user: AnyInternalUser): return await training_service.get_course(db, org_id, course_id) @router.patch("/courses/{course_id}", response_model=TrainingCourseResponse) async def update_course( - org_id: UUID, course_id: UUID, data: TrainingCourseUpdate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, course_id: UUID, data: TrainingCourseUpdate, db: DB, current_user: ComplianceUser ): - return await training_service.update_course(db, org_id, course_id, data) + item = await training_service.update_course(db, org_id, course_id, data) + await log_audit(db, current_user, "update", "training_course", str(course_id), org_id) + return item @router.delete("/courses/{course_id}", status_code=204) -async def delete_course(org_id: UUID, course_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_course(org_id: VerifiedOrgId, course_id: UUID, db: DB, current_user: ComplianceUser): await training_service.delete_course(db, org_id, course_id) + await log_audit(db, current_user, "delete", "training_course", str(course_id), org_id) # === Assignments === @router.get("/assignments", response_model=PaginatedResponse) async def list_assignments( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, course_id: UUID | None = None, @@ -91,17 +97,21 @@ async def list_assignments( @router.post("/assignments", response_model=TrainingAssignmentResponse, status_code=201) -async def create_assignment(org_id: UUID, data: TrainingAssignmentCreate, db: DB, current_user: ComplianceUser): - return await training_service.create_assignment(db, org_id, data, assigned_by_id=current_user.id) +async def create_assignment(org_id: VerifiedOrgId, data: TrainingAssignmentCreate, db: DB, current_user: ComplianceUser): + item = await training_service.create_assignment(db, org_id, data, assigned_by_id=current_user.id) + await log_audit(db, current_user, "create", "training_assignment", str(item.id), org_id) + return item @router.get("/assignments/stats", response_model=TrainingStatsResponse) -async def get_training_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_training_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await training_service.get_training_stats(db, org_id) @router.patch("/assignments/{assignment_id}", response_model=TrainingAssignmentResponse) async def update_assignment( - org_id: UUID, assignment_id: UUID, data: TrainingAssignmentUpdate, db: DB, current_user: CurrentUser + org_id: VerifiedOrgId, assignment_id: UUID, data: TrainingAssignmentUpdate, db: DB, current_user: CurrentUser ): - return await training_service.update_assignment(db, org_id, assignment_id, data) + item = await training_service.update_assignment(db, org_id, assignment_id, data) + await log_audit(db, current_user, "update", "training_assignment", str(assignment_id), org_id) + return item diff --git a/backend/app/api/v1/trust_center.py b/backend/app/api/v1/trust_center.py index 4bec3a6..8b4c3ba 100644 --- a/backend/app/api/v1/trust_center.py +++ b/backend/app/api/v1/trust_center.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.trust_center import ( TrustCenterConfigCreate, TrustCenterConfigUpdate, @@ -23,44 +23,44 @@ @router.get("/config", response_model=TrustCenterConfigResponse) -async def get_config(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_config(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await trust_center_service.get_or_create_config(db, org_id) @router.post("/config", response_model=TrustCenterConfigResponse, status_code=201) -async def create_config(org_id: UUID, data: TrustCenterConfigCreate, db: DB, current_user: ComplianceUser): +async def create_config(org_id: VerifiedOrgId, data: TrustCenterConfigCreate, db: DB, current_user: ComplianceUser): return await trust_center_service.get_or_create_config(db, org_id, data) @router.patch("/config", response_model=TrustCenterConfigResponse) -async def update_config(org_id: UUID, data: TrustCenterConfigUpdate, db: DB, current_user: ComplianceUser): +async def update_config(org_id: VerifiedOrgId, data: TrustCenterConfigUpdate, db: DB, current_user: ComplianceUser): return await trust_center_service.update_config(db, org_id, data) @router.get("/documents", response_model=list[TrustCenterDocumentResponse]) -async def list_documents(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def list_documents(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await trust_center_service.list_documents(db, org_id) @router.post("/documents", response_model=TrustCenterDocumentResponse, status_code=201) -async def create_document(org_id: UUID, data: TrustCenterDocumentCreate, db: DB, current_user: ComplianceUser): +async def create_document(org_id: VerifiedOrgId, data: TrustCenterDocumentCreate, db: DB, current_user: ComplianceUser): return await trust_center_service.create_document(db, org_id, data) @router.get("/documents/{doc_id}", response_model=TrustCenterDocumentResponse) -async def get_document(org_id: UUID, doc_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_document(org_id: VerifiedOrgId, doc_id: UUID, db: DB, current_user: AnyInternalUser): return await trust_center_service.get_document(db, org_id, doc_id) @router.patch("/documents/{doc_id}", response_model=TrustCenterDocumentResponse) async def update_document( - org_id: UUID, doc_id: UUID, data: TrustCenterDocumentUpdate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, doc_id: UUID, data: TrustCenterDocumentUpdate, db: DB, current_user: ComplianceUser ): return await trust_center_service.update_document(db, org_id, doc_id, data) @router.delete("/documents/{doc_id}", status_code=204) -async def delete_document(org_id: UUID, doc_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_document(org_id: VerifiedOrgId, doc_id: UUID, db: DB, current_user: ComplianceUser): await trust_center_service.delete_document(db, org_id, doc_id) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index b3db845..008c493 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AdminUser +from app.core.dependencies import DB, CurrentUser, AdminUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.user import UserCreate, UserResponse, UserUpdate from app.services import user_service @@ -12,7 +12,7 @@ @router.get("", response_model=PaginatedResponse) async def list_users( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AdminUser, page: int = Query(1, ge=1), @@ -29,22 +29,22 @@ async def list_users( @router.post("", response_model=UserResponse, status_code=201) -async def create_user(org_id: UUID, data: UserCreate, db: DB, current_user: AdminUser): +async def create_user(org_id: VerifiedOrgId, data: UserCreate, db: DB, current_user: AdminUser): return await user_service.create_user(db, org_id, data) @router.get("/{user_id}", response_model=UserResponse) -async def get_user(org_id: UUID, user_id: UUID, db: DB, current_user: AdminUser): +async def get_user(org_id: VerifiedOrgId, user_id: UUID, db: DB, current_user: AdminUser): return await user_service.get_user(db, org_id, user_id) @router.patch("/{user_id}", response_model=UserResponse) async def update_user( - org_id: UUID, user_id: UUID, data: UserUpdate, db: DB, current_user: AdminUser + org_id: VerifiedOrgId, user_id: UUID, data: UserUpdate, db: DB, current_user: AdminUser ): return await user_service.update_user(db, org_id, user_id, data) @router.delete("/{user_id}", status_code=204) -async def delete_user(org_id: UUID, user_id: UUID, db: DB, current_user: AdminUser): +async def delete_user(org_id: VerifiedOrgId, user_id: UUID, db: DB, current_user: AdminUser): await user_service.delete_user(db, org_id, user_id) diff --git a/backend/app/api/v1/vendors.py b/backend/app/api/v1/vendors.py index 4108b2f..868f9d0 100644 --- a/backend/app/api/v1/vendors.py +++ b/backend/app/api/v1/vendors.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Query -from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser +from app.core.audit_middleware import log_audit +from app.core.dependencies import DB, CurrentUser, AnyInternalUser, ComplianceUser, VerifiedOrgId from app.schemas.common import PaginatedResponse from app.schemas.vendor import ( VendorCreate, @@ -22,7 +23,7 @@ @router.get("", response_model=PaginatedResponse) async def list_vendors( - org_id: UUID, + org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser, risk_tier: str | None = None, @@ -43,39 +44,46 @@ async def list_vendors( @router.post("", response_model=VendorResponse, status_code=201) -async def create_vendor(org_id: UUID, data: VendorCreate, db: DB, current_user: ComplianceUser): - return await vendor_service.create_vendor(db, org_id, data) +async def create_vendor(org_id: VerifiedOrgId, data: VendorCreate, db: DB, current_user: ComplianceUser): + item = await vendor_service.create_vendor(db, org_id, data) + await log_audit(db, current_user, "create", "vendor", str(item.id), org_id) + return item @router.get("/stats", response_model=VendorStatsResponse) -async def get_vendor_stats(org_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_vendor_stats(org_id: VerifiedOrgId, db: DB, current_user: AnyInternalUser): return await vendor_service.get_vendor_stats(db, org_id) @router.get("/{vendor_id}", response_model=VendorResponse) -async def get_vendor(org_id: UUID, vendor_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_vendor(org_id: VerifiedOrgId, vendor_id: UUID, db: DB, current_user: AnyInternalUser): return await vendor_service.get_vendor(db, org_id, vendor_id) @router.patch("/{vendor_id}", response_model=VendorResponse) async def update_vendor( - org_id: UUID, vendor_id: UUID, data: VendorUpdate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, vendor_id: UUID, data: VendorUpdate, db: DB, current_user: ComplianceUser ): - return await vendor_service.update_vendor(db, org_id, vendor_id, data) + item = await vendor_service.update_vendor(db, org_id, vendor_id, data) + await log_audit(db, current_user, "update", "vendor", str(vendor_id), org_id) + return item @router.delete("/{vendor_id}", status_code=204) -async def delete_vendor(org_id: UUID, vendor_id: UUID, db: DB, current_user: ComplianceUser): +async def delete_vendor(org_id: VerifiedOrgId, vendor_id: UUID, db: DB, current_user: ComplianceUser): await vendor_service.delete_vendor(db, org_id, vendor_id) + await log_audit(db, current_user, "delete", "vendor", str(vendor_id), org_id) @router.post("/{vendor_id}/assessments", response_model=VendorAssessmentResponse, status_code=201) async def create_assessment( - org_id: UUID, vendor_id: UUID, data: VendorAssessmentCreate, db: DB, current_user: ComplianceUser + org_id: VerifiedOrgId, vendor_id: UUID, data: VendorAssessmentCreate, db: DB, current_user: ComplianceUser ): - return await vendor_service.create_assessment(db, org_id, vendor_id, data, assessed_by_id=current_user.id) + item = await vendor_service.create_assessment(db, org_id, vendor_id, data, assessed_by_id=current_user.id) + await log_audit(db, current_user, "create_assessment", "vendor", str(vendor_id), org_id) + return item @router.get("/{vendor_id}/assessments", response_model=list[VendorAssessmentResponse]) -async def get_assessments(org_id: UUID, vendor_id: UUID, db: DB, current_user: AnyInternalUser): +async def get_assessments(org_id: VerifiedOrgId, vendor_id: UUID, db: DB, current_user: AnyInternalUser): return await vendor_service.get_assessments(db, org_id, vendor_id) diff --git a/backend/app/config.py b/backend/app/config.py index 4b5c36c..961151d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -32,6 +32,14 @@ class Settings(BaseSettings): LITELLM_MODEL: str = "gpt-4o-mini" OPENAI_API_KEY: str = "" + # SMTP email + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM_EMAIL: str = "notifications@quicktrust.dev" + SMTP_USE_TLS: bool = True + @property def cors_origins_list(self) -> list[str]: return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 96389c2..454c3c4 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -1,5 +1,6 @@ from functools import wraps from typing import Annotated +from uuid import UUID from fastapi import Depends, Header from sqlalchemy import select @@ -45,6 +46,22 @@ async def get_current_user( return user +async def verify_org_access( + org_id: UUID, + current_user: User = Depends(get_current_user), +) -> UUID: + """Verify the authenticated user belongs to the requested organization. + + Super admins can access any organization. All other users must belong + to the organization specified in the URL path. + """ + if current_user.role == SUPER_ADMIN: + return org_id + if str(current_user.org_id) != str(org_id): + raise ForbiddenError("Access denied: you do not belong to this organization") + return org_id + + async def get_optional_user( authorization: Annotated[str | None, Header()] = None, db: AsyncSession = Depends(get_db), @@ -150,3 +167,8 @@ async def __call__( User, Depends(RoleChecker(SUPER_ADMIN, ADMIN, COMPLIANCE_MANAGER)) ] AnyInternalUser = Annotated[User, Depends(RoleChecker(*INTERNAL_ROLES))] + +# --------------------------------------------------------------------------- +# Org-scoped access — validates the org_id path param against the user's org +# --------------------------------------------------------------------------- +VerifiedOrgId = Annotated[UUID, Depends(verify_org_access)] diff --git a/backend/app/main.py b/backend/app/main.py index 903ecfd..d2a07c9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,7 +23,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title=settings.APP_NAME, description="Open-source, agent-first GRC platform", - version="0.5.0", + version="0.6.0", lifespan=lifespan, ) diff --git a/backend/app/services/collection_service.py b/backend/app/services/collection_service.py index 013e0a1..d31b134 100644 --- a/backend/app/services/collection_service.py +++ b/backend/app/services/collection_service.py @@ -47,10 +47,20 @@ async def trigger_collection( await db.refresh(job) return job + # Build credentials dict from the integration's stored reference + credentials = None + if integration.credentials_ref: + # credentials_ref can be a JSON string or a vault reference + import json + try: + credentials = json.loads(integration.credentials_ref) + except (json.JSONDecodeError, TypeError): + credentials = {"ref": integration.credentials_ref} + try: result_data = await collector.collect( config=integration.config or {}, - credentials=None, + credentials=credentials, ) job.result_data = result_data job.status = "completed" diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index ba69b11..ac02f46 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -223,10 +223,46 @@ async def _send_slack(db: AsyncSession, org_id: UUID, notification: Notification async def _send_email(notification: Notification) -> None: - """Placeholder email sender — logs the notification for now.""" - logger.info( - "EMAIL notification [%s]: %s — %s", - notification.severity, - notification.title, - notification.message, - ) + """Send email notification via SMTP. Falls back to logging when SMTP is not configured.""" + from app.config import get_settings + import smtplib + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + + settings = get_settings() + if not settings.SMTP_HOST: + logger.info( + "EMAIL notification [%s]: %s — %s (SMTP not configured)", + notification.severity, + notification.title, + notification.message, + ) + return + + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = f"[{notification.severity.upper()}] {notification.title}" + msg["From"] = settings.SMTP_FROM_EMAIL + msg["To"] = settings.SMTP_FROM_EMAIL # default; per-user routing can be added + + body = f""" +

{notification.title}

+

{notification.message}

+

Severity: {notification.severity} | Category: {notification.category}

+ """ + msg.attach(MIMEText(body, "html")) + + if settings.SMTP_USE_TLS: + server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) + server.starttls() + else: + server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) + + if settings.SMTP_USER: + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + + server.sendmail(settings.SMTP_FROM_EMAIL, [msg["To"]], msg.as_string()) + server.quit() + logger.info("Email sent for notification %s", notification.id) + except Exception as exc: + logger.warning("Failed to send email notification: %s", exc) diff --git a/backend/app/services/policy_service.py b/backend/app/services/policy_service.py index 2ee5569..c7ca3f9 100644 --- a/backend/app/services/policy_service.py +++ b/backend/app/services/policy_service.py @@ -4,6 +4,7 @@ from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession +from app.core.cache import cache_get, cache_set, cache_delete from app.core.exceptions import BadRequestError, NotFoundError from app.models.policy import Policy from app.models.policy_template import PolicyTemplate @@ -35,6 +36,7 @@ async def create_policy(db: AsyncSession, org_id: UUID, data: PolicyCreate) -> P db.add(policy) await db.commit() await db.refresh(policy) + await cache_delete(f"org:{org_id}:policy_stats") return policy @@ -64,9 +66,15 @@ async def delete_policy(db: AsyncSession, org_id: UUID, policy_id: UUID) -> None policy = await get_policy(db, org_id, policy_id) await db.delete(policy) await db.commit() + await cache_delete(f"org:{org_id}:policy_stats") async def get_policy_stats(db: AsyncSession, org_id: UUID) -> PolicyStatsResponse: + cache_key = f"org:{org_id}:policy_stats" + cached = await cache_get(cache_key) + if cached: + return PolicyStatsResponse(**cached) + result = await db.execute( select( func.count().label("total"), @@ -80,7 +88,7 @@ async def get_policy_stats(db: AsyncSession, org_id: UUID) -> PolicyStatsRespons .where(Policy.org_id == org_id) ) row = result.one() - return PolicyStatsResponse( + stats = PolicyStatsResponse( total=row.total, draft=row.draft, in_review=row.in_review, @@ -88,6 +96,8 @@ async def get_policy_stats(db: AsyncSession, org_id: UUID) -> PolicyStatsRespons published=row.published, archived=row.archived, ) + await cache_set(cache_key, stats.model_dump(), ttl=120) + return stats async def list_policy_templates( @@ -135,6 +145,7 @@ async def submit_for_review( policy.status = "in_review" await db.commit() await db.refresh(policy) + await cache_delete(f"org:{org_id}:policy_stats") return policy @@ -152,6 +163,7 @@ async def approve_policy( policy.approved_at = datetime.now(timezone.utc) await db.commit() await db.refresh(policy) + await cache_delete(f"org:{org_id}:policy_stats") return policy @@ -168,6 +180,7 @@ async def publish_policy( policy.published_at = datetime.now(timezone.utc) await db.commit() await db.refresh(policy) + await cache_delete(f"org:{org_id}:policy_stats") return policy @@ -179,4 +192,5 @@ async def archive_policy( policy.status = "archived" await db.commit() await db.refresh(policy) + await cache_delete(f"org:{org_id}:policy_stats") return policy diff --git a/backend/app/services/risk_service.py b/backend/app/services/risk_service.py index 89480f0..dd60a2f 100644 --- a/backend/app/services/risk_service.py +++ b/backend/app/services/risk_service.py @@ -3,6 +3,7 @@ from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession +from app.core.cache import cache_get, cache_set, cache_delete from app.core.exceptions import NotFoundError from app.models.risk import Risk from app.models.risk_control_mapping import RiskControlMapping @@ -69,6 +70,7 @@ async def create_risk(db: AsyncSession, org_id: UUID, data: RiskCreate) -> Risk: db.add(risk) await db.commit() await db.refresh(risk) + await cache_delete(f"org:{org_id}:risk_stats") return risk @@ -97,6 +99,7 @@ async def update_risk(db: AsyncSession, org_id: UUID, risk_id: UUID, data: RiskU await db.commit() await db.refresh(risk) + await cache_delete(f"org:{org_id}:risk_stats") return risk @@ -104,9 +107,15 @@ async def delete_risk(db: AsyncSession, org_id: UUID, risk_id: UUID) -> None: risk = await get_risk(db, org_id, risk_id) await db.delete(risk) await db.commit() + await cache_delete(f"org:{org_id}:risk_stats") async def get_risk_stats(db: AsyncSession, org_id: UUID) -> dict: + cache_key = f"org:{org_id}:risk_stats" + cached = await cache_get(cache_key) + if cached: + return cached + risks_result = await db.execute(select(Risk).where(Risk.org_id == org_id)) risks = list(risks_result.scalars().all()) @@ -119,12 +128,14 @@ async def get_risk_stats(db: AsyncSession, org_id: UUID) -> dict: by_level[r.risk_level] = by_level.get(r.risk_level, 0) + 1 total_score += r.risk_score - return { + stats = { "total": len(risks), "by_status": by_status, "by_risk_level": by_level, "average_score": round(total_score / len(risks), 1) if risks else 0.0, } + await cache_set(cache_key, stats, ttl=120) + return stats async def get_risk_matrix(db: AsyncSession, org_id: UUID) -> list[dict]: diff --git a/frontend/src/app/(dashboard)/evidence/page.tsx b/frontend/src/app/(dashboard)/evidence/page.tsx index 120e560..e4e4c08 100644 --- a/frontend/src/app/(dashboard)/evidence/page.tsx +++ b/frontend/src/app/(dashboard)/evidence/page.tsx @@ -1,13 +1,22 @@ "use client"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { useEvidence } from "@/hooks/use-api"; +import { useEvidence, useCreateEvidence, useUploadEvidence } from "@/hooks/use-api"; import { useOrgId } from "@/hooks/use-org-id"; -import { Shield, FileCheck, AlertTriangle } from "lucide-react"; +import { + Shield, + FileCheck, + AlertTriangle, + Upload, + Plus, + Loader2, + FileUp, + Download, +} from "lucide-react"; const STATUS_FILTERS: { label: string; value: string | undefined }[] = [ { label: "All", value: undefined }, @@ -24,7 +33,10 @@ const METHOD_FILTERS: { label: string; value: string | undefined }[] = [ { label: "API", value: "api" }, ]; -const statusBadgeVariant: Record = { +const statusBadgeVariant: Record< + string, + "default" | "secondary" | "success" | "destructive" | "outline" +> = { pending: "secondary", collected: "success", valid: "success", @@ -32,41 +44,89 @@ const statusBadgeVariant: Record = { - manual: "outline", - automated: "default", - api: "secondary", -}; +const methodBadgeVariant: Record = + { + manual: "outline", + automated: "default", + api: "secondary", + }; export default function EvidencePage() { const orgId = useOrgId(); - const [statusFilter, setStatusFilter] = useState(undefined); - const [methodFilter, setMethodFilter] = useState(undefined); + const [statusFilter, setStatusFilter] = useState( + undefined + ); + const [methodFilter, setMethodFilter] = useState( + undefined + ); + const [showCreate, setShowCreate] = useState(false); + const [createTitle, setCreateTitle] = useState(""); + const [uploadingId, setUploadingId] = useState(null); + const fileInputRef = useRef(null); const { data, isLoading, error } = useEvidence(orgId); + const createEvidence = useCreateEvidence(orgId); + const uploadEvidence = useUploadEvidence(orgId); const allItems = data?.items || []; - // Client-side filtering for status and method const evidenceItems = allItems.filter((item) => { if (statusFilter && item.status !== statusFilter) return false; if (methodFilter && item.collection_method !== methodFilter) return false; return true; }); + function handleCreate() { + if (!createTitle.trim()) return; + createEvidence.mutate( + { title: createTitle, status: "pending", collection_method: "manual" }, + { + onSuccess: () => { + setCreateTitle(""); + setShowCreate(false); + }, + } + ); + } + + function handleUploadClick(evidenceId: string) { + setUploadingId(evidenceId); + fileInputRef.current?.click(); + } + + function handleFileSelected(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file || !uploadingId) return; + uploadEvidence.mutate( + { evidenceId: uploadingId, file }, + { + onSettled: () => { + setUploadingId(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, + } + ); + } + + const apiUrl = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; + if (error) { return (

Evidence Library

-

Track evidence collection status

+

+ Track evidence collection status +

Failed to load evidence

- {error.message || "An unexpected error occurred. Please try again later."} + {error.message || + "An unexpected error occurred. Please try again later."}

+ {/* Create evidence form */} + {showCreate && ( + + +
+ + setCreateTitle(e.target.value)} + /> +
+
+ + +
+
+
+ )} + {/* Filters */}
- {/* Status filters */}
{STATUS_FILTERS.map((f) => (
- - {/* Collection method filters */}
Method: @@ -131,7 +240,10 @@ export default function EvidencePage() { ) : evidenceItems.length > 0 ? (
{evidenceItems.map((evidence) => ( - +
@@ -158,9 +270,46 @@ export default function EvidencePage() { {evidence.collector} )} + {evidence.file_name && ( + <> + · + + {evidence.file_name} + + )}
+ {/* Upload / Download button */} + {evidence.file_url ? ( + + + + ) : ( + + )}

No evidence found

- No evidence items match the current filters, or the evidence library is empty. + No evidence items match the current filters, or the evidence + library is empty.

)} - {/* Pagination info */} {data && data.total_pages > 1 && (

diff --git a/frontend/src/app/(dashboard)/integrations/page.tsx b/frontend/src/app/(dashboard)/integrations/page.tsx index 46cf974..3cf7207 100644 --- a/frontend/src/app/(dashboard)/integrations/page.tsx +++ b/frontend/src/app/(dashboard)/integrations/page.tsx @@ -1,13 +1,24 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { useProviders, useIntegrations } from "@/hooks/use-api"; +import { + useProviders, + useIntegrations, + useCreateIntegration, +} from "@/hooks/use-api"; import { useOrgId } from "@/hooks/use-org-id"; -import { Plug, Unplug, Cable } from "lucide-react"; +import { Plug, Unplug, Cable, Loader2, X, CheckCircle2 } from "lucide-react"; const statusVariant: Record = { connected: "success", @@ -15,14 +26,72 @@ const statusVariant: Record = { error: "destructive", }; +const CREDENTIAL_FIELDS: Record = { + aws: [ + { label: "AWS Access Key ID", placeholder: "AKIA..." }, + { label: "AWS Secret Access Key", placeholder: "wJalr...", type: "password" }, + { label: "AWS Region", placeholder: "us-east-1" }, + ], + github: [ + { label: "GitHub Token", placeholder: "ghp_...", type: "password" }, + { label: "Repository (owner/repo)", placeholder: "acme/backend" }, + ], + okta: [ + { label: "Okta Domain", placeholder: "your-org.okta.com" }, + { label: "API Token", placeholder: "00...", type: "password" }, + ], +}; + export default function IntegrationsPage() { const orgId = useOrgId(); const { data: providers, isLoading: providersLoading } = useProviders(orgId); const { data: integrationsData, isLoading: integrationsLoading } = useIntegrations(orgId); + const createIntegration = useCreateIntegration(orgId); const integrations = integrationsData?.items || []; + const [connectProvider, setConnectProvider] = useState(null); + const [connectName, setConnectName] = useState(""); + const [credFields, setCredFields] = useState>({}); + const [connectSuccess, setConnectSuccess] = useState(false); + + function openConnectModal(providerKey: string) { + setConnectProvider(providerKey); + setConnectName(""); + setCredFields({}); + setConnectSuccess(false); + } + + function handleConnect() { + if (!connectProvider || !connectName.trim()) return; + + const config: Record = {}; + const fields = CREDENTIAL_FIELDS[connectProvider] || []; + for (const f of fields) { + if (credFields[f.label]) { + config[f.label.toLowerCase().replace(/\s+/g, "_")] = credFields[f.label]; + } + } + + createIntegration.mutate( + { provider: connectProvider, name: connectName, config }, + { + onSuccess: () => { + setConnectSuccess(true); + setTimeout(() => { + setConnectProvider(null); + setConnectSuccess(false); + }, 1500); + }, + } + ); + } + + const selectedProvider = providers?.find( + (p) => p.provider === connectProvider + ); + return (

{/* Page header */} @@ -33,6 +102,93 @@ export default function IntegrationsPage() {

+ {/* Connect Modal */} + {connectProvider && selectedProvider && ( + + +
+ + + Connect {selectedProvider.name} + + +
+ {selectedProvider.description} +
+ + {connectSuccess ? ( +
+ +

+ Connected successfully! +

+
+ ) : ( + <> +
+ + setConnectName(e.target.value)} + /> +
+ + {(CREDENTIAL_FIELDS[connectProvider] || []).map((field) => ( +
+ + + setCredFields((prev) => ({ + ...prev, + [field.label]: e.target.value, + })) + } + /> +
+ ))} + +
+ + +
+ + )} +
+
+ )} + {/* Available Providers */}

Available Providers

@@ -61,7 +217,10 @@ export default function IntegrationsPage() { ? "collector" : "collectors"} - @@ -105,7 +264,9 @@ export default function IntegrationsPage() {
{integration.name}
- {integration.provider} + + {integration.provider} + {integration.last_sync_at && ( <> · @@ -133,7 +294,9 @@ export default function IntegrationsPage() { -

No integrations connected

+

+ No integrations connected +

Connect a provider above to start collecting evidence automatically. diff --git a/frontend/src/app/trust/[slug]/page.tsx b/frontend/src/app/trust/[slug]/page.tsx new file mode 100644 index 0000000..8191357 --- /dev/null +++ b/frontend/src/app/trust/[slug]/page.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { Shield, FileText, ExternalLink, CheckCircle2, Lock } from "lucide-react"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; + +interface TrustCenterDoc { + id: string; + title: string; + document_type: string; + description?: string; + url?: string; + is_public: boolean; + requires_nda: boolean; +} + +interface TrustCenterData { + headline: string; + description: string; + certifications: string[]; + contact_email: string; + documents: TrustCenterDoc[]; +} + +export default function PublicTrustCenterPage() { + const params = useParams(); + const slug = params.slug as string; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchTrustCenter() { + try { + const res = await fetch(`${API_URL}/api/v1/trust/${slug}`); + if (!res.ok) { + if (res.status === 404) { + setError("Trust center not found or not published."); + } else { + setError("Failed to load trust center."); + } + return; + } + const json = await res.json(); + setData(json); + } catch { + setError("Failed to connect to the server."); + } finally { + setLoading(false); + } + } + fetchTrustCenter(); + }, [slug]); + + if (loading) { + return ( +

+
+ +

Loading trust center...

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+ +

+ {error || "Trust center not available"} +

+

+ This trust center may not be published yet. +

+
+
+ ); + } + + const publicDocs = data.documents.filter((d) => d.is_public); + const ndaDocs = data.documents.filter((d) => d.requires_nda && !d.is_public); + + return ( +
+ {/* Hero */} +
+
+ +

+ {data.headline || "Security & Compliance"} +

+ {data.description && ( +

+ {data.description} +

+ )} +
+
+ +
+ {/* Certifications */} + {data.certifications.length > 0 && ( +
+

+ Certifications & Compliance +

+
+ {data.certifications.map((cert) => ( +
+ + + {cert} + +
+ ))} +
+
+ )} + + {/* Public Documents */} + {publicDocs.length > 0 && ( +
+

+ Resources +

+
+ {publicDocs.map((doc) => ( +
+ +
+
+ {doc.title} +
+ {doc.description && ( +

+ {doc.description} +

+ )} +
+ + {doc.document_type} + + {doc.url && ( + + + + )} +
+ ))} +
+
+ )} + + {/* NDA-Required Documents */} + {ndaDocs.length > 0 && ( +
+

+ NDA-Required Documents +

+
+ {ndaDocs.map((doc) => ( +
+ +
+
+ {doc.title} +
+

+ Requires NDA — contact us for access +

+
+ + {doc.document_type} + +
+ ))} +
+
+ )} + + {/* Contact */} + {data.contact_email && ( +
+

+ Questions? +

+

+ Contact our security team at{" "} + + {data.contact_email} + +

+
+ )} +
+ +
+ Powered by QuickTrust +
+
+ ); +} diff --git a/frontend/src/hooks/use-api.ts b/frontend/src/hooks/use-api.ts index abbcd08..5550297 100644 --- a/frontend/src/hooks/use-api.ts +++ b/frontend/src/hooks/use-api.ts @@ -174,6 +174,33 @@ export function useEvidence(orgId: string, params?: { control_id?: string; page? }); } +// Evidence Upload +export function useUploadEvidence(orgId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ evidenceId, file }: { evidenceId: string; file: File }) => + api.upload( + `/organizations/${orgId}/evidence/${evidenceId}/upload`, + file + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["evidence", orgId] }); + }, + }); +} + +// Evidence Create +export function useCreateEvidence(orgId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { title: string; status?: string; collection_method?: string; control_id?: string }) => + api.post(`/organizations/${orgId}/evidence`, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["evidence", orgId] }); + }, + }); +} + // Agent Runs export function useAgentRuns(orgId: string) { return useQuery({ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4cf99ec..d28cf4c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -60,6 +60,30 @@ class ApiClient { delete(path: string) { return this.request(path, { method: "DELETE" }); } + + async upload(path: string, file: File, fieldName: string = "file"): Promise { + const formData = new FormData(); + formData.append(fieldName, file); + + const headers: Record = {}; + if (this.token) { + headers["Authorization"] = `Bearer ${this.token}`; + } + // Do NOT set Content-Type — browser sets it with boundary for multipart + + const res = await fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers, + body: formData, + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(error.detail || `Upload error: ${res.status}`); + } + + return res.json(); + } } export const api = new ApiClient(`${API_URL}/api/v1`);