From 27a587ce7e0af5a70e9d81a6b0049cbb38cdfa58 Mon Sep 17 00:00:00 2001 From: Jackson Severino da Rocha Date: Fri, 16 Oct 2026 13:02:43 -0300 Subject: [PATCH 1/3] feat(work-management): implement consultation intake, reads, and triage (#4) --- contracts/schemas/work_management.yaml | 8 + .../src/eventflow_work_management/api/app.py | 4 + .../api/consultation_routes.py | 223 ++++++++ .../0004_consultation_triage_detail.py | 64 +++ .../models/consultation.py | 20 + .../repositories/consultation_repository.py | 116 +++- .../services/consultation.py | 522 ++++++++++++++++++ .../test_consultation_api_integration.py | 393 +++++++++++++ .../tests/test_migrations_integration.py | 4 +- 9 files changed, 1351 insertions(+), 3 deletions(-) create mode 100644 services/work-management/src/eventflow_work_management/api/consultation_routes.py create mode 100644 services/work-management/src/eventflow_work_management/migrations/versions/0004_consultation_triage_detail.py create mode 100644 services/work-management/src/eventflow_work_management/services/consultation.py create mode 100644 services/work-management/tests/test_consultation_api_integration.py diff --git a/contracts/schemas/work_management.yaml b/contracts/schemas/work_management.yaml index a0a6875..848c9ce 100644 --- a/contracts/schemas/work_management.yaml +++ b/contracts/schemas/work_management.yaml @@ -577,6 +577,14 @@ $defs: description: type: string minLength: 5 + category: + anyOf: + - $ref: '#/$defs/TriageCategory' + - type: 'null' + description: >- + Optional reporter-supplied type hint captured at intake. It is a + provisional classification, not a triage decision; an authorized + triage operation may confirm or override it. editor_context: $ref: '#/$defs/EditorContext' external_references: diff --git a/services/work-management/src/eventflow_work_management/api/app.py b/services/work-management/src/eventflow_work_management/api/app.py index 93bbfcf..eadc09c 100644 --- a/services/work-management/src/eventflow_work_management/api/app.py +++ b/services/work-management/src/eventflow_work_management/api/app.py @@ -14,6 +14,9 @@ from sqlalchemy.orm import sessionmaker from eventflow_work_management.api.auth_routes import router as authentication_router +from eventflow_work_management.api.consultation_routes import ( + router as consultation_router, +) from eventflow_work_management.api.errors import ( authentication_dependency_error_handler, authentication_error_handler, @@ -182,5 +185,6 @@ def ready(): app.include_router(authentication_router) app.include_router(workspace_router) + app.include_router(consultation_router) return app diff --git a/services/work-management/src/eventflow_work_management/api/consultation_routes.py b/services/work-management/src/eventflow_work_management/api/consultation_routes.py new file mode 100644 index 0000000..d0c086f --- /dev/null +++ b/services/work-management/src/eventflow_work_management/api/consultation_routes.py @@ -0,0 +1,223 @@ +"""Authenticated consultation intake, read, and triage routes (Issue #4). + +The tenant and actor are derived from the verified principal and the path +locator, never from the request body. Mutating routes require Origin + CSRF for +cookie-authenticated callers and accept an optional ``Idempotency-Key``; +``triage`` additionally honours a strong ``If-Match`` validator. +""" + +from __future__ import annotations + +import re +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, Response +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from eventflow_work_management.api.dependencies import ( + authenticated_principal, + correlation_id, + database_session, + require_csrf, +) +from eventflow_work_management.authorization.policy import Action +from eventflow_work_management.authorization.service import AuthorizationService +from eventflow_work_management.hashing import fingerprint +from eventflow_work_management.models import Consultation +from eventflow_work_management.security.errors import ProblemError +from eventflow_work_management.security.principal import AuthenticatedPrincipal +from eventflow_work_management.services.consultation import ( + ConsultationService, + consultation_etag, +) + +router = APIRouter(tags=["Consultations"]) + +IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9._~-]{8,128}$") +ORIGIN_PATTERN = "^(web|vscode|desktop|external)$" +PRIORITY_PATTERN = "^(critical|high|medium|low)$" +CATEGORY_PATTERN = "^(bug|feature|chore|docs|security)$" + + +class CreateConsultationRequest(BaseModel): + project_id: UUID + origin: str = Field(pattern=ORIGIN_PATTERN) + title: str = Field(min_length=3, max_length=200) + description: str = Field(min_length=5) + category: str | None = Field(default=None, pattern=CATEGORY_PATTERN) + editor_context: dict | None = None + external_references: list[str] | None = None + + +class TriageConsultationRequest(BaseModel): + category: str = Field(pattern=CATEGORY_PATTERN) + priority: str = Field(pattern=PRIORITY_PATTERN) + triage_notes: str = Field(min_length=5) + bug_criteria: dict | None = None + feature_criteria: dict | None = None + chore_criteria: dict | None = None + docs_criteria: dict | None = None + security_criteria: dict | None = None + + +def _authorize( + session: Session, + principal: AuthenticatedPrincipal, + workspace_id: UUID, + action: Action, +): + return AuthorizationService(session).authorize(principal, workspace_id, action) + + +def _idempotency_key(request: Request) -> str | None: + supplied = request.headers.get("idempotency-key") + if supplied is None: + return None + if not IDEMPOTENCY_KEY_PATTERN.match(supplied): + raise ProblemError( + status_code=400, + code="VALIDATION_ERROR", + detail="The Idempotency-Key header is malformed.", + title="Invalid request", + ) + return supplied + + +def _consultation_payload(consultation: Consultation) -> dict: + return { + "consultation_id": consultation.id, + "workspace_id": consultation.workspace_id, + "project_id": consultation.project_id, + "author_id": consultation.author_id, + "assignee_id": consultation.assignee_id, + "origin": consultation.origin, + "title": consultation.title, + "description": consultation.description, + "category": consultation.category, + "status": consultation.status, + "priority": consultation.priority, + "editor_context": consultation.editor_context, + "external_references": consultation.external_references, + "active_proposal_id": consultation.active_proposal_id, + "created_at": consultation.created_at, + "updated_at": consultation.updated_at, + } + + +@router.post("/v1/workspaces/{workspace_id}/consultations", status_code=201) +def create_consultation( + workspace_id: UUID, + payload: CreateConsultationRequest, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.CONSULTATION_CREATE) + result = ConsultationService(session).submit( + context, + project_id=payload.project_id, + origin=payload.origin, + title=payload.title, + description=payload.description, + category=payload.category, + editor_context=payload.editor_context, + external_references=payload.external_references, + idempotency_key=_idempotency_key(request), + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = consultation_etag(result.consultation) + response.headers["Location"] = ( + f"/v1/workspaces/{workspace_id}/consultations/{result.consultation.id}" + ) + return _consultation_payload(result.consultation) + + +@router.get("/v1/workspaces/{workspace_id}/consultations") +def list_consultations( + workspace_id: UUID, + response: Response, + status: str | None = Query(default=None), + category: str | None = Query(default=None), + priority: str | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + cursor: str | None = Query(default=None), + sort: str | None = Query(default=None), + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize(session, principal, workspace_id, Action.CONSULTATION_READ) + items, next_cursor = ConsultationService(session).list_page( + context, + status=status, + category=category, + priority=priority, + sort=sort, + limit=limit, + cursor=cursor, + ) + response.headers["ETag"] = ( + '"' + + fingerprint( + [ + str(workspace_id), + [f"{item.id}{item.updated_at.isoformat()}" for item in items], + next_cursor, + ] + ) + + '"' + ) + return { + "items": [_consultation_payload(item) for item in items], + "page_info": {"has_more": next_cursor is not None, "next_cursor": next_cursor}, + } + + +@router.get("/v1/workspaces/{workspace_id}/consultations/{consultation_id}") +def get_consultation( + workspace_id: UUID, + consultation_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize(session, principal, workspace_id, Action.CONSULTATION_READ) + consultation = ConsultationService(session).get(context, consultation_id) + response.headers["ETag"] = consultation_etag(consultation) + return _consultation_payload(consultation) + + +@router.post("/v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage") +def triage_consultation( + workspace_id: UUID, + consultation_id: UUID, + payload: TriageConsultationRequest, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.CONSULTATION_TRIAGE) + criteria = { + "bug_criteria": payload.bug_criteria, + "feature_criteria": payload.feature_criteria, + "chore_criteria": payload.chore_criteria, + "docs_criteria": payload.docs_criteria, + "security_criteria": payload.security_criteria, + } + result = ConsultationService(session).triage( + context, + consultation_id, + category=payload.category, + priority=payload.priority, + triage_notes=payload.triage_notes, + criteria=criteria, + if_match=request.headers.get("if-match"), + idempotency_key=_idempotency_key(request), + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = consultation_etag(result.consultation) + return _consultation_payload(result.consultation) diff --git a/services/work-management/src/eventflow_work_management/migrations/versions/0004_consultation_triage_detail.py b/services/work-management/src/eventflow_work_management/migrations/versions/0004_consultation_triage_detail.py new file mode 100644 index 0000000..a4b0690 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/migrations/versions/0004_consultation_triage_detail.py @@ -0,0 +1,64 @@ +"""Add persisted triage detail to consultations (Issue #4).""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0004_consultation_triage_detail" +down_revision = "0003_workspace_membership_rbac" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("consultations", sa.Column("triage_notes", sa.Text(), nullable=True)) + op.add_column( + "consultations", + sa.Column("triage_criteria", JSONB(), nullable=True), + ) + op.add_column( + "consultations", sa.Column("triaged_by", sa.Uuid(as_uuid=True), nullable=True) + ) + op.add_column( + "consultations", + sa.Column("triaged_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_check_constraint( + op.f("ck_consultations_triage_criteria_is_object"), + "consultations", + "triage_criteria IS NULL OR jsonb_typeof(triage_criteria) = 'object'", + ) + op.create_check_constraint( + op.f("ck_consultations_triage_notes_min_length"), + "consultations", + "triage_notes IS NULL OR char_length(triage_notes) >= 5", + ) + op.create_check_constraint( + op.f("ck_consultations_triage_actor_consistency"), + "consultations", + "(triaged_at IS NULL) = (triaged_by IS NULL)", + ) + + +def downgrade() -> None: + op.drop_constraint( + op.f("ck_consultations_triage_actor_consistency"), + "consultations", + type_="check", + ) + op.drop_constraint( + op.f("ck_consultations_triage_notes_min_length"), + "consultations", + type_="check", + ) + op.drop_constraint( + op.f("ck_consultations_triage_criteria_is_object"), + "consultations", + type_="check", + ) + op.drop_column("consultations", "triaged_at") + op.drop_column("consultations", "triaged_by") + op.drop_column("consultations", "triage_criteria") + op.drop_column("consultations", "triage_notes") diff --git a/services/work-management/src/eventflow_work_management/models/consultation.py b/services/work-management/src/eventflow_work_management/models/consultation.py index 4b1a69e..0282622 100644 --- a/services/work-management/src/eventflow_work_management/models/consultation.py +++ b/services/work-management/src/eventflow_work_management/models/consultation.py @@ -2,10 +2,12 @@ from __future__ import annotations +from datetime import datetime from uuid import UUID, uuid4 from sqlalchemy import ( CheckConstraint, + DateTime, ForeignKeyConstraint, Index, String, @@ -56,6 +58,12 @@ class Consultation(TimestampMixin, Base): active_proposal_id: Mapped[UUID | None] = mapped_column( Uuid(as_uuid=True), nullable=True ) + triage_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + triage_criteria: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + triaged_by: Mapped[UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True) + triaged_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) __table_args__ = ( ForeignKeyConstraint( @@ -85,6 +93,18 @@ class Consultation(TimestampMixin, Base): CheckConstraint( "jsonb_typeof(external_references) = 'array'", name="references_is_array" ), + CheckConstraint( + "triage_criteria IS NULL OR jsonb_typeof(triage_criteria) = 'object'", + name="triage_criteria_is_object", + ), + CheckConstraint( + "triage_notes IS NULL OR char_length(triage_notes) >= 5", + name="triage_notes_min_length", + ), + CheckConstraint( + "(triaged_at IS NULL) = (triaged_by IS NULL)", + name="triage_actor_consistency", + ), Index("ix_consultations_workspace_id", "workspace_id"), Index("ix_consultations_project_id", "project_id"), Index("ix_consultations_status", "status"), diff --git a/services/work-management/src/eventflow_work_management/repositories/consultation_repository.py b/services/work-management/src/eventflow_work_management/repositories/consultation_repository.py index 14b69c6..91f05f6 100644 --- a/services/work-management/src/eventflow_work_management/repositories/consultation_repository.py +++ b/services/work-management/src/eventflow_work_management/repositories/consultation_repository.py @@ -2,14 +2,22 @@ from __future__ import annotations +from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import select +from sqlalchemy import select, tuple_ from sqlalchemy.orm import Session from eventflow_work_management.models import Consultation from eventflow_work_management.repositories.base import Repository +SORT_COLUMNS = { + "created_at": Consultation.created_at, + "updated_at": Consultation.updated_at, + "priority": Consultation.priority, + "status": Consultation.status, +} + class ConsultationRepository(Repository): def __init__(self, session: Session) -> None: @@ -48,6 +56,43 @@ def create( self._session.commit() return consultation + def add( + self, + *, + workspace_id: UUID, + project_id: UUID, + author_id: UUID, + origin: str, + title: str, + description: str, + priority: str = "medium", + category: str | None = None, + editor_context: dict | None = None, + external_references: list | None = None, + ) -> Consultation: + """Add a consultation and flush without committing. + + The owning service commits once so intake, its audit record, and any + idempotency reservation land in a single transaction. + """ + consultation = Consultation( + id=uuid4(), + workspace_id=workspace_id, + project_id=project_id, + author_id=author_id, + origin=origin, + title=title, + description=description, + category=category, + priority=priority, + status="open", + editor_context=editor_context, + external_references=external_references or [], + ) + self._session.add(consultation) + self._session.flush() + return consultation + def get(self, *, workspace_id: UUID, consultation_id: UUID) -> Consultation | None: statement = select(Consultation).where( Consultation.workspace_id == workspace_id, @@ -55,6 +100,19 @@ def get(self, *, workspace_id: UUID, consultation_id: UUID) -> Consultation | No ) return self._session.execute(statement).scalar_one_or_none() + def get_for_update( + self, *, workspace_id: UUID, consultation_id: UUID + ) -> Consultation | None: + statement = ( + select(Consultation) + .where( + Consultation.workspace_id == workspace_id, + Consultation.id == consultation_id, + ) + .with_for_update() + ) + return self._session.execute(statement).scalar_one_or_none() + def list_for_project( self, *, workspace_id: UUID, project_id: UUID ) -> list[Consultation]: @@ -67,3 +125,59 @@ def list_for_project( .order_by(Consultation.created_at) ) return list(self._session.execute(statement).scalars()) + + def list_for_workspace( + self, + *, + workspace_id: UUID, + status: str | None = None, + category: str | None = None, + priority: str | None = None, + sort_key: str = "created_at", + descending: bool = False, + limit: int = 50, + after: tuple[object, UUID] | None = None, + ) -> list[Consultation]: + """Return a keyset page ordered by ``sort_key`` with an id tiebreaker.""" + column = SORT_COLUMNS[sort_key] + statement = select(Consultation).where(Consultation.workspace_id == workspace_id) + if status is not None: + statement = statement.where(Consultation.status == status) + if category is not None: + statement = statement.where(Consultation.category == category) + if priority is not None: + statement = statement.where(Consultation.priority == priority) + if after is not None: + value, after_id = after + comparison = ( + tuple_(column, Consultation.id) < tuple_(value, after_id) + if descending + else tuple_(column, Consultation.id) > tuple_(value, after_id) + ) + statement = statement.where(comparison) + ordering = column.desc() if descending else column.asc() + id_ordering = Consultation.id.desc() if descending else Consultation.id.asc() + statement = statement.order_by(ordering, id_ordering).limit(limit) + return list(self._session.execute(statement).scalars()) + + def apply_triage( + self, + consultation: Consultation, + *, + category: str, + priority: str, + status: str, + triage_notes: str, + triage_criteria: dict, + triaged_by: UUID, + triaged_at: datetime, + ) -> None: + consultation.category = category + consultation.priority = priority + consultation.status = status + consultation.triage_notes = triage_notes + consultation.triage_criteria = triage_criteria + consultation.triaged_by = triaged_by + consultation.triaged_at = triaged_at + consultation.updated_at = triaged_at + self._session.flush() diff --git a/services/work-management/src/eventflow_work_management/services/consultation.py b/services/work-management/src/eventflow_work_management/services/consultation.py new file mode 100644 index 0000000..7c1468c --- /dev/null +++ b/services/work-management/src/eventflow_work_management/services/consultation.py @@ -0,0 +1,522 @@ +"""Consultation application service: multi-source intake, reads, and triage. + +Every public method is one unit of work: it authorizes through +``AuthorizationService`` (the tenant and actor are derived from the verified +principal, never from the request body), performs a single transactional change, +writes an append-only audit record, and commits exactly once. Optional +idempotency replay is stored in the same transaction as the change so a retried +submission never duplicates a consultation. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +from dataclasses import dataclass +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from eventflow_work_management.authorization.policy import Action +from eventflow_work_management.authorization.service import AuthorizationContext +from eventflow_work_management.hashing import fingerprint +from eventflow_work_management.models import Consultation +from eventflow_work_management.models.enums import ( + CONSULTATION_STATUS_VALUES, + ORIGIN_VALUES, + PRIORITY_VALUES, + TRIAGE_CATEGORY_VALUES, +) +from eventflow_work_management.repositories.audit_repository import AuditRepository +from eventflow_work_management.repositories.consultation_repository import ( + SORT_COLUMNS, + ConsultationRepository, +) +from eventflow_work_management.repositories.idempotency_repository import ( + IdempotencyRepository, +) +from eventflow_work_management.repositories.workspace_repository import ( + ProjectRepository, +) +from eventflow_work_management.security.errors import ProblemError + +CREATE_OPERATION = "consultation.create" +TRIAGE_OPERATION = "consultation.triage" + +DEFAULT_PRIORITY = "medium" + +TRIAGE_START_STATUSES = frozenset({"open", "reopened"}) + +CRITERIA_FIELD_FOR_CATEGORY = { + "bug": "bug_criteria", + "feature": "feature_criteria", + "chore": "chore_criteria", + "docs": "docs_criteria", + "security": "security_criteria", +} + +ALLOWED_SORT_KEYS = frozenset(SORT_COLUMNS) + +MAX_PAGE_SIZE = 200 +DEFAULT_PAGE_SIZE = 50 + +_NOT_FOUND = "The requested resource was not found." + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _validation(detail: str, *, status_code: int = 400) -> ProblemError: + return ProblemError( + status_code=status_code, + code="VALIDATION_ERROR", + detail=detail, + title="Invalid request" if status_code == 400 else "Unprocessable entity", + ) + + +def _not_found() -> ProblemError: + return ProblemError( + status_code=404, code="NOT_FOUND", detail=_NOT_FOUND, title="Not found" + ) + + +def _conflict(detail: str, *, code: str = "CONFLICT") -> ProblemError: + return ProblemError( + status_code=409, code=code, detail=detail, title="Conflict" + ) + + +def _precondition_failed() -> ProblemError: + return ProblemError( + status_code=412, + code="PRECONDITION_FAILED", + detail="The supplied If-Match validator is stale.", + title="Precondition failed", + ) + + +def consultation_etag(consultation: Consultation) -> str: + """Strong validator over the persisted consultation version.""" + return f'"{fingerprint(["consultation", str(consultation.id), consultation.updated_at.isoformat()])}"' + + +@dataclass(frozen=True, slots=True) +class IntakeResult: + """A persisted consultation plus whether it was served from an idempotent replay.""" + + consultation: Consultation + replayed: bool + + +def _encode_cursor(sort_key: str, descending: bool, value, consultation_id: UUID) -> str: + payload = { + "k": sort_key, + "d": descending, + "v": value.isoformat() if isinstance(value, datetime) else value, + "id": str(consultation_id), + } + raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii") + + +def _decode_cursor(cursor: str, sort_key: str, descending: bool) -> tuple: + try: + raw = base64.urlsafe_b64decode(cursor.encode("ascii")) + payload = json.loads(raw) + if payload["k"] != sort_key or payload["d"] is not descending: + raise ValueError("cursor is bound to a different sort") + consultation_id = UUID(payload["id"]) + value = payload["v"] + if sort_key in {"created_at", "updated_at"}: + value = datetime.fromisoformat(value) + return value, consultation_id + except ( + binascii.Error, + UnicodeDecodeError, + json.JSONDecodeError, + KeyError, + TypeError, + ValueError, + ) as exc: + raise _validation("The pagination cursor is invalid.") from exc + + +def _parse_sort(sort: str | None) -> tuple[str, bool]: + if not sort: + return "created_at", False + token = sort.split(",", 1)[0].strip() + if not token: + raise _validation("The sort parameter is invalid.") + descending = token.startswith("-") + key = token[1:] if descending else token + if key not in ALLOWED_SORT_KEYS: + raise _validation("The sort parameter is invalid.") + return key, descending + + +class ConsultationService: + """Authorization, validation, and persistence for consultation intake.""" + + def __init__(self, session: Session) -> None: + self._session = session + self.consultations = ConsultationRepository(session) + self.projects = ProjectRepository(session) + self.audit = AuditRepository(session) + self.idempotency = IdempotencyRepository(session) + + # -- intake ----------------------------------------------------------- + + def submit( + self, + context: AuthorizationContext, + *, + project_id: UUID, + origin: str, + title: str, + description: str, + category: str | None = None, + editor_context: dict | None = None, + external_references: list | None = None, + idempotency_key: str | None = None, + correlation_id: UUID | None = None, + ) -> IntakeResult: + self._validate_origin(origin) + self._validate_category(category) + self._validate_editor_context(editor_context) + self._require_project(context.workspace_id, project_id) + + request_fingerprint = fingerprint( + { + "project_id": str(project_id), + "origin": origin, + "title": title, + "description": description, + "category": category, + "editor_context": editor_context, + "external_references": external_references or [], + } + ) + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, CREATE_OPERATION, idempotency_key, request_fingerprint + ) + if replay is not None: + return replay + + try: + record = ( + self.idempotency.reserve( + workspace_id=context.workspace_id, + operation=CREATE_OPERATION, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) + if idempotency_key is not None + else None + ) + consultation = self.consultations.add( + workspace_id=context.workspace_id, + project_id=project_id, + author_id=context.actor_id, + origin=origin, + title=title, + description=description, + priority=DEFAULT_PRIORITY, + category=category, + editor_context=editor_context, + external_references=external_references, + ) + self.audit.append( + workspace_id=context.workspace_id, + action="consultation.created", + entity_type="consultation", + entity_id=consultation.id, + actor_id=context.actor_id, + snapshot={ + "consultation_id": str(consultation.id), + "project_id": str(project_id), + "origin": origin, + "status": consultation.status, + "category": category, + }, + correlation_id=correlation_id, + ) + if record is not None: + self.idempotency.complete( + record, + response_status=201, + response_body={"consultation_id": str(consultation.id)}, + ) + self._session.commit() + except IntegrityError: + self._session.rollback() + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, + CREATE_OPERATION, + idempotency_key, + request_fingerprint, + ) + if replay is not None: + return replay + raise + return IntakeResult(consultation=consultation, replayed=False) + + # -- reads ------------------------------------------------------------ + + def get( + self, context: AuthorizationContext, consultation_id: UUID + ) -> Consultation: + consultation = self.consultations.get( + workspace_id=context.workspace_id, consultation_id=consultation_id + ) + if consultation is None: + raise _not_found() + return consultation + + def list_page( + self, + context: AuthorizationContext, + *, + status: str | None = None, + category: str | None = None, + priority: str | None = None, + sort: str | None = None, + limit: int = DEFAULT_PAGE_SIZE, + cursor: str | None = None, + ) -> tuple[list[Consultation], str | None]: + self._validate_category(category) + self._validate_priority(priority) + if status is not None and status not in CONSULTATION_STATUS_VALUES: + raise _validation("Unknown consultation status filter.") + if not 1 <= limit <= MAX_PAGE_SIZE: + raise _validation("The page limit is out of range.") + sort_key, descending = _parse_sort(sort) + after = ( + _decode_cursor(cursor, sort_key, descending) if cursor is not None else None + ) + rows = self.consultations.list_for_workspace( + workspace_id=context.workspace_id, + status=status, + category=category, + priority=priority, + sort_key=sort_key, + descending=descending, + limit=limit + 1, + after=after, + ) + has_more = len(rows) > limit + page = rows[:limit] + next_cursor = None + if has_more and page: + last = page[-1] + value = getattr(last, sort_key) + next_cursor = _encode_cursor(sort_key, descending, value, last.id) + return page, next_cursor + + # -- triage ----------------------------------------------------------- + + def triage( + self, + context: AuthorizationContext, + consultation_id: UUID, + *, + category: str, + priority: str, + triage_notes: str, + criteria: dict, + if_match: str | None = None, + idempotency_key: str | None = None, + correlation_id: UUID | None = None, + ) -> IntakeResult: + self._validate_category(category, required=True) + self._validate_priority(priority, required=True) + self._validate_triage_criteria(category, criteria) + + request_fingerprint = fingerprint( + { + "consultation_id": str(consultation_id), + "category": category, + "priority": priority, + "triage_notes": triage_notes, + "criteria": criteria, + } + ) + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, TRIAGE_OPERATION, idempotency_key, request_fingerprint + ) + if replay is not None: + return replay + + try: + consultation = self.consultations.get_for_update( + workspace_id=context.workspace_id, consultation_id=consultation_id + ) + if consultation is None: + raise _not_found() + if if_match is not None and if_match != consultation_etag(consultation): + raise _precondition_failed() + if consultation.status not in TRIAGE_START_STATUSES: + raise _conflict( + "The consultation is not in a state that can be triaged.", + code="INVALID_STATE", + ) + record = ( + self.idempotency.reserve( + workspace_id=context.workspace_id, + operation=TRIAGE_OPERATION, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) + if idempotency_key is not None + else None + ) + self.consultations.apply_triage( + consultation, + category=category, + priority=priority, + status="triaging", + triage_notes=triage_notes, + triage_criteria=criteria, + triaged_by=context.actor_id, + triaged_at=_now(), + ) + self.audit.append( + workspace_id=context.workspace_id, + action="consultation.triaged", + entity_type="consultation", + entity_id=consultation.id, + actor_id=context.actor_id, + snapshot={ + "consultation_id": str(consultation.id), + "category": category, + "priority": priority, + "status": "triaging", + }, + correlation_id=correlation_id, + ) + if record is not None: + self.idempotency.complete( + record, + response_status=200, + response_body={"consultation_id": str(consultation.id)}, + ) + self._session.commit() + except IntegrityError: + self._session.rollback() + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, + TRIAGE_OPERATION, + idempotency_key, + request_fingerprint, + ) + if replay is not None: + return replay + raise + except ProblemError: + self._session.rollback() + raise + return IntakeResult(consultation=consultation, replayed=False) + + # -- internals -------------------------------------------------------- + + def _replay( + self, + workspace_id: UUID, + operation: str, + idempotency_key: str, + request_fingerprint: str, + ) -> IntakeResult | None: + """Return a stored consultation for a replayed key, without side effects.""" + stored = self.idempotency.get( + workspace_id=workspace_id, + operation=operation, + idempotency_key=idempotency_key, + ) + if stored is None: + return None + if stored.request_fingerprint != request_fingerprint: + raise ProblemError( + status_code=409, + code="IDEMPOTENCY_CONFLICT", + detail="The idempotency key was reused with a different request.", + title="Conflict", + ) + consultation_id = ( + stored.response_body.get("consultation_id") + if stored.response_body + else None + ) + if consultation_id is None: + return None + consultation = self.consultations.get( + workspace_id=stored.workspace_id, consultation_id=UUID(consultation_id) + ) + if consultation is None: + return None + return IntakeResult(consultation=consultation, replayed=True) + + @staticmethod + def _validate_origin(origin: str) -> None: + if origin not in ORIGIN_VALUES: + raise _validation("Unknown consultation origin.") + + @staticmethod + def _validate_category(category: str | None, *, required: bool = False) -> None: + if category is None: + if required: + raise _validation("A triage category is required.") + return + if category not in TRIAGE_CATEGORY_VALUES: + raise _validation("Unknown triage category.") + + @staticmethod + def _validate_priority(priority: str | None, *, required: bool = False) -> None: + if priority is None: + if required: + raise _validation("A priority is required.") + return + if priority not in PRIORITY_VALUES: + raise _validation("Unknown priority.") + + @staticmethod + def _validate_editor_context(editor_context: dict | None) -> None: + if editor_context is None: + return + required = { + "repository", + "branch", + "commit_sha", + "file_path", + "line_start", + "line_end", + "consent_granted", + } + if not required <= set(editor_context): + raise _validation("Editor context is incomplete.") + if editor_context.get("consent_granted") is not True: + raise _validation( + "Editor context requires explicit developer consent.", + status_code=422, + ) + + @staticmethod + def _validate_triage_criteria(category: str, criteria: dict) -> None: + expected = CRITERIA_FIELD_FOR_CATEGORY[category] + provided = {key for key, value in criteria.items() if value is not None} + if provided != {expected}: + raise _validation( + "Triage criteria must match the selected category.", + status_code=422, + ) + + def _require_project(self, workspace_id: UUID, project_id: UUID) -> None: + if self.projects.get(workspace_id=workspace_id, project_id=project_id) is None: + raise _not_found() diff --git a/services/work-management/tests/test_consultation_api_integration.py b/services/work-management/tests/test_consultation_api_integration.py new file mode 100644 index 0000000..67555cd --- /dev/null +++ b/services/work-management/tests/test_consultation_api_integration.py @@ -0,0 +1,393 @@ +"""HTTP integration tests for consultation intake, read, and triage (Issue #4). + +The app runs against a throwaway migrated database and a deterministic +authentication stub. The tests exercise the public contract behaviour: +workspace-scoped intake from multiple origins, optional initial category, +ETag/Location, optional idempotent replay, cursor pagination and filters, +non-disclosive 404s for non-members, role-gated triage, and optimistic +concurrency. They skip themselves when no test database is configured. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient +from eventflow_work_management.api.app import create_app +from eventflow_work_management.db.engine import session_factory +from eventflow_work_management.security.config import AuthSettings +from eventflow_work_management.security.crypto import TokenCipher +from support import require_database_url, temporary_database + +ISSUER = "https://id.example.test/realms/eventflow" + + +class FakeOIDC: + """Maps each bearer token to a distinct internal identity.""" + + def validate_access_token(self, token: str) -> dict: + return {"iss": ISSUER, "sub": token} + + def close(self) -> None: # pragma: no cover - lifecycle no-op + return None + + +def _settings() -> AuthSettings: + return AuthSettings( + environment="test", + issuer=ISSUER, + audience="eventflow-api", + web_client_id="eventflow-web-bff", + native_client_ids=("eventflow-desktop", "eventflow-vscode"), + redirect_uri="https://api.example.test/v1/auth/oidc/callback", + frontend_redirect_uri="https://web.example.test/auth/complete", + allowed_origins=("https://web.example.test",), + token_encryption_keys=(TokenCipher.generate_key(),), + ) + + +def _bearer(subject: str) -> dict: + return {"Authorization": f"Bearer {subject}"} + + +def _principal_id(client: TestClient, subject: str) -> str: + response = client.get("/v1/auth/principal", headers=_bearer(subject)) + assert response.status_code == 200, response.text + return response.json()["user_id"] + + +def _make_workspace(client: TestClient, subject: str, name: str) -> str: + created = client.post( + "/v1/workspaces", + json={"name": name, "slug": name.lower().replace(" ", "-")}, + headers=_bearer(subject), + ) + assert created.status_code == 201, created.text + return created.json()["workspace_id"] + + +def _make_project(client: TestClient, subject: str, workspace_id: str) -> str: + created = client.post( + f"/v1/workspaces/{workspace_id}/projects", + json={"key": "PAY", "name": "Payments"}, + headers=_bearer(subject), + ) + assert created.status_code == 201, created.text + return created.json()["project_id"] + + +def _invite_and_accept( + client: TestClient, owner: str, workspace_id: str, invitee: str, role: str +) -> None: + invitee_id = _principal_id(client, invitee) + invitation = client.post( + f"/v1/workspaces/{workspace_id}/invitations", + json={"invited_user_id": invitee_id, "role": role}, + headers=_bearer(owner), + ) + assert invitation.status_code == 201, invitation.text + body = invitation.json() + accepted = client.post( + f"/v1/workspaces/{workspace_id}/invitations/{body['invitation']['invitation_id']}/accept", + json={"token": body["token"]}, + headers=_bearer(invitee), + ) + assert accepted.status_code == 201, accepted.text + + +def _submit(client: TestClient, subject: str, workspace_id: str, project_id: str, **extra): + payload = { + "project_id": project_id, + "origin": "vscode", + "title": "Null pointer on empty cart", + "description": "Submitting an empty cart returns 500 instead of 400.", + } + payload.update(extra) + return client.post( + f"/v1/workspaces/{workspace_id}/consultations", + json=payload, + headers=_bearer(subject), + ) + + +def test_intake_read_list_and_isolation(): + with temporary_database(require_database_url()) as (_, engine): + app = create_app( + settings=_settings(), + session_factory_override=session_factory(engine), + oidc_client=FakeOIDC(), + ) + with TestClient(app) as client: + workspace_id = _make_workspace(client, "alice", "Acme Intake") + project_id = _make_project(client, "alice", workspace_id) + + created = _submit( + client, "alice", workspace_id, project_id, origin="web", category="bug" + ) + assert created.status_code == 201, created.text + body = created.json() + assert body["status"] == "open" + assert body["priority"] == "medium" + assert body["category"] == "bug" + assert body["author_id"] + assert created.headers["ETag"].startswith('"') + assert created.headers["Location"].endswith(body["consultation_id"]) + + detail = client.get( + f"/v1/workspaces/{workspace_id}/consultations/{body['consultation_id']}", + headers=_bearer("alice"), + ) + assert detail.status_code == 200 + assert detail.headers["ETag"] == created.headers["ETag"] + + # A non-member must not distinguish "exists" from "foreign". + assert ( + client.get( + f"/v1/workspaces/{workspace_id}/consultations/{body['consultation_id']}", + headers=_bearer("mallory"), + ).status_code + == 404 + ) + assert _submit(client, "mallory", workspace_id, project_id).status_code == 404 + assert ( + client.get( + f"/v1/workspaces/{workspace_id}/consultations", + headers=_bearer("mallory"), + ).status_code + == 404 + ) + + # Every declared client origin is accepted. + for origin in ("web", "vscode", "desktop", "external"): + assert ( + _submit( + client, "alice", workspace_id, project_id, origin=origin + ).status_code + == 201 + ) + + +def test_intake_requires_consent_for_editor_context(): + with temporary_database(require_database_url()) as (_, engine): + app = create_app( + settings=_settings(), + session_factory_override=session_factory(engine), + oidc_client=FakeOIDC(), + ) + with TestClient(app) as client: + workspace_id = _make_workspace(client, "alice", "Consent Co") + project_id = _make_project(client, "alice", workspace_id) + + denied = _submit( + client, + "alice", + workspace_id, + project_id, + editor_context={ + "repository": "github.com/x/y", + "branch": "main", + "commit_sha": "1670d90", + "file_path": "src/a.py", + "line_start": 1, + "line_end": 2, + "consent_granted": False, + }, + ) + assert denied.status_code == 422 + + granted = _submit( + client, + "alice", + workspace_id, + project_id, + editor_context={ + "repository": "github.com/x/y", + "branch": "main", + "commit_sha": "1670d90", + "file_path": "src/a.py", + "line_start": 1, + "line_end": 2, + "consent_granted": True, + }, + ) + assert granted.status_code == 201, granted.text + + +def test_idempotent_intake_replays_and_conflicts(): + with temporary_database(require_database_url()) as (_, engine): + app = create_app( + settings=_settings(), + session_factory_override=session_factory(engine), + oidc_client=FakeOIDC(), + ) + with TestClient(app) as client: + workspace_id = _make_workspace(client, "alice", "Idem Co") + project_id = _make_project(client, "alice", workspace_id) + + headers = {**_bearer("alice"), "Idempotency-Key": "intake-key-0001"} + payload = { + "project_id": project_id, + "origin": "desktop", + "title": "Offline submission", + "description": "Submitted from the desktop offline queue.", + } + first = client.post( + f"/v1/workspaces/{workspace_id}/consultations", + json=payload, + headers=headers, + ) + assert first.status_code == 201, first.text + replay = client.post( + f"/v1/workspaces/{workspace_id}/consultations", + json=payload, + headers=headers, + ) + assert replay.status_code == 201 + assert ( + replay.json()["consultation_id"] == first.json()["consultation_id"] + ) + + conflict = client.post( + f"/v1/workspaces/{workspace_id}/consultations", + json={**payload, "title": "Different payload"}, + headers=headers, + ) + assert conflict.status_code == 409 + assert conflict.headers["content-type"] == "application/problem+json" + + listed = client.get( + f"/v1/workspaces/{workspace_id}/consultations", headers=_bearer("alice") + ) + assert listed.json()["page_info"]["has_more"] is False + assert len(listed.json()["items"]) == 1 + + +def test_list_pagination_and_filters(): + with temporary_database(require_database_url()) as (_, engine): + app = create_app( + settings=_settings(), + session_factory_override=session_factory(engine), + oidc_client=FakeOIDC(), + ) + with TestClient(app) as client: + workspace_id = _make_workspace(client, "alice", "Paging Co") + project_id = _make_project(client, "alice", workspace_id) + for index in range(3): + assert ( + _submit( + client, + "alice", + workspace_id, + project_id, + title=f"Consultation number {index}", + category="bug" if index == 0 else None, + ).status_code + == 201 + ) + + first = client.get( + f"/v1/workspaces/{workspace_id}/consultations?limit=2", + headers=_bearer("alice"), + ) + assert first.status_code == 200 + body = first.json() + assert len(body["items"]) == 2 + assert body["page_info"]["has_more"] is True + cursor = body["page_info"]["next_cursor"] + assert cursor + + second = client.get( + f"/v1/workspaces/{workspace_id}/consultations?limit=2&cursor={cursor}", + headers=_bearer("alice"), + ) + assert second.status_code == 200 + assert len(second.json()["items"]) == 1 + assert second.json()["page_info"]["has_more"] is False + + filtered = client.get( + f"/v1/workspaces/{workspace_id}/consultations?category=bug", + headers=_bearer("alice"), + ) + assert len(filtered.json()["items"]) == 1 + + invalid = client.get( + f"/v1/workspaces/{workspace_id}/consultations?sort=secret", + headers=_bearer("alice"), + ) + assert invalid.status_code == 400 + + +def test_triage_is_role_gated_and_optimistic(): + with temporary_database(require_database_url()) as (_, engine): + app = create_app( + settings=_settings(), + session_factory_override=session_factory(engine), + oidc_client=FakeOIDC(), + ) + with TestClient(app) as client: + workspace_id = _make_workspace(client, "owner", "Triage Co") + project_id = _make_project(client, "owner", workspace_id) + _invite_and_accept(client, "owner", workspace_id, "contrib", "contributor") + _invite_and_accept(client, "owner", workspace_id, "tina", "triager") + + created = _submit(client, "contrib", workspace_id, project_id) + assert created.status_code == 201, created.text + consultation_id = created.json()["consultation_id"] + etag = created.headers["ETag"] + + triage_body = { + "category": "bug", + "priority": "high", + "triage_notes": "Reproduced locally against the current build.", + "bug_criteria": { + "expected_behavior": "Returns 400 with a problem document.", + "observed_behavior": "Raises an unhandled 500 error.", + "reproduction_steps": ["Submit an empty cart payload."], + "environment": "Python 3.11", + "severity": "high", + "is_regression": False, + }, + } + + # A contributor cannot triage. + assert ( + client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", + json=triage_body, + headers=_bearer("contrib"), + ).status_code + == 403 + ) + + # Criteria must match the selected category. + mismatch = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", + json={**triage_body, "feature_criteria": {}}, + headers=_bearer("tina"), + ) + assert mismatch.status_code == 422 + + # A stale If-Match validator yields 412. + stale = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", + json=triage_body, + headers={**_bearer("tina"), "If-Match": '"deadbeef"'}, + ) + assert stale.status_code == 412 + + triaged = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", + json=triage_body, + headers={**_bearer("tina"), "If-Match": etag}, + ) + assert triaged.status_code == 200, triaged.text + assert triaged.json()["status"] == "triaging" + assert triaged.json()["priority"] == "high" + assert triaged.headers["ETag"] != etag + + # A consultation already in triage cannot be triaged again. + again = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", + json=triage_body, + headers=_bearer("tina"), + ) + assert again.status_code == 409 + assert again.json()["code"] == "INVALID_STATE" diff --git a/services/work-management/tests/test_migrations_integration.py b/services/work-management/tests/test_migrations_integration.py index 8c3f0de..1292c7e 100644 --- a/services/work-management/tests/test_migrations_integration.py +++ b/services/work-management/tests/test_migrations_integration.py @@ -98,7 +98,7 @@ def test_upgrade_creates_schema_guards_and_revision(): tables = _tables(engine) assert EXPECTED_TABLES <= tables assert "alembic_version" in tables - assert _revision(engine) == "0003_workspace_membership_rbac" + assert _revision(engine) == "0004_consultation_triage_detail" assert EXPECTED_TRIGGERS <= _triggers(engine) assert EXPECTED_FUNCTIONS <= _functions(engine) assert EXPECTED_CONSTRAINTS <= _constraints(engine) @@ -109,7 +109,7 @@ def test_upgrade_is_idempotent(): with temporary_empty_database(admin_url) as (url, engine): run_migrations(url) run_migrations(url) - assert _revision(engine) == "0003_workspace_membership_rbac" + assert _revision(engine) == "0004_consultation_triage_detail" def test_downgrade_removes_tables_triggers_and_functions(): From bc788d4803e910079e3264ecc3c902e982e25dbb Mon Sep 17 00:00:00 2001 From: Jackson Severino da Rocha Date: Fri, 16 Oct 2026 14:59:42 -0300 Subject: [PATCH 2/3] feat(work-management): add draft consultation lifecycle (#4) Introduce the DRAFT consultation status and dedicated draft endpoints so offline-capable clients can persist intake before submission, while preserving direct OPEN submission. - Add draft to the consultation status enum, JSON Schema, and OpenAPI; recreate ck_consultations_status_values in migration 0005. - Add POST .../consultations/drafts, PATCH .../consultations/{id}, and POST .../consultations/{id}/submit with idempotency, If-Match, and ETag. - Grant consultation:update and consultation:submit; restrict draft mutation to the author or a workspace administrator. - Cover create/edit/submit, repeated submission, optimistic locking, idempotent replay, RBAC, tenant isolation, and concurrent submission in PostgreSQL integration tests. --- contracts/openapi/work-management.yaml | 165 +++++++ contracts/schemas/work_management.yaml | 32 +- docs/architecture/work-management-api.md | 8 +- docs/architecture/work-management-workflow.md | 5 + .../api/consultation_routes.py | 86 ++++ .../authorization/policy.py | 12 + .../versions/0005_consultation_drafts.py | 45 ++ .../eventflow_work_management/models/enums.py | 1 + .../repositories/consultation_repository.py | 40 +- .../services/consultation.py | 286 +++++++++++- .../test_consultation_draft_integration.py | 442 ++++++++++++++++++ .../tests/test_migrations_integration.py | 4 +- .../tests/test_rbac_policy_unit.py | 3 + tests/contracts/test_work_management_api.py | 3 + tests/security/test_security_contracts.py | 3 + 15 files changed, 1124 insertions(+), 11 deletions(-) create mode 100644 services/work-management/src/eventflow_work_management/migrations/versions/0005_consultation_drafts.py create mode 100644 services/work-management/tests/test_consultation_draft_integration.py diff --git a/contracts/openapi/work-management.yaml b/contracts/openapi/work-management.yaml index 1d9ea05..5e50f2b 100644 --- a/contracts/openapi/work-management.yaml +++ b/contracts/openapi/work-management.yaml @@ -143,6 +143,64 @@ paths: default: $ref: '#/components/responses/UnexpectedError' + /v1/workspaces/{workspace_id}/consultations/drafts: + post: + operationId: createConsultationDraft + tags: [Consultations] + summary: Create a draft consultation + description: >- + Records a consultation in DRAFT state. A draft is an unsubmitted intake + that cannot be triaged until it is submitted, which transitions it to + OPEN. Drafts let offline-capable clients persist intake before submission. + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/IdempotencyKeyOptional' + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/CreateConsultationRequest' + responses: + '201': + description: Draft consultation created. + headers: + ETag: + $ref: '#/components/headers/ETag' + Location: + description: URI of the created consultation resource. + schema: + type: string + format: uri-reference + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/Consultation' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + /v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage: post: operationId: triageConsultation @@ -196,6 +254,57 @@ paths: default: $ref: '#/components/responses/UnexpectedError' + /v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit: + post: + operationId: submitConsultationDraft + tags: [Consultations] + summary: Submit a draft consultation + description: >- + Transitions a DRAFT consultation to OPEN, making it eligible for triage. + Only the draft author or a workspace administrator may submit it. An + already-submitted consultation returns 409 Conflict. + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ConsultationId' + - $ref: '#/components/parameters/IdempotencyKeyOptional' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: Draft submitted and now in OPEN state. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/Consultation' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '412': + $ref: '#/components/responses/PreconditionFailed' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + /v1/workspaces/{workspace_id}/consultations/{consultation_id}: get: operationId: getConsultation @@ -233,6 +342,62 @@ paths: default: $ref: '#/components/responses/UnexpectedError' + patch: + operationId: updateConsultationDraft + tags: [Consultations] + summary: Edit a draft consultation + description: >- + Applies a partial update to a DRAFT consultation. Only the draft author + or a workspace administrator may edit it, and only while it remains in + DRAFT state; editing any other status returns 409 Conflict. + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ConsultationId' + - $ref: '#/components/parameters/IdempotencyKeyOptional' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/UpdateConsultationDraftRequest' + responses: + '200': + description: Draft consultation updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/Consultation' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '412': + $ref: '#/components/responses/PreconditionFailed' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + /v1/workspaces/{workspace_id}/proposals: post: operationId: createProposal diff --git a/contracts/schemas/work_management.yaml b/contracts/schemas/work_management.yaml index 848c9ce..f0c879e 100644 --- a/contracts/schemas/work_management.yaml +++ b/contracts/schemas/work_management.yaml @@ -26,7 +26,7 @@ $defs: ConsultationStatus: type: string - enum: [open, triaging, proposed, duplicate, rejected, closed, reopened] + enum: [draft, open, triaging, proposed, duplicate, rejected, closed, reopened] ProposalStatus: type: string @@ -592,6 +592,36 @@ $defs: items: type: string + UpdateConsultationDraftRequest: + type: object + description: Request DTO for PATCH /v1/workspaces/{workspace_id}/consultations/{consultation_id}. + additionalProperties: false + minProperties: 1 + properties: + project_id: + $ref: '#/$defs/UUID' + origin: + $ref: '#/$defs/Origin' + title: + type: string + minLength: 3 + maxLength: 200 + description: + type: string + minLength: 5 + category: + anyOf: + - $ref: '#/$defs/TriageCategory' + - type: 'null' + editor_context: + anyOf: + - $ref: '#/$defs/EditorContext' + - type: 'null' + external_references: + type: array + items: + type: string + TriageConsultationRequest: type: object description: Request DTO for POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage. diff --git a/docs/architecture/work-management-api.md b/docs/architecture/work-management-api.md index 9c5790d..33dbabf 100644 --- a/docs/architecture/work-management-api.md +++ b/docs/architecture/work-management-api.md @@ -64,8 +64,9 @@ section 3.1) and for contract tests. | DTO | Used by | |---|---| -| `CreateConsultationRequest` | `POST /v1/workspaces/{workspace_id}/consultations` | -| `Consultation` | detail GET and create/list/triage responses | +| `CreateConsultationRequest` | `POST /v1/workspaces/{workspace_id}/consultations` and `POST .../consultations/drafts` | +| `Consultation` | detail GET and create/create-draft/update-draft/submit-draft/list/triage responses | +| `UpdateConsultationDraftRequest` | `PATCH .../consultations/{consultation_id}` | | `TriageConsultationRequest` | `POST .../consultations/{consultation_id}/triage` | | `CreateProposalRequest` | `POST /v1/workspaces/{workspace_id}/proposals` | | `Proposal` | detail GET and create/submit responses | @@ -164,6 +165,9 @@ Amendment A-02, every unsafe operation a client may retry accepts an | Operation | Header | Required | |---|---|---| | `POST .../consultations` | `Idempotency-Key` | No (required for offline clients) | +| `POST .../consultations/drafts` | `Idempotency-Key` | No (required for offline clients) | +| `PATCH .../consultations/{consultation_id}` | `Idempotency-Key` | No | +| `POST .../consultations/{consultation_id}/submit` | `Idempotency-Key` | No | | `POST .../consultations/{consultation_id}/triage` | `Idempotency-Key` | No | | `POST .../proposals` | `Idempotency-Key` | No | | `POST .../proposals/{proposal_id}/submit` | `Idempotency-Key` | No | diff --git a/docs/architecture/work-management-workflow.md b/docs/architecture/work-management-workflow.md index 4f7ad6c..b209dcb 100644 --- a/docs/architecture/work-management-workflow.md +++ b/docs/architecture/work-management-workflow.md @@ -211,7 +211,9 @@ flowchart TD ```mermaid stateDiagram-v2 + [*] --> DRAFT: Create Draft (Web/VSCode/Desktop) [*] --> OPEN: Submit (Web/VSCode/Desktop) + DRAFT --> OPEN: Submit Draft OPEN --> TRIAGING: Start Triage TRIAGING --> PROPOSED: Proposal Drafted TRIAGING --> REJECTED: Invalid / Out of Scope @@ -272,6 +274,9 @@ follows: *Any Authenticated / Author / Proposer / Assignee* → `contributor`; | Entity | From State | To State | Authorized Role | Pre-conditions | Invariants Enforced | Side Effects & Domain Events | Error Codes | |---|---|---|---|---|---|---|---| +| **Consultation** | `None` | `DRAFT` | `Author` | Valid title and description; optional reporter category; consent granted if editor context present | Status initialized to `DRAFT`; not eligible for triage | `consultation.draft_created` event published | `400 Bad Request` | +| **Consultation** | `DRAFT` | `DRAFT` | `Author`, `Admin` | Draft is in `DRAFT` state; `If-Match` valid when supplied | Editable fields only; status unchanged | `consultation.draft_updated` event published | `403 Forbidden`, `409 Conflict`, `412 Precondition Failed` | +| **Consultation** | `DRAFT` | `OPEN` | `Author`, `Admin` | Draft is in `DRAFT` state; `If-Match` valid when supplied | Status becomes `OPEN`; eligible for triage | `consultation.submitted` event published | `403 Forbidden`, `409 Conflict`, `412 Precondition Failed` | | **Consultation** | `None` | `OPEN` | `Any Authenticated` | Valid title and description; consent granted if editor context present | Status initialized to `OPEN` | `consultation.created` event published | `400 Bad Request` | | **Consultation** | `OPEN` | `TRIAGING` | `Triager`, `Maintainer` | Consultation is in `OPEN` or `REOPENED` | Category and priority must be assigned | `consultation.triaged` event published | `409 Conflict` | | **Consultation** | `TRIAGING` | `PROPOSED` | `Author`, `Maintainer` | A valid Proposal references this `consultation_id` | `active_proposal_id` linked | Consultation status locked to `PROPOSED` | `400 Bad Request`, `409 Conflict` | diff --git a/services/work-management/src/eventflow_work_management/api/consultation_routes.py b/services/work-management/src/eventflow_work_management/api/consultation_routes.py index d0c086f..9fe4053 100644 --- a/services/work-management/src/eventflow_work_management/api/consultation_routes.py +++ b/services/work-management/src/eventflow_work_management/api/consultation_routes.py @@ -50,6 +50,16 @@ class CreateConsultationRequest(BaseModel): external_references: list[str] | None = None +class UpdateConsultationDraftRequest(BaseModel): + project_id: UUID | None = None + origin: str | None = Field(default=None, pattern=ORIGIN_PATTERN) + title: str | None = Field(default=None, min_length=3, max_length=200) + description: str | None = Field(default=None, min_length=5) + category: str | None = Field(default=None, pattern=CATEGORY_PATTERN) + editor_context: dict | None = None + external_references: list[str] | None = None + + class TriageConsultationRequest(BaseModel): category: str = Field(pattern=CATEGORY_PATTERN) priority: str = Field(pattern=PRIORITY_PATTERN) @@ -135,6 +145,36 @@ def create_consultation( return _consultation_payload(result.consultation) +@router.post("/v1/workspaces/{workspace_id}/consultations/drafts", status_code=201) +def create_consultation_draft( + workspace_id: UUID, + payload: CreateConsultationRequest, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.CONSULTATION_CREATE) + result = ConsultationService(session).create_draft( + context, + project_id=payload.project_id, + origin=payload.origin, + title=payload.title, + description=payload.description, + category=payload.category, + editor_context=payload.editor_context, + external_references=payload.external_references, + idempotency_key=_idempotency_key(request), + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = consultation_etag(result.consultation) + response.headers["Location"] = ( + f"/v1/workspaces/{workspace_id}/consultations/{result.consultation.id}" + ) + return _consultation_payload(result.consultation) + + @router.get("/v1/workspaces/{workspace_id}/consultations") def list_consultations( workspace_id: UUID, @@ -189,6 +229,52 @@ def get_consultation( return _consultation_payload(consultation) +@router.patch("/v1/workspaces/{workspace_id}/consultations/{consultation_id}") +def update_consultation_draft( + workspace_id: UUID, + consultation_id: UUID, + payload: UpdateConsultationDraftRequest, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.CONSULTATION_UPDATE) + result = ConsultationService(session).update_draft( + context, + consultation_id, + changes=payload.model_dump(exclude_unset=True), + if_match=request.headers.get("if-match"), + idempotency_key=_idempotency_key(request), + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = consultation_etag(result.consultation) + return _consultation_payload(result.consultation) + + +@router.post("/v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit") +def submit_consultation_draft( + workspace_id: UUID, + consultation_id: UUID, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.CONSULTATION_SUBMIT) + result = ConsultationService(session).submit_draft( + context, + consultation_id, + if_match=request.headers.get("if-match"), + idempotency_key=_idempotency_key(request), + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = consultation_etag(result.consultation) + return _consultation_payload(result.consultation) + + @router.post("/v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage") def triage_consultation( workspace_id: UUID, diff --git a/services/work-management/src/eventflow_work_management/authorization/policy.py b/services/work-management/src/eventflow_work_management/authorization/policy.py index 6eb970e..c2a8097 100644 --- a/services/work-management/src/eventflow_work_management/authorization/policy.py +++ b/services/work-management/src/eventflow_work_management/authorization/policy.py @@ -40,6 +40,8 @@ class Action(str, Enum): CONSULTATION_READ = "consultation:read" CONSULTATION_CREATE = "consultation:create" + CONSULTATION_UPDATE = "consultation:update" + CONSULTATION_SUBMIT = "consultation:submit" CONSULTATION_TRIAGE = "consultation:triage" PROPOSAL_READ = "proposal:read" @@ -77,6 +79,8 @@ class Action(str, Enum): Action.MEMBERSHIP_REVOKE, Action.MEMBERSHIP_TRANSFER_OWNERSHIP, Action.CONSULTATION_CREATE, + Action.CONSULTATION_UPDATE, + Action.CONSULTATION_SUBMIT, Action.CONSULTATION_TRIAGE, Action.PROPOSAL_CREATE, Action.PROPOSAL_SUBMIT, @@ -94,6 +98,8 @@ class Action(str, Enum): Action.MEMBERSHIP_UPDATE_ROLE, Action.MEMBERSHIP_REVOKE, Action.CONSULTATION_CREATE, + Action.CONSULTATION_UPDATE, + Action.CONSULTATION_SUBMIT, Action.CONSULTATION_TRIAGE, Action.PROPOSAL_CREATE, Action.PROPOSAL_SUBMIT, @@ -105,6 +111,8 @@ class Action(str, Enum): "triager": frozenset( { Action.CONSULTATION_CREATE, + Action.CONSULTATION_UPDATE, + Action.CONSULTATION_SUBMIT, Action.CONSULTATION_TRIAGE, Action.PROPOSAL_CREATE, Action.PROPOSAL_SUBMIT, @@ -114,6 +122,8 @@ class Action(str, Enum): "approver": frozenset( { Action.CONSULTATION_CREATE, + Action.CONSULTATION_UPDATE, + Action.CONSULTATION_SUBMIT, Action.PROPOSAL_CREATE, Action.PROPOSAL_SUBMIT, Action.APPROVAL_DECIDE, @@ -122,6 +132,8 @@ class Action(str, Enum): "contributor": frozenset( { Action.CONSULTATION_CREATE, + Action.CONSULTATION_UPDATE, + Action.CONSULTATION_SUBMIT, Action.PROPOSAL_CREATE, Action.PROPOSAL_SUBMIT, Action.WORK_ITEM_UPDATE, diff --git a/services/work-management/src/eventflow_work_management/migrations/versions/0005_consultation_drafts.py b/services/work-management/src/eventflow_work_management/migrations/versions/0005_consultation_drafts.py new file mode 100644 index 0000000..1534b6e --- /dev/null +++ b/services/work-management/src/eventflow_work_management/migrations/versions/0005_consultation_drafts.py @@ -0,0 +1,45 @@ +"""Add the DRAFT consultation status (Issue #4).""" + +from __future__ import annotations + +from alembic import op + +revision = "0005_consultation_drafts" +down_revision = "0004_consultation_triage_detail" +branch_labels = None +depends_on = None + +_STATUS_WITH_DRAFT = ( + "status IN ('draft', 'open', 'triaging', 'proposed', 'duplicate', " + "'rejected', 'closed', 'reopened')" +) +_STATUS_WITHOUT_DRAFT = ( + "status IN ('open', 'triaging', 'proposed', 'duplicate', " + "'rejected', 'closed', 'reopened')" +) + + +def upgrade() -> None: + op.drop_constraint( + op.f("ck_consultations_status_values"), + "consultations", + type_="check", + ) + op.create_check_constraint( + op.f("ck_consultations_status_values"), + "consultations", + _STATUS_WITH_DRAFT, + ) + + +def downgrade() -> None: + op.drop_constraint( + op.f("ck_consultations_status_values"), + "consultations", + type_="check", + ) + op.create_check_constraint( + op.f("ck_consultations_status_values"), + "consultations", + _STATUS_WITHOUT_DRAFT, + ) diff --git a/services/work-management/src/eventflow_work_management/models/enums.py b/services/work-management/src/eventflow_work_management/models/enums.py index 9a74cbb..2c05975 100644 --- a/services/work-management/src/eventflow_work_management/models/enums.py +++ b/services/work-management/src/eventflow_work_management/models/enums.py @@ -10,6 +10,7 @@ TRIAGE_CATEGORY_VALUES = ("bug", "feature", "chore", "docs", "security") PRIORITY_VALUES = ("critical", "high", "medium", "low") CONSULTATION_STATUS_VALUES = ( + "draft", "open", "triaging", "proposed", diff --git a/services/work-management/src/eventflow_work_management/repositories/consultation_repository.py b/services/work-management/src/eventflow_work_management/repositories/consultation_repository.py index 91f05f6..b6e6ed5 100644 --- a/services/work-management/src/eventflow_work_management/repositories/consultation_repository.py +++ b/services/work-management/src/eventflow_work_management/repositories/consultation_repository.py @@ -18,6 +18,18 @@ "status": Consultation.status, } +_DRAFT_UPDATABLE_FIELDS = frozenset( + { + "project_id", + "origin", + "title", + "description", + "category", + "editor_context", + "external_references", + } +) + class ConsultationRepository(Repository): def __init__(self, session: Session) -> None: @@ -69,6 +81,7 @@ def add( category: str | None = None, editor_context: dict | None = None, external_references: list | None = None, + status: str = "open", ) -> Consultation: """Add a consultation and flush without committing. @@ -85,7 +98,7 @@ def add( description=description, category=category, priority=priority, - status="open", + status=status, editor_context=editor_context, external_references=external_references or [], ) @@ -93,6 +106,31 @@ def add( self._session.flush() return consultation + def update_draft( + self, + consultation: Consultation, + *, + changes: dict[str, object], + updated_at: datetime, + ) -> Consultation: + """Apply a whitelisted partial update to a draft and flush.""" + for field, value in changes.items(): + if field not in _DRAFT_UPDATABLE_FIELDS: + raise ValueError(f"unexpected draft field: {field}") + setattr(consultation, field, value) + consultation.updated_at = updated_at + self._session.flush() + return consultation + + def submit_draft( + self, consultation: Consultation, *, submitted_at: datetime + ) -> Consultation: + """Transition a draft to OPEN and flush.""" + consultation.status = "open" + consultation.updated_at = submitted_at + self._session.flush() + return consultation + def get(self, *, workspace_id: UUID, consultation_id: UUID) -> Consultation | None: statement = select(Consultation).where( Consultation.workspace_id == workspace_id, diff --git a/services/work-management/src/eventflow_work_management/services/consultation.py b/services/work-management/src/eventflow_work_management/services/consultation.py index 7c1468c..9f83fdc 100644 --- a/services/work-management/src/eventflow_work_management/services/consultation.py +++ b/services/work-management/src/eventflow_work_management/services/consultation.py @@ -20,7 +20,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from eventflow_work_management.authorization.policy import Action +from eventflow_work_management.authorization.policy import ADMINISTRATOR_ROLES from eventflow_work_management.authorization.service import AuthorizationContext from eventflow_work_management.hashing import fingerprint from eventflow_work_management.models import Consultation @@ -45,9 +45,14 @@ CREATE_OPERATION = "consultation.create" TRIAGE_OPERATION = "consultation.triage" +DRAFT_CREATE_OPERATION = "consultation.draft.create" +DRAFT_UPDATE_OPERATION = "consultation.draft.update" +DRAFT_SUBMIT_OPERATION = "consultation.draft.submit" DEFAULT_PRIORITY = "medium" +DRAFT_STATUS = "draft" + TRIAGE_START_STATUSES = frozenset({"open", "reopened"}) CRITERIA_FIELD_FOR_CATEGORY = { @@ -184,6 +189,71 @@ def submit( external_references: list | None = None, idempotency_key: str | None = None, correlation_id: UUID | None = None, + ) -> IntakeResult: + """Direct multi-source intake: the consultation enters OPEN immediately.""" + return self._create( + context, + status="open", + operation=CREATE_OPERATION, + audit_action="consultation.created", + project_id=project_id, + origin=origin, + title=title, + description=description, + category=category, + editor_context=editor_context, + external_references=external_references, + idempotency_key=idempotency_key, + correlation_id=correlation_id, + ) + + def create_draft( + self, + context: AuthorizationContext, + *, + project_id: UUID, + origin: str, + title: str, + description: str, + category: str | None = None, + editor_context: dict | None = None, + external_references: list | None = None, + idempotency_key: str | None = None, + correlation_id: UUID | None = None, + ) -> IntakeResult: + """Create a consultation in DRAFT state; it is not yet eligible for triage.""" + return self._create( + context, + status=DRAFT_STATUS, + operation=DRAFT_CREATE_OPERATION, + audit_action="consultation.draft_created", + project_id=project_id, + origin=origin, + title=title, + description=description, + category=category, + editor_context=editor_context, + external_references=external_references, + idempotency_key=idempotency_key, + correlation_id=correlation_id, + ) + + def _create( + self, + context: AuthorizationContext, + *, + status: str, + operation: str, + audit_action: str, + project_id: UUID, + origin: str, + title: str, + description: str, + category: str | None, + editor_context: dict | None, + external_references: list | None, + idempotency_key: str | None, + correlation_id: UUID | None, ) -> IntakeResult: self._validate_origin(origin) self._validate_category(category) @@ -203,7 +273,7 @@ def submit( ) if idempotency_key is not None: replay = self._replay( - context.workspace_id, CREATE_OPERATION, idempotency_key, request_fingerprint + context.workspace_id, operation, idempotency_key, request_fingerprint ) if replay is not None: return replay @@ -212,7 +282,7 @@ def submit( record = ( self.idempotency.reserve( workspace_id=context.workspace_id, - operation=CREATE_OPERATION, + operation=operation, idempotency_key=idempotency_key, request_fingerprint=request_fingerprint, ) @@ -230,10 +300,11 @@ def submit( category=category, editor_context=editor_context, external_references=external_references, + status=status, ) self.audit.append( workspace_id=context.workspace_id, - action="consultation.created", + action=audit_action, entity_type="consultation", entity_id=consultation.id, actor_id=context.actor_id, @@ -258,7 +329,7 @@ def submit( if idempotency_key is not None: replay = self._replay( context.workspace_id, - CREATE_OPERATION, + operation, idempotency_key, request_fingerprint, ) @@ -267,6 +338,195 @@ def submit( raise return IntakeResult(consultation=consultation, replayed=False) + def update_draft( + self, + context: AuthorizationContext, + consultation_id: UUID, + *, + changes: dict[str, object], + if_match: str | None = None, + idempotency_key: str | None = None, + correlation_id: UUID | None = None, + ) -> IntakeResult: + """Apply a partial update to a draft owned by the caller (or an admin).""" + if not changes: + raise _validation("At least one field must be provided.") + if "origin" in changes: + self._validate_origin(changes["origin"]) + if "category" in changes: + self._validate_category(changes["category"]) + if "editor_context" in changes: + self._validate_editor_context(changes["editor_context"]) + if "project_id" in changes: + self._require_project(context.workspace_id, changes["project_id"]) + + request_fingerprint = fingerprint( + { + "consultation_id": str(consultation_id), + "version": if_match, + "changes": { + key: str(value) if isinstance(value, UUID) else value + for key, value in sorted(changes.items()) + }, + } + ) + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, + DRAFT_UPDATE_OPERATION, + idempotency_key, + request_fingerprint, + ) + if replay is not None: + return replay + + try: + consultation = self.consultations.get_for_update( + workspace_id=context.workspace_id, consultation_id=consultation_id + ) + if consultation is None: + raise _not_found() + self._require_draft_owner(context, consultation) + if if_match is not None and if_match != consultation_etag(consultation): + raise _precondition_failed() + if consultation.status != DRAFT_STATUS: + raise _conflict( + "Only a draft consultation can be edited.", + code="INVALID_STATE", + ) + record = ( + self.idempotency.reserve( + workspace_id=context.workspace_id, + operation=DRAFT_UPDATE_OPERATION, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) + if idempotency_key is not None + else None + ) + self.consultations.update_draft( + consultation, changes=changes, updated_at=_now() + ) + self.audit.append( + workspace_id=context.workspace_id, + action="consultation.draft_updated", + entity_type="consultation", + entity_id=consultation.id, + actor_id=context.actor_id, + snapshot={ + "consultation_id": str(consultation.id), + "fields": sorted(changes), + }, + correlation_id=correlation_id, + ) + if record is not None: + self.idempotency.complete( + record, + response_status=200, + response_body={"consultation_id": str(consultation.id)}, + ) + self._session.commit() + except IntegrityError: + self._session.rollback() + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, + DRAFT_UPDATE_OPERATION, + idempotency_key, + request_fingerprint, + ) + if replay is not None: + return replay + raise + except ProblemError: + self._session.rollback() + raise + return IntakeResult(consultation=consultation, replayed=False) + + def submit_draft( + self, + context: AuthorizationContext, + consultation_id: UUID, + *, + if_match: str | None = None, + idempotency_key: str | None = None, + correlation_id: UUID | None = None, + ) -> IntakeResult: + """Transition a draft owned by the caller (or an admin) to OPEN.""" + request_fingerprint = fingerprint( + {"consultation_id": str(consultation_id), "version": if_match} + ) + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, + DRAFT_SUBMIT_OPERATION, + idempotency_key, + request_fingerprint, + ) + if replay is not None: + return replay + + try: + consultation = self.consultations.get_for_update( + workspace_id=context.workspace_id, consultation_id=consultation_id + ) + if consultation is None: + raise _not_found() + self._require_draft_owner(context, consultation) + if if_match is not None and if_match != consultation_etag(consultation): + raise _precondition_failed() + if consultation.status != DRAFT_STATUS: + raise _conflict( + "Only a draft consultation can be submitted.", + code="INVALID_STATE", + ) + record = ( + self.idempotency.reserve( + workspace_id=context.workspace_id, + operation=DRAFT_SUBMIT_OPERATION, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) + if idempotency_key is not None + else None + ) + self.consultations.submit_draft(consultation, submitted_at=_now()) + self.audit.append( + workspace_id=context.workspace_id, + action="consultation.submitted", + entity_type="consultation", + entity_id=consultation.id, + actor_id=context.actor_id, + snapshot={ + "consultation_id": str(consultation.id), + "status": consultation.status, + }, + correlation_id=correlation_id, + ) + if record is not None: + self.idempotency.complete( + record, + response_status=200, + response_body={"consultation_id": str(consultation.id)}, + ) + self._session.commit() + except IntegrityError: + self._session.rollback() + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, + DRAFT_SUBMIT_OPERATION, + idempotency_key, + request_fingerprint, + ) + if replay is not None: + return replay + raise + except ProblemError: + self._session.rollback() + raise + return IntakeResult(consultation=consultation, replayed=False) + # -- reads ------------------------------------------------------------ def get( @@ -427,6 +687,22 @@ def triage( # -- internals -------------------------------------------------------- + @staticmethod + def _require_draft_owner( + context: AuthorizationContext, consultation: Consultation + ) -> None: + """Only the draft author or a workspace administrator may mutate a draft.""" + if consultation.author_id == context.actor_id: + return + if context.role in ADMINISTRATOR_ROLES: + return + raise ProblemError( + status_code=403, + code="FORBIDDEN", + detail="Only the draft author or a workspace administrator may do this.", + title="Forbidden", + ) + def _replay( self, workspace_id: UUID, diff --git a/services/work-management/tests/test_consultation_draft_integration.py b/services/work-management/tests/test_consultation_draft_integration.py new file mode 100644 index 0000000..170713e --- /dev/null +++ b/services/work-management/tests/test_consultation_draft_integration.py @@ -0,0 +1,442 @@ +"""HTTP and concurrency integration tests for consultation drafts (Issue #4). + +Drafts add a DRAFT consultation status and dedicated create/edit/submit +endpoints on top of the direct-submission intake. The tests exercise the public +contract behaviour: draft creation, author/admin-only editing, the DRAFT->OPEN +submission transition, repeated submission rejection, optimistic concurrency +(If-Match), idempotent replay, and authorization/tenant isolation. A service +level concurrency test proves the row lock lets only one concurrent submit win. +They skip themselves when no test database is configured. +""" + +from __future__ import annotations + +import threading +from uuid import uuid4 + +from fastapi.testclient import TestClient +from eventflow_work_management.api.app import create_app +from eventflow_work_management.authorization.service import AuthorizationContext +from eventflow_work_management.db.engine import session_factory +from eventflow_work_management.models import WorkspaceMembership +from eventflow_work_management.security.config import AuthSettings +from eventflow_work_management.security.crypto import TokenCipher +from eventflow_work_management.security.errors import ProblemError +from eventflow_work_management.security.principal import AuthenticatedPrincipal +from eventflow_work_management.services.consultation import ConsultationService +from support import ( + create_project, + create_workspace, + migrated_engine, + require_database_url, + session_scope, + temporary_database, +) + +ISSUER = "https://id.example.test/realms/eventflow" + + +class FakeOIDC: + """Maps each bearer token to a distinct internal identity.""" + + def validate_access_token(self, token: str) -> dict: + return {"iss": ISSUER, "sub": token} + + def close(self) -> None: # pragma: no cover - lifecycle no-op + return None + + +def _settings() -> AuthSettings: + return AuthSettings( + environment="test", + issuer=ISSUER, + audience="eventflow-api", + web_client_id="eventflow-web-bff", + native_client_ids=("eventflow-desktop", "eventflow-vscode"), + redirect_uri="https://api.example.test/v1/auth/oidc/callback", + frontend_redirect_uri="https://web.example.test/auth/complete", + allowed_origins=("https://web.example.test",), + token_encryption_keys=(TokenCipher.generate_key(),), + ) + + +def _bearer(subject: str) -> dict: + return {"Authorization": f"Bearer {subject}"} + + +def _client(engine) -> TestClient: + app = create_app( + settings=_settings(), + session_factory_override=session_factory(engine), + oidc_client=FakeOIDC(), + ) + return TestClient(app) + + +def _principal_id(client: TestClient, subject: str) -> str: + response = client.get("/v1/auth/principal", headers=_bearer(subject)) + assert response.status_code == 200, response.text + return response.json()["user_id"] + + +def _make_workspace(client: TestClient, subject: str, name: str) -> str: + created = client.post( + "/v1/workspaces", + json={"name": name, "slug": name.lower().replace(" ", "-")}, + headers=_bearer(subject), + ) + assert created.status_code == 201, created.text + return created.json()["workspace_id"] + + +def _make_project(client: TestClient, subject: str, workspace_id: str) -> str: + created = client.post( + f"/v1/workspaces/{workspace_id}/projects", + json={"key": "PAY", "name": "Payments"}, + headers=_bearer(subject), + ) + assert created.status_code == 201, created.text + return created.json()["project_id"] + + +def _invite_and_accept( + client: TestClient, owner: str, workspace_id: str, invitee: str, role: str +) -> None: + invitee_id = _principal_id(client, invitee) + invitation = client.post( + f"/v1/workspaces/{workspace_id}/invitations", + json={"invited_user_id": invitee_id, "role": role}, + headers=_bearer(owner), + ) + assert invitation.status_code == 201, invitation.text + body = invitation.json() + accepted = client.post( + f"/v1/workspaces/{workspace_id}/invitations/{body['invitation']['invitation_id']}/accept", + json={"token": body["token"]}, + headers=_bearer(invitee), + ) + assert accepted.status_code == 201, accepted.text + + +def _create_draft(client: TestClient, subject: str, workspace_id: str, project_id: str, **extra): + payload = { + "project_id": project_id, + "origin": "vscode", + "title": "Draft null pointer on empty cart", + "description": "Submitting an empty cart returns 500 instead of 400.", + } + payload.update(extra) + return client.post( + f"/v1/workspaces/{workspace_id}/consultations/drafts", + json=payload, + headers=_bearer(subject), + ) + + +def _triage_body(): + return { + "category": "bug", + "priority": "high", + "triage_notes": "Reproduced locally against the current build.", + "bug_criteria": { + "expected_behavior": "Returns 400 with a problem document.", + "observed_behavior": "Raises an unhandled 500 error.", + "reproduction_steps": ["Submit an empty cart payload."], + "environment": "Python 3.11", + "severity": "high", + "is_regression": False, + }, + } + + +def test_draft_is_not_triageable_until_submitted(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine) as client: + workspace_id = _make_workspace(client, "alice", "Draft Co") + project_id = _make_project(client, "alice", workspace_id) + + created = _create_draft( + client, "alice", workspace_id, project_id, origin="web", category="bug" + ) + assert created.status_code == 201, created.text + body = created.json() + consultation_id = body["consultation_id"] + assert body["status"] == "draft" + assert body["priority"] == "medium" + assert body["category"] == "bug" + assert created.headers["ETag"].startswith('"') + assert created.headers["Location"].endswith(consultation_id) + + listed = client.get( + f"/v1/workspaces/{workspace_id}/consultations", headers=_bearer("alice") + ) + assert listed.status_code == 200 + assert [item["status"] for item in listed.json()["items"]] == ["draft"] + + triage_blocked = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", + json=_triage_body(), + headers=_bearer("alice"), + ) + assert triage_blocked.status_code == 409 + assert triage_blocked.json()["code"] == "INVALID_STATE" + + submitted = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", + headers=_bearer("alice"), + ) + assert submitted.status_code == 200, submitted.text + assert submitted.json()["status"] == "open" + assert submitted.headers["ETag"] != created.headers["ETag"] + + triaged = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", + json=_triage_body(), + headers={**_bearer("alice"), "If-Match": submitted.headers["ETag"]}, + ) + assert triaged.status_code == 200, triaged.text + assert triaged.json()["status"] == "triaging" + + # A submitted consultation is no longer a draft. + repeated = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", + headers=_bearer("alice"), + ) + assert repeated.status_code == 409 + assert repeated.json()["code"] == "INVALID_STATE" + + +def test_update_draft_partial_clear_and_optimistic_lock(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine) as client: + workspace_id = _make_workspace(client, "alice", "Edit Co") + project_id = _make_project(client, "alice", workspace_id) + + created = _create_draft( + client, "alice", workspace_id, project_id, category="bug" + ) + assert created.status_code == 201, created.text + consultation_id = created.json()["consultation_id"] + etag = created.headers["ETag"] + + updated = client.patch( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}", + json={"title": "Edited draft title", "description": "Edited description body."}, + headers={**_bearer("alice"), "If-Match": etag}, + ) + assert updated.status_code == 200, updated.text + assert updated.json()["title"] == "Edited draft title" + assert updated.headers["ETag"] != etag + new_etag = updated.headers["ETag"] + + stale = client.patch( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}", + json={"title": "Stale write"}, + headers={**_bearer("alice"), "If-Match": etag}, + ) + assert stale.status_code == 412 + + cleared = client.patch( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}", + json={"category": None}, + headers={**_bearer("alice"), "If-Match": new_etag}, + ) + assert cleared.status_code == 200, cleared.text + assert cleared.json()["category"] is None + + empty = client.patch( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}", + json={}, + headers=_bearer("alice"), + ) + assert empty.status_code == 400 + + client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", + headers=_bearer("alice"), + ) + after_submit = client.patch( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}", + json={"title": "Too late to edit"}, + headers=_bearer("alice"), + ) + assert after_submit.status_code == 409 + assert after_submit.json()["code"] == "INVALID_STATE" + + +def test_draft_editing_is_author_or_admin_only_and_tenant_isolated(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine) as client: + workspace_id = _make_workspace(client, "owner", "Draft Authz") + project_id = _make_project(client, "owner", workspace_id) + _invite_and_accept(client, "owner", workspace_id, "author", "contributor") + _invite_and_accept(client, "owner", workspace_id, "peer", "contributor") + _invite_and_accept(client, "owner", workspace_id, "root", "admin") + _invite_and_accept(client, "owner", workspace_id, "watch", "viewer") + + created = _create_draft(client, "author", workspace_id, project_id) + assert created.status_code == 201, created.text + consultation_id = created.json()["consultation_id"] + + # A different contributor in the same workspace may not edit the draft. + peer_edit = client.patch( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}", + json={"title": "Peer tampering"}, + headers=_bearer("peer"), + ) + assert peer_edit.status_code == 403 + peer_submit = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", + headers=_bearer("peer"), + ) + assert peer_submit.status_code == 403 + + # A viewer cannot create a draft at all. + assert _create_draft(client, "watch", workspace_id, project_id).status_code == 403 + + # A non-member cannot see or mutate the draft. + assert ( + client.patch( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}", + json={"title": "Intruder"}, + headers=_bearer("mallory"), + ).status_code + == 404 + ) + assert ( + client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", + headers=_bearer("mallory"), + ).status_code + == 404 + ) + + # A workspace admin may edit and submit the draft. + admin_edit = client.patch( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}", + json={"title": "Admin edit"}, + headers=_bearer("root"), + ) + assert admin_edit.status_code == 200, admin_edit.text + admin_submit = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", + headers=_bearer("root"), + ) + assert admin_submit.status_code == 200, admin_submit.text + assert admin_submit.json()["status"] == "open" + + +def test_draft_idempotent_replay_and_conflict(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine) as client: + workspace_id = _make_workspace(client, "alice", "Draft Idem") + project_id = _make_project(client, "alice", workspace_id) + + headers = {**_bearer("alice"), "Idempotency-Key": "draft-key-0001"} + payload = { + "project_id": project_id, + "origin": "desktop", + "title": "Offline draft", + "description": "Persisted from the desktop offline queue.", + } + first = client.post( + f"/v1/workspaces/{workspace_id}/consultations/drafts", + json=payload, + headers=headers, + ) + assert first.status_code == 201, first.text + consultation_id = first.json()["consultation_id"] + + replay = client.post( + f"/v1/workspaces/{workspace_id}/consultations/drafts", + json=payload, + headers=headers, + ) + assert replay.status_code == 201 + assert replay.json()["consultation_id"] == consultation_id + + conflict = client.post( + f"/v1/workspaces/{workspace_id}/consultations/drafts", + json={**payload, "title": "Different payload"}, + headers=headers, + ) + assert conflict.status_code == 409 + assert conflict.headers["content-type"] == "application/problem+json" + + submit_headers = {**_bearer("alice"), "Idempotency-Key": "draft-key-0002"} + submitted = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", + headers=submit_headers, + ) + assert submitted.status_code == 200, submitted.text + submit_replay = client.post( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", + headers=submit_headers, + ) + assert submit_replay.status_code == 200 + assert submit_replay.json()["status"] == "open" + + listed = client.get( + f"/v1/workspaces/{workspace_id}/consultations", headers=_bearer("alice") + ) + assert len(listed.json()["items"]) == 1 + + +def _service_context(workspace, author_id): + membership = WorkspaceMembership( + workspace_id=workspace.id, user_id=author_id, role="contributor" + ) + principal = AuthenticatedPrincipal( + user_id=author_id, issuer=ISSUER, subject="author" + ) + return AuthorizationContext( + principal=principal, workspace=workspace, membership=membership + ) + + +def test_concurrent_submit_allows_exactly_one_transition(): + admin_url = require_database_url() + with migrated_engine(admin_url) as engine: + with session_scope(engine) as setup_session: + workspace = create_workspace(setup_session) + project = create_project(setup_session, workspace) + author_id = uuid4() + context = _service_context(workspace, author_id) + draft = ConsultationService(setup_session).create_draft( + context, + project_id=project.id, + origin="web", + title="Concurrent draft", + description="A draft submitted from two clients at once.", + ).consultation + draft_id = draft.id + + barrier = threading.Barrier(2) + successes: list = [] + errors: list[Exception] = [] + + def worker(): + try: + with session_scope(engine) as session: + barrier.wait(timeout=10) + result = ConsultationService(session).submit_draft( + context, draft_id + ) + successes.append(result) + except Exception as error: # noqa: BLE001 - asserted below + errors.append(error) + + threads = [threading.Thread(target=worker) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=30) + + assert len(successes) == 1, f"expected one winner, got {len(successes)}: {errors}" + assert len(errors) == 1 + assert isinstance(errors[0], ProblemError) + assert errors[0].code == "INVALID_STATE" + + with session_scope(engine) as session: + persisted = ConsultationService(session).get(context, draft_id) + assert persisted.status == "open" diff --git a/services/work-management/tests/test_migrations_integration.py b/services/work-management/tests/test_migrations_integration.py index 1292c7e..72bd942 100644 --- a/services/work-management/tests/test_migrations_integration.py +++ b/services/work-management/tests/test_migrations_integration.py @@ -98,7 +98,7 @@ def test_upgrade_creates_schema_guards_and_revision(): tables = _tables(engine) assert EXPECTED_TABLES <= tables assert "alembic_version" in tables - assert _revision(engine) == "0004_consultation_triage_detail" + assert _revision(engine) == "0005_consultation_drafts" assert EXPECTED_TRIGGERS <= _triggers(engine) assert EXPECTED_FUNCTIONS <= _functions(engine) assert EXPECTED_CONSTRAINTS <= _constraints(engine) @@ -109,7 +109,7 @@ def test_upgrade_is_idempotent(): with temporary_empty_database(admin_url) as (url, engine): run_migrations(url) run_migrations(url) - assert _revision(engine) == "0004_consultation_triage_detail" + assert _revision(engine) == "0005_consultation_drafts" def test_downgrade_removes_tables_triggers_and_functions(): diff --git a/services/work-management/tests/test_rbac_policy_unit.py b/services/work-management/tests/test_rbac_policy_unit.py index b138c6b..55e8855 100644 --- a/services/work-management/tests/test_rbac_policy_unit.py +++ b/services/work-management/tests/test_rbac_policy_unit.py @@ -91,6 +91,9 @@ def test_policy_matches_declared_openapi_roles(): "transferOwnership": Action.MEMBERSHIP_TRANSFER_OWNERSHIP, "createInvitation": Action.MEMBERSHIP_INVITE, "createConsultation": Action.CONSULTATION_CREATE, + "createConsultationDraft": Action.CONSULTATION_CREATE, + "updateConsultationDraft": Action.CONSULTATION_UPDATE, + "submitConsultationDraft": Action.CONSULTATION_SUBMIT, "triageConsultation": Action.CONSULTATION_TRIAGE, "decideProposal": Action.APPROVAL_DECIDE, "moveBoardCard": Action.BOARD_MANAGE, diff --git a/tests/contracts/test_work_management_api.py b/tests/contracts/test_work_management_api.py index 7a8cc59..5023bef 100644 --- a/tests/contracts/test_work_management_api.py +++ b/tests/contracts/test_work_management_api.py @@ -59,7 +59,10 @@ EXPECTED_IDEMPOTENCY_OPERATIONS = { "POST /v1/workspaces/{workspace_id}/consultations", + "POST /v1/workspaces/{workspace_id}/consultations/drafts", "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", + "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", + "PATCH /v1/workspaces/{workspace_id}/consultations/{consultation_id}", "POST /v1/workspaces/{workspace_id}/proposals", "POST /v1/workspaces/{workspace_id}/proposals/{proposal_id}/submit", "POST /v1/workspaces/{workspace_id}/proposals/{proposal_id}/decide", diff --git a/tests/security/test_security_contracts.py b/tests/security/test_security_contracts.py index 3f512ac..b6db5f4 100644 --- a/tests/security/test_security_contracts.py +++ b/tests/security/test_security_contracts.py @@ -309,7 +309,10 @@ def test_idempotency_key_declared_on_retryable_operations(wm_openapi): operations.append(f"{method.upper()} {path}") assert operations == [ "POST /v1/workspaces/{workspace_id}/consultations", + "POST /v1/workspaces/{workspace_id}/consultations/drafts", "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", + "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", + "PATCH /v1/workspaces/{workspace_id}/consultations/{consultation_id}", "POST /v1/workspaces/{workspace_id}/proposals", "POST /v1/workspaces/{workspace_id}/proposals/{proposal_id}/submit", "POST /v1/workspaces/{workspace_id}/proposals/{proposal_id}/decide", From a4c8724a2c2c62d77ae3c20909ab259b1019a82d Mon Sep 17 00:00:00 2001 From: Jackson Severino da Rocha Date: Fri, 16 Oct 2026 15:32:08 -0300 Subject: [PATCH 3/3] feat(work-management): add consultation attachments, evidence, and history (#4) --- contracts/openapi/work-management.yaml | 357 +++++++++ contracts/schemas/work_management.yaml | 225 ++++++ docs/architecture/work-management-api.md | 47 ++ docs/architecture/work-management-workflow.md | 57 ++ docs/security/open-decisions.md | 12 + docs/security/risk-register.md | 16 +- docs/security/security-observability.md | 8 +- docs/security/traceability.md | 16 +- docs/security/untrusted-content.md | 11 +- .../src/eventflow_work_management/api/app.py | 10 + .../api/attachment_routes.py | 351 +++++++++ .../authorization/policy.py | 25 + .../0006_consultation_attachments_evidence.py | 263 +++++++ .../models/__init__.py | 8 + .../models/attachment.py | 143 ++++ .../eventflow_work_management/models/enums.py | 7 + .../models/evidence.py | 88 +++ .../repositories/attachment_repository.py | 184 +++++ .../repositories/audit_repository.py | 28 +- .../security/attachment_scanner.py | 61 ++ .../services/attachment.py | 449 ++++++++++++ .../services/evidence.py | 257 +++++++ .../services/history.py | 111 +++ ...est_consultation_attachment_integration.py | 685 ++++++++++++++++++ .../tests/test_migrations_integration.py | 7 +- .../work-management/tests/test_models_unit.py | 6 + .../tests/test_rbac_policy_unit.py | 8 + tests/contracts/test_work_management_api.py | 2 + tests/security/test_security_contracts.py | 2 + 29 files changed, 3422 insertions(+), 22 deletions(-) create mode 100644 services/work-management/src/eventflow_work_management/api/attachment_routes.py create mode 100644 services/work-management/src/eventflow_work_management/migrations/versions/0006_consultation_attachments_evidence.py create mode 100644 services/work-management/src/eventflow_work_management/models/attachment.py create mode 100644 services/work-management/src/eventflow_work_management/models/evidence.py create mode 100644 services/work-management/src/eventflow_work_management/repositories/attachment_repository.py create mode 100644 services/work-management/src/eventflow_work_management/security/attachment_scanner.py create mode 100644 services/work-management/src/eventflow_work_management/services/attachment.py create mode 100644 services/work-management/src/eventflow_work_management/services/evidence.py create mode 100644 services/work-management/src/eventflow_work_management/services/history.py create mode 100644 services/work-management/tests/test_consultation_attachment_integration.py diff --git a/contracts/openapi/work-management.yaml b/contracts/openapi/work-management.yaml index 5e50f2b..36e47b7 100644 --- a/contracts/openapi/work-management.yaml +++ b/contracts/openapi/work-management.yaml @@ -19,6 +19,9 @@ servers: tags: - name: Consultations - name: Triage + - name: Attachments + - name: Evidence + - name: History - name: Proposals - name: Approvals - name: WorkItems @@ -398,6 +401,354 @@ paths: default: $ref: '#/components/responses/UnexpectedError' + /v1/workspaces/{workspace_id}/consultations/{consultation_id}/attachments: + post: + operationId: createConsultationAttachment + tags: [Attachments] + summary: Upload a consultation attachment + description: >- + Accepts a base64-encoded object, validates its size, filename, and content + type, scans it through the configured malware scanner, and records the + outcome. An object that is not proven clean is quarantined or rejected and + is never downloadable. + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ConsultationId' + - $ref: '#/components/parameters/IdempotencyKeyOptional' + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/CreateAttachmentRequest' + responses: + '201': + description: Attachment recorded; its status reflects the scan verdict. + headers: + ETag: + $ref: '#/components/headers/ETag' + Location: + description: URI of the created attachment resource. + schema: + type: string + format: uri-reference + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/ConsultationAttachment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + get: + operationId: listConsultationAttachments + tags: [Attachments] + summary: List a consultation's attachments + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner, viewer] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ConsultationId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: The consultation's attachment metadata. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/ConsultationAttachmentList' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/consultations/{consultation_id}/attachments/{attachment_id}: + get: + operationId: getConsultationAttachment + tags: [Attachments] + summary: Read one attachment's metadata + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner, viewer] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ConsultationId' + - $ref: '#/components/parameters/AttachmentId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: Attachment metadata. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/ConsultationAttachment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + delete: + operationId: deleteConsultationAttachment + tags: [Attachments] + summary: Delete an attachment and its stored object + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ConsultationId' + - $ref: '#/components/parameters/AttachmentId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '204': + description: Attachment metadata and object were removed. + headers: + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/consultations/{consultation_id}/attachments/{attachment_id}/content: + get: + operationId: downloadConsultationAttachment + tags: [Attachments] + summary: Download an available attachment's object + description: >- + Serves the stored object as an opaque download with a nosniff header. A + quarantined or rejected attachment returns 409 and never exposes bytes. + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner, viewer] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ConsultationId' + - $ref: '#/components/parameters/AttachmentId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: The attachment object as an opaque byte stream. + headers: + ETag: + $ref: '#/components/headers/ETag' + Content-Disposition: + description: Forces the object to be saved rather than rendered. + schema: + type: string + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/octet-stream: + schema: + type: string + format: binary + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/consultations/{consultation_id}/evidence: + post: + operationId: createConsultationEvidence + tags: [Evidence] + summary: Add structured evidence to a consultation + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ConsultationId' + - $ref: '#/components/parameters/IdempotencyKeyOptional' + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/CreateEvidenceRequest' + responses: + '201': + description: Evidence recorded. + headers: + ETag: + $ref: '#/components/headers/ETag' + Location: + description: URI of the created evidence resource. + schema: + type: string + format: uri-reference + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/ConsultationEvidence' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + get: + operationId: listConsultationEvidence + tags: [Evidence] + summary: List a consultation's evidence + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner, viewer] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ConsultationId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: The consultation's evidence. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/ConsultationEvidenceList' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/consultations/{consultation_id}/history: + get: + operationId: listConsultationHistory + tags: [History] + summary: Read a consultation's lifecycle timeline + description: >- + Returns the append-only audit timeline for one consultation, oldest first, + including intake, draft, triage, attachment, and evidence events. + security: + - bearerAuth: [] + x-required-roles: [contributor, triager, approver, admin, owner, viewer] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ConsultationId' + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/PageCursor' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: A page of consultation lifecycle events. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/ConsultationHistoryPage' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + /v1/workspaces/{workspace_id}/proposals: post: operationId: createProposal @@ -1702,6 +2053,12 @@ components: required: true schema: $ref: '../schemas/work_management.yaml#/$defs/UUID' + AttachmentId: + name: attachment_id + in: path + required: true + schema: + $ref: '../schemas/work_management.yaml#/$defs/UUID' ProjectId: name: project_id in: path diff --git a/contracts/schemas/work_management.yaml b/contracts/schemas/work_management.yaml index f0c879e..733023a 100644 --- a/contracts/schemas/work_management.yaml +++ b/contracts/schemas/work_management.yaml @@ -40,6 +40,21 @@ $defs: type: string enum: [ready, in_progress, in_review, blocked, done, cancelled] + AttachmentStatus: + type: string + description: >- + Fail-closed attachment lifecycle. An upload starts quarantined and only + becomes available after an approved scanner returns a clean verdict. + enum: [quarantined, available, rejected] + + AttachmentScanVerdict: + type: string + enum: [clean, infected, unknown] + + EvidenceKind: + type: string + enum: [text, link, attachment] + Role: type: string description: >- @@ -622,6 +637,216 @@ $defs: items: type: string + ConsultationAttachment: + type: object + description: >- + Metadata for one uploaded consultation attachment. Object bytes are never + inlined in a response; content is served separately as an opaque download. + additionalProperties: false + required: [attachment_id, workspace_id, consultation_id, file_name, content_type, size_bytes, checksum_sha256, status, scan_verdict, uploaded_by, created_at] + properties: + attachment_id: + $ref: '#/$defs/UUID' + workspace_id: + $ref: '#/$defs/UUID' + consultation_id: + $ref: '#/$defs/UUID' + file_name: + type: string + minLength: 1 + maxLength: 255 + content_type: + type: string + minLength: 1 + maxLength: 255 + description: The detected content type, which must match the declared type. + size_bytes: + type: integer + minimum: 0 + maximum: 5242880 + checksum_sha256: + type: string + pattern: ^[0-9a-f]{64}$ + status: + $ref: '#/$defs/AttachmentStatus' + scan_verdict: + $ref: '#/$defs/AttachmentScanVerdict' + rejection_reason: + anyOf: + - type: string + minLength: 1 + maxLength: 120 + - type: 'null' + uploaded_by: + $ref: '#/$defs/UUID' + created_at: + $ref: '#/$defs/RFC3339DateTime' + + CreateAttachmentRequest: + type: object + description: >- + Request DTO for POST .../attachments. Bytes are base64 encoded so the upload + reuses the authenticated JSON transport; the server enforces the decoded + size limit and the content-type allowlist. + additionalProperties: false + required: [file_name, content_type, content_base64] + properties: + file_name: + type: string + minLength: 1 + maxLength: 255 + content_type: + type: string + minLength: 1 + maxLength: 255 + content_base64: + type: string + minLength: 1 + maxLength: 8388608 + + ConsultationAttachmentList: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: + $ref: '#/$defs/ConsultationAttachment' + + ConsultationEvidence: + type: object + description: One structured piece of evidence attached to a consultation. + additionalProperties: false + required: [evidence_id, workspace_id, consultation_id, kind, created_by, created_at] + properties: + evidence_id: + $ref: '#/$defs/UUID' + workspace_id: + $ref: '#/$defs/UUID' + consultation_id: + $ref: '#/$defs/UUID' + kind: + $ref: '#/$defs/EvidenceKind' + description: + anyOf: + - type: string + minLength: 1 + maxLength: 500 + - type: 'null' + content: + anyOf: + - type: string + minLength: 1 + maxLength: 4096 + - type: 'null' + attachment_id: + anyOf: + - $ref: '#/$defs/UUID' + - type: 'null' + created_by: + $ref: '#/$defs/UUID' + created_at: + $ref: '#/$defs/RFC3339DateTime' + + CreateEvidenceRequest: + type: object + description: Request DTO for POST .../evidence. + additionalProperties: false + required: [kind] + properties: + kind: + $ref: '#/$defs/EvidenceKind' + description: + type: string + minLength: 1 + maxLength: 500 + content: + type: string + minLength: 1 + maxLength: 4096 + attachment_id: + $ref: '#/$defs/UUID' + allOf: + - if: + properties: + kind: + const: attachment + required: [kind] + then: + required: [attachment_id] + not: + required: [content] + - if: + properties: + kind: + enum: [text, link] + required: [kind] + then: + required: [content] + not: + required: [attachment_id] + + ConsultationEvidenceList: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: + $ref: '#/$defs/ConsultationEvidence' + + ConsultationHistoryEntry: + type: object + description: One append-only lifecycle event in a consultation timeline. + additionalProperties: false + required: [audit_id, workspace_id, action, entity_type, entity_id, occurred_at, snapshot_hash] + properties: + audit_id: + $ref: '#/$defs/UUID' + workspace_id: + $ref: '#/$defs/UUID' + actor_id: + anyOf: + - $ref: '#/$defs/UUID' + - type: 'null' + action: + type: string + minLength: 3 + maxLength: 120 + entity_type: + type: string + minLength: 3 + maxLength: 80 + entity_id: + $ref: '#/$defs/UUID' + occurred_at: + $ref: '#/$defs/RFC3339DateTime' + snapshot: + anyOf: + - type: object + - type: 'null' + snapshot_hash: + type: string + pattern: ^[0-9a-f]{64}$ + correlation_id: + anyOf: + - $ref: '#/$defs/UUID' + - type: 'null' + + ConsultationHistoryPage: + type: object + additionalProperties: false + required: [items, page_info] + properties: + items: + type: array + items: + $ref: '#/$defs/ConsultationHistoryEntry' + page_info: + $ref: '#/$defs/PageInfo' + TriageConsultationRequest: type: object description: Request DTO for POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage. diff --git a/docs/architecture/work-management-api.md b/docs/architecture/work-management-api.md index 33dbabf..b3b7a28 100644 --- a/docs/architecture/work-management-api.md +++ b/docs/architecture/work-management-api.md @@ -79,6 +79,9 @@ section 3.1) and for contract tests. | `CreateProjectRequest`, `UpdateProjectRequest` | project administration (Issue #9) | | `WorkspaceMember`, `ChangeMemberRoleRequest` | membership administration (Issue #9) | | `WorkspaceInvitation`, `CreatedInvitation`, `CreateInvitationRequest`, `AcceptInvitationRequest` | invitation administration (Issue #9) | +| `CreateAttachmentRequest`, `ConsultationAttachment`, `ConsultationAttachmentList` | consultation attachments (Issue #4) | +| `CreateEvidenceRequest`, `ConsultationEvidence`, `ConsultationEvidenceList` | consultation evidence (Issue #4) | +| `ConsultationHistoryEntry`, `ConsultationHistoryPage` | consultation history (Issue #4) | Entity DTOs (`Consultation`, `Proposal`, `Approval`, `WorkItem`, `BoardCard`) remain the canonical shapes shared by all three clients. @@ -169,6 +172,8 @@ Amendment A-02, every unsafe operation a client may retry accepts an | `PATCH .../consultations/{consultation_id}` | `Idempotency-Key` | No | | `POST .../consultations/{consultation_id}/submit` | `Idempotency-Key` | No | | `POST .../consultations/{consultation_id}/triage` | `Idempotency-Key` | No | +| `POST .../consultations/{consultation_id}/attachments` | `Idempotency-Key` | No | +| `POST .../consultations/{consultation_id}/evidence` | `Idempotency-Key` | No | | `POST .../proposals` | `Idempotency-Key` | No | | `POST .../proposals/{proposal_id}/submit` | `Idempotency-Key` | No | | `POST .../proposals/{proposal_id}/decide` | `Idempotency-Key` | Yes | @@ -266,6 +271,48 @@ role applies) are separate from the role list and must be checked by the backend. A cross-workspace resource is reported as `404`; a visible resource with insufficient action permission is `403`. +### 8.2 Consultation attachments, evidence, and history (Issue #4) + +Issue #4 adds the evidence pipeline that sits beside consultation intake. All +three resource families are workspace-scoped and use the same authorization, +tenant-isolation, and problem+json rules as the rest of the API; `workspace_id` +and `consultation_id` in the path are locators, never authority. + +| Operation | Method and path | Required role (`x-required-roles`) | +|---|---|---| +| Upload attachment | `POST .../consultations/{consultation_id}/attachments` | `owner`, `admin`, `triager`, `approver`, `contributor` | +| List attachments | `GET .../consultations/{consultation_id}/attachments` | any member (incl. `viewer`) | +| Read attachment | `GET .../consultations/{consultation_id}/attachments/{attachment_id}` | any member (incl. `viewer`) | +| Download content | `GET .../consultations/{consultation_id}/attachments/{attachment_id}/content` | any member (incl. `viewer`) | +| Delete attachment | `DELETE .../consultations/{consultation_id}/attachments/{attachment_id}` | uploader or `owner`/`admin` | +| Create evidence | `POST .../consultations/{consultation_id}/evidence` | `owner`, `admin`, `triager`, `approver`, `contributor` | +| List evidence | `GET .../consultations/{consultation_id}/evidence` | any member (incl. `viewer`) | +| Read history | `GET .../consultations/{consultation_id}/history` | any member (incl. `viewer`) | + +- **Upload transport.** Bytes travel as base64 in the JSON body + (`content_base64`) so the upload reuses the authenticated JSON transport; the + server enforces the size limit and content-type allowlist on the decoded bytes, + never the declared type alone. Upload returns `201` with the created resource + whose `status` is `available`, `quarantined`, or `rejected`; a client error + (bad filename, unsupported or mismatched type, oversized or malformed content) + is a `400`/`422` problem and stores nothing. +- **Fail-closed scanning.** With no scanning backend configured every upload is + quarantined (`scan_verdict: unknown`); quarantined and rejected objects are + never downloadable (`409 ATTACHMENT_QUARANTINED`) and can never be cited as + evidence. +- **Quota.** Exceeding the per-consultation attachment quota returns + `409 ATTACHMENT_LIMIT_REACHED`. +- **Evidence shape.** Evidence is `text`, `link`, or `attachment`; an + `attachment` entry requires an `attachment_id` that points at an `available` + attachment in the same consultation, and text/link entries must not carry one. +- **History.** History is a read-only, cursor-paginated projection of the + append-only audit trail for one consultation. It uses the same page envelope + and `limit`/`cursor` rules as other list operations but is not a filterable + entity list; attachment and evidence lifecycle actions appear in the timeline. +- **Idempotency.** Attachment upload and evidence creation accept an optional + `Idempotency-Key` (section 7); an equivalent replay returns the original + resource and a different fingerprint returns `409`. + ## 9. Internal events Versioned internal events (for example `work_item.created`, diff --git a/docs/architecture/work-management-workflow.md b/docs/architecture/work-management-workflow.md index b209dcb..8079e17 100644 --- a/docs/architecture/work-management-workflow.md +++ b/docs/architecture/work-management-workflow.md @@ -113,7 +113,32 @@ classDiagram +DateTime updated_at } + class ConsultationAttachment { + +UUID attachment_id + +UUID consultation_id + +UUID uploaded_by + +String file_name + +String content_type + +Integer size_bytes + +String checksum_sha256 + +AttachmentStatus status + +AttachmentScanVerdict scan_verdict + } + + class ConsultationEvidence { + +UUID evidence_id + +UUID consultation_id + +UUID created_by + +EvidenceKind kind + +String description + +String content + +UUID attachment_id + } + Consultation "1" --> "0..*" Proposal : derives + Consultation "1" --> "0..*" ConsultationAttachment : attaches + Consultation "1" --> "0..*" ConsultationEvidence : evidenced by + ConsultationEvidence "0..*" --> "0..1" ConsultationAttachment : cites Proposal "1" --> "0..*" Approval : evaluates Approval "1" --> "0..1" WorkItem : authorizes WorkItem "1" --> "1..*" BoardCard : projects into @@ -259,6 +284,38 @@ stateDiagram-v2 CANCELLED --> [*] ``` +### 3.4 Consultation Attachments, Evidence, and History + +Attachments and evidence are supporting records that enrich a Consultation +without changing its identity or bypassing triage. They are not part of the +Consultation → Proposal → Approval → WorkItem → BoardCard chain and never create +a Proposal, Approval, WorkItem, or BoardCard. + +- **Attachment lifecycle.** An upload is validated (filename, size, declared vs. + sniffed content type against a published allowlist), then scanned through an + injected, fail-closed scanner. A `clean` verdict becomes `available`; an + `unknown` verdict is `quarantined` and never downloadable; an `infected` + verdict is `rejected` and its bytes are not stored. Every outcome is recorded + in the append-only audit trail. Deletion removes the object and its metadata + and is restricted to the uploader or a workspace administrator. +- **Evidence.** Evidence is `text`, `link`, or a reference to an `available` + attachment in the same consultation; a quarantined or rejected attachment can + never be cited. Text and link evidence require inline `content`. +- **History.** The consultation history endpoint is a read-only, + cursor-paginated projection of the append-only audit records for that + consultation, so intake, draft edits, triage, uploads, evidence, and deletion + appear as one ordered timeline. + +```mermaid +stateDiagram-v2 + [*] --> AVAILABLE: Clean scan + [*] --> QUARANTINED: Unknown scan (fail closed) + [*] --> REJECTED: Infected scan (bytes not stored) + AVAILABLE --> [*]: Deleted by uploader or admin + QUARANTINED --> [*]: Deleted by uploader or admin + REJECTED --> [*]: Deleted by uploader or admin +``` + --- ## 4. Comprehensive Transition Matrix diff --git a/docs/security/open-decisions.md b/docs/security/open-decisions.md index 2b76447..ba6e288 100644 --- a/docs/security/open-decisions.md +++ b/docs/security/open-decisions.md @@ -43,6 +43,12 @@ unresolved item below names its owner and closure criteria. routed? - **Owner:** [Issue #12](https://github.com/jacksonsr451/EventFlow/issues/12). - **Closure criteria:** scanner selected and quarantine flow defined (`AT-03`). +- **Partial resolution:** [Issue #4](https://github.com/jacksonsr451/EventFlow/issues/4) + defines the quarantine flow as a fail-closed boundary: uploads pass through an + injected scanner, an `unknown` verdict is quarantined and never becomes + available, an `infected` verdict is rejected without storing bytes, and every + outcome is audited. Selecting and configuring the concrete engine remains with + Issue #12; until then no scanner is configured and every upload is quarantined. ## OD-05 — Attachment allowlist and limits @@ -50,6 +56,12 @@ unresolved item below names its owner and closure criteria. limits? - **Owner:** [Issue #12](https://github.com/jacksonsr451/EventFlow/issues/12). - **Closure criteria:** allowlist and limits published (`AT-01`, `AT-02`). +- **Resolution:** [Issue #4](https://github.com/jacksonsr451/EventFlow/issues/4) + publishes the allowlist `image/png`, `image/jpeg`, `image/gif`, + `application/pdf`, `text/plain` (SVG and HTML are rejected), a 5 MiB per-file + limit, and a 25-attachment per-consultation quota, enforced on decoded bytes + after magic-byte sniffing. Any later provider-specific change remains with + Issue #12. ## OD-06 — AI provider and data boundary diff --git a/docs/security/risk-register.md b/docs/security/risk-register.md index 2707f56..56f1dcd 100644 --- a/docs/security/risk-register.md +++ b/docs/security/risk-register.md @@ -47,14 +47,14 @@ never counted as an implemented security control. | R-020 | Webhooks | TM-WH-06 | Undetectable forgeries after secret compromise | low | high | Specified | Issues #27, #28 | WH-08, WH-10 | tests/security/test_security_docs.py | | R-021 | Webhooks | TM-WH-07 | Duplicate effects from blind retries | medium | medium | Specified | Issue #46 | WH-09, WH-05 | tests/security/test_security_docs.py | | R-022 | Webhooks | TM-WH-08 | Repository data disclosure through logged payloads | medium | medium | Specified (Pending Decision) | Issues #27, #28 | WH-10 | tests/security/test_security_docs.py | -| R-023 | Attachments | TM-AT-01 | Endpoint compromise from a malicious attachment | medium | high | Specified | Issues #4, #12 | AT-03, AT-02 | tests/security/test_security_docs.py | -| R-024 | Attachments | TM-AT-02 | Control bypass through a disguised file type | medium | medium | Specified | Issue #12 | AT-01, AT-02 | tests/security/test_security_docs.py | -| R-025 | Attachments | TM-AT-03 | Object overwrite or exposure via path traversal | low | high | Specified | Issue #12 | AT-07 | tests/security/test_security_docs.py | -| R-026 | Attachments | TM-AT-04 | Cross-tenant attachment disclosure | medium | critical | Specified | Issue #47 | TI-04, AT-04 | tests/security/test_security_contracts.py | -| R-027 | Attachments | TM-AT-05 | Unauthorized download through a leaked link | medium | high | Specified | Issue #9 | AT-05, TI-03 | tests/security/test_security_contracts.py | -| R-028 | Attachments | TM-AT-06 | Script execution from inline HTML or SVG | medium | high | Specified | Issues #14, #22 | AT-06 | tests/security/test_security_docs.py | -| R-029 | Attachments | TM-AT-07 | Availability and cost impact from large uploads | medium | medium | Specified | Issue #12 | AT-02 | tests/security/test_security_docs.py | -| R-030 | Attachments | TM-AT-08 | Deleted attachments remaining retrievable | medium | medium | Specified | Issue #12 | AT-09, DG-04 | tests/security/test_security_docs.py | +| R-023 | Attachments | TM-AT-01 | Endpoint compromise from a malicious attachment | medium | high | Implemented | Issues #4, #12 | AT-03, AT-02 | services/work-management/tests/test_consultation_attachment_integration.py | +| R-024 | Attachments | TM-AT-02 | Control bypass through a disguised file type | medium | medium | Implemented | Issue #4 | AT-01, AT-02 | services/work-management/tests/test_consultation_attachment_integration.py | +| R-025 | Attachments | TM-AT-03 | Object overwrite or exposure via path traversal | low | high | Implemented | Issue #4 | AT-07 | services/work-management/tests/test_consultation_attachment_integration.py | +| R-026 | Attachments | TM-AT-04 | Cross-tenant attachment disclosure | medium | critical | Partially Implemented | Issues #4, #47 | TI-04, AT-04 | services/work-management/tests/test_consultation_attachment_integration.py | +| R-027 | Attachments | TM-AT-05 | Unauthorized download through a leaked link | medium | high | Implemented | Issues #4, #9 | AT-05, TI-03 | services/work-management/tests/test_consultation_attachment_integration.py | +| R-028 | Attachments | TM-AT-06 | Script execution from inline HTML or SVG | medium | high | Implemented | Issues #4, #14, #22 | AT-06 | services/work-management/tests/test_consultation_attachment_integration.py | +| R-029 | Attachments | TM-AT-07 | Availability and cost impact from large uploads | medium | medium | Implemented | Issue #4 | AT-02 | services/work-management/tests/test_consultation_attachment_integration.py | +| R-030 | Attachments | TM-AT-08 | Deleted attachments remaining retrievable | medium | medium | Implemented | Issues #4, #12 | AT-09, DG-04 | services/work-management/tests/test_consultation_attachment_integration.py | | R-031 | Tenant isolation | TM-MT-01 | Cross-tenant read via insecure direct object reference | high | critical | Specified | Issue #47 | TI-01, TI-04 | tests/security/test_security_contracts.py | | R-032 | Tenant isolation | TM-MT-02 | Cross-tenant write through a foreign workspace_id | high | critical | Specified | Issue #47 | TI-02, TI-04 | tests/security/test_security_contracts.py | | R-033 | Tenant isolation | TM-MT-03 | Resource discovery through enumerable identifiers | medium | medium | Specified | Issue #47 | TI-03, TI-07 | tests/security/test_security_contracts.py | diff --git a/docs/security/security-observability.md b/docs/security/security-observability.md index b837654..b2bf45b 100644 --- a/docs/security/security-observability.md +++ b/docs/security/security-observability.md @@ -8,8 +8,12 @@ constrains [Issue #47](https://github.com/jacksonsr451/EventFlow/issues/47) and the observability work in [Issue #46](https://github.com/jacksonsr451/EventFlow/issues/46). -Status: Specified with Issue #10 authentication events emitted to the append-only -authentication audit store. Centralized SIEM/alerting and other Issue #47 +Status: Partially implemented with Issue #10 authentication events emitted to the +append-only authentication audit store, and Issue #4 attachment and evidence +lifecycle actions (`attachment.uploaded`, `attachment.quarantined`, +`attachment.rejected`, `attachment.deleted`, `evidence.added`) emitted as +workspace-scoped append-only audit records that also project into the +consultation history. Centralized SIEM/alerting and other Issue #47 observability controls remain specified-only. The machine-readable taxonomy is [`contracts/schemas/security_events.yaml`](../../contracts/schemas/security_events.yaml). diff --git a/docs/security/traceability.md b/docs/security/traceability.md index 9564401..06921b5 100644 --- a/docs/security/traceability.md +++ b/docs/security/traceability.md @@ -94,15 +94,15 @@ Every control listed here is referenced by at least one threat. "State" uses the | SC-05 | untrusted-content.md | Specified | Stored script content does not execute in the application origin | Issue #14 tests | Issues #13, #14 | | SC-06 | untrusted-content.md | Specified | Model output is validated before use; input is separated from instructions | Issue #45 tests | Issue #45 | | SC-07 | untrusted-content.md | Specified | An RBAC check gates every authoritative effect | Issue #9 tests | Issue #9 | -| AT-01 | untrusted-content.md | Specified (Pending Decision) | A file whose content contradicts its declared type is rejected | Issue #12 tests | Issue #12 | -| AT-02 | untrusted-content.md | Specified (Pending Decision) | An upload above the size limit is rejected with 413 | Issue #12 tests | Issue #12 | -| AT-03 | untrusted-content.md | Specified | Malware is quarantined and rejected; attachment.rejected is emitted | Issue #12 tests | Issue #12 | -| AT-04 | untrusted-content.md | Specified | Objects live under a per-tenant prefix with no public path | tests/security/test_security_contracts.py | Issue #47 | -| AT-05 | untrusted-content.md | Specified | A download without membership is denied; links are short-lived | Issue #9 tests | Issue #9 | -| AT-06 | untrusted-content.md | Specified | HTML and SVG attachments are not rendered inline | Issue #14 tests | Issues #14, #22 | -| AT-07 | untrusted-content.md | Specified | A filename with traversal never influences the storage path | Issue #12 tests | Issue #12 | +| AT-01 | untrusted-content.md | Implemented | A file whose content contradicts its declared type is rejected | services/work-management/tests/test_consultation_attachment_integration.py | Issue #4 | +| AT-02 | untrusted-content.md | Implemented | An upload above the size limit or the per-consultation quota is rejected | services/work-management/tests/test_consultation_attachment_integration.py | Issue #4 | +| AT-03 | untrusted-content.md | Implemented | Malware is quarantined and rejected; attachment.rejected is emitted | services/work-management/tests/test_consultation_attachment_integration.py | Issues #4, #12 | +| AT-04 | untrusted-content.md | Partially Implemented | Objects are stored tenant-scoped with no public path; encryption at rest is pending | services/work-management/tests/test_consultation_attachment_integration.py | Issues #4, #12 | +| AT-05 | untrusted-content.md | Implemented | A download without membership is denied; quarantined content is not downloadable | services/work-management/tests/test_consultation_attachment_integration.py | Issues #4, #9 | +| AT-06 | untrusted-content.md | Implemented | HTML and SVG attachments are rejected by the allowlist and never rendered inline | services/work-management/tests/test_consultation_attachment_integration.py | Issues #4, #14, #22 | +| AT-07 | untrusted-content.md | Implemented | A filename with traversal never influences the storage path | services/work-management/tests/test_consultation_attachment_integration.py | Issue #4 | | AT-08 | untrusted-content.md | Specified | Stored attachments are encrypted at rest | Issue #12 tests | Issue #12 | -| AT-09 | untrusted-content.md | Specified | Deleting an attachment removes object, metadata, and derived data | tests/security/test_security_docs.py | Issue #12 | +| AT-09 | untrusted-content.md | Implemented | Deleting an attachment removes object, metadata, and derived data | services/work-management/tests/test_consultation_attachment_integration.py | Issues #4, #12 | | WH-01 | webhooks.md | Specified | A delivery with an invalid or missing signature is rejected before any effect | Issue #27 tests | Issues #27, #28 | | WH-02 | webhooks.md | Specified | Signature comparison does not leak timing | Issue #27 tests | Issues #27, #28 | | WH-03 | webhooks.md | Specified | Verification uses raw bytes; a re-serialized payload fails | Issue #27 tests | Issues #27, #28 | diff --git a/docs/security/untrusted-content.md b/docs/security/untrusted-content.md index b57ca96..97bb690 100644 --- a/docs/security/untrusted-content.md +++ b/docs/security/untrusted-content.md @@ -10,7 +10,16 @@ the clients ([Issue #14](https://github.com/jacksonsr451/EventFlow/issues/14), [Issue #22](https://github.com/jacksonsr451/EventFlow/issues/22)), and future AI assistance ([Issue #45](https://github.com/jacksonsr451/EventFlow/issues/45)). -Status: Specified (Phase 0). +Status: Partially implemented. The Work Management consultation service +([Issue #4](https://github.com/jacksonsr451/EventFlow/issues/4)) implements the +attachment controls AT-01, AT-02, AT-03, AT-04, AT-05, AT-06, AT-07 and AT-09: +uploads are size- and quota-bounded, content-sniffed against a published +allowlist, scanned through an injected fail-closed scanner that quarantines every +unscanned object, stored tenant-scoped with no public path, downloaded only +through an authorized endpoint with `nosniff`, and deleted with their metadata. +Encryption at rest (AT-08) and the scanning provider selection remain owned by +[Issue #12](https://github.com/jacksonsr451/EventFlow/issues/12). The source-code +and editor-context controls remain specified (Phase 0). ## Principle diff --git a/services/work-management/src/eventflow_work_management/api/app.py b/services/work-management/src/eventflow_work_management/api/app.py index eadc09c..064e05d 100644 --- a/services/work-management/src/eventflow_work_management/api/app.py +++ b/services/work-management/src/eventflow_work_management/api/app.py @@ -14,6 +14,9 @@ from sqlalchemy.orm import sessionmaker from eventflow_work_management.api.auth_routes import router as authentication_router +from eventflow_work_management.api.attachment_routes import ( + router as attachment_router, +) from eventflow_work_management.api.consultation_routes import ( router as consultation_router, ) @@ -27,6 +30,10 @@ from eventflow_work_management.api.workspace_routes import router as workspace_router from eventflow_work_management.config import database_url from eventflow_work_management.db.engine import create_engine_from_url, session_factory +from eventflow_work_management.security.attachment_scanner import ( + MalwareScanner, + UnconfiguredMalwareScanner, +) from eventflow_work_management.security.config import AuthSettings from eventflow_work_management.security.crypto import TokenCipher from eventflow_work_management.security.errors import ( @@ -47,6 +54,7 @@ def create_app( engine: Engine | None = None, session_factory_override: sessionmaker | None = None, oidc_client: OIDCClient | None = None, + malware_scanner: MalwareScanner | None = None, ) -> FastAPI: """Construct the API and dependency graph; no authentication bypass exists.""" auth_settings = settings or AuthSettings.from_env() @@ -82,6 +90,7 @@ async def lifespan(application: FastAPI): app.state.authentication_service = authentication_service app.state.oidc_client = oidc app.state.database_engine = owned_engine + app.state.malware_scanner = malware_scanner or UnconfiguredMalwareScanner() app.add_middleware( CORSMiddleware, allow_origins=list(auth_settings.allowed_origins), @@ -186,5 +195,6 @@ def ready(): app.include_router(authentication_router) app.include_router(workspace_router) app.include_router(consultation_router) + app.include_router(attachment_router) return app diff --git a/services/work-management/src/eventflow_work_management/api/attachment_routes.py b/services/work-management/src/eventflow_work_management/api/attachment_routes.py new file mode 100644 index 0000000..b5821d4 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/api/attachment_routes.py @@ -0,0 +1,351 @@ +"""Consultation attachment, evidence, and history routes (Issue #4). + +The tenant and actor are derived from the verified principal and the path +locator, never from the request body. Attachment bytes travel as base64 in a JSON +body so the upload reuses the authenticated JSON transport; the service enforces +the size limit and the content-type allowlist on the decoded bytes. +""" + +from __future__ import annotations + +import re +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, Response +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from eventflow_work_management.api.dependencies import ( + authenticated_principal, + correlation_id, + database_session, + require_csrf, +) +from eventflow_work_management.authorization.policy import Action +from eventflow_work_management.authorization.service import AuthorizationService +from eventflow_work_management.hashing import fingerprint +from eventflow_work_management.models import AuditRecord, ConsultationAttachment +from eventflow_work_management.models.evidence import ConsultationEvidence +from eventflow_work_management.security.attachment_scanner import MalwareScanner +from eventflow_work_management.security.errors import ProblemError +from eventflow_work_management.security.principal import AuthenticatedPrincipal +from eventflow_work_management.services.attachment import ( + AttachmentService, + attachment_etag, +) +from eventflow_work_management.services.evidence import EvidenceService +from eventflow_work_management.services.history import ConsultationHistoryService + +router = APIRouter(tags=["Attachments"]) + +IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9._~-]{8,128}$") +EVIDENCE_KIND_PATTERN = "^(text|link|attachment)$" + + +class CreateAttachmentRequest(BaseModel): + file_name: str = Field(min_length=1, max_length=255) + content_type: str = Field(min_length=1, max_length=255) + content_base64: str = Field(min_length=1, max_length=8_388_608) + + +class CreateEvidenceRequest(BaseModel): + kind: str = Field(pattern=EVIDENCE_KIND_PATTERN) + description: str | None = Field(default=None, min_length=1, max_length=500) + content: str | None = Field(default=None, min_length=1, max_length=4096) + attachment_id: UUID | None = None + + +def _authorize( + session: Session, + principal: AuthenticatedPrincipal, + workspace_id: UUID, + action: Action, +): + return AuthorizationService(session).authorize(principal, workspace_id, action) + + +def _idempotency_key(request: Request) -> str | None: + supplied = request.headers.get("idempotency-key") + if supplied is None: + return None + if not IDEMPOTENCY_KEY_PATTERN.match(supplied): + raise ProblemError( + status_code=400, + code="VALIDATION_ERROR", + detail="The Idempotency-Key header is malformed.", + title="Invalid request", + ) + return supplied + + +def _scanner(request: Request) -> MalwareScanner: + return request.app.state.malware_scanner + + +def _attachment_payload(attachment: ConsultationAttachment) -> dict: + return { + "attachment_id": attachment.id, + "workspace_id": attachment.workspace_id, + "consultation_id": attachment.consultation_id, + "file_name": attachment.file_name, + "content_type": attachment.detected_content_type, + "size_bytes": attachment.size_bytes, + "checksum_sha256": attachment.checksum_sha256, + "status": attachment.status, + "scan_verdict": attachment.scan_verdict, + "rejection_reason": attachment.rejection_reason, + "uploaded_by": attachment.uploaded_by, + "created_at": attachment.created_at, + } + + +def _evidence_payload(evidence: ConsultationEvidence) -> dict: + return { + "evidence_id": evidence.id, + "workspace_id": evidence.workspace_id, + "consultation_id": evidence.consultation_id, + "kind": evidence.kind, + "description": evidence.description, + "content": evidence.content, + "attachment_id": evidence.attachment_id, + "created_by": evidence.created_by, + "created_at": evidence.created_at, + } + + +def _history_payload(record: AuditRecord) -> dict: + return { + "audit_id": record.id, + "workspace_id": record.workspace_id, + "actor_id": record.actor_id, + "action": record.action, + "entity_type": record.entity_type, + "entity_id": record.entity_id, + "occurred_at": record.occurred_at, + "snapshot": record.snapshot, + "snapshot_hash": record.snapshot_hash, + "correlation_id": record.correlation_id, + } + + +def _collection_etag(workspace_id: UUID, parts: list[str], next_cursor: str | None) -> str: + return '"' + fingerprint([str(workspace_id), parts, next_cursor]) + '"' + + +@router.post( + "/v1/workspaces/{workspace_id}/consultations/{consultation_id}/attachments", + status_code=201, +) +def create_consultation_attachment( + workspace_id: UUID, + consultation_id: UUID, + payload: CreateAttachmentRequest, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize( + session, principal, workspace_id, Action.ATTACHMENT_UPLOAD + ) + result = AttachmentService(session, _scanner(request)).upload( + context, + consultation_id, + file_name=payload.file_name, + content_type=payload.content_type, + content_base64=payload.content_base64, + idempotency_key=_idempotency_key(request), + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = attachment_etag(result.attachment) + response.headers["Location"] = ( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}" + f"/attachments/{result.attachment.id}" + ) + return _attachment_payload(result.attachment) + + +@router.get( + "/v1/workspaces/{workspace_id}/consultations/{consultation_id}/attachments" +) +def list_consultation_attachments( + workspace_id: UUID, + consultation_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize( + session, principal, workspace_id, Action.ATTACHMENT_READ + ) + items = AttachmentService(session).list_for_consultation(context, consultation_id) + response.headers["ETag"] = _collection_etag( + workspace_id, + [f"{item.id}{item.status}{item.checksum_sha256}" for item in items], + None, + ) + return {"items": [_attachment_payload(item) for item in items]} + + +@router.get( + "/v1/workspaces/{workspace_id}/consultations/{consultation_id}" + "/attachments/{attachment_id}" +) +def get_consultation_attachment( + workspace_id: UUID, + consultation_id: UUID, + attachment_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize( + session, principal, workspace_id, Action.ATTACHMENT_READ + ) + attachment = AttachmentService(session).get( + context, consultation_id, attachment_id + ) + response.headers["ETag"] = attachment_etag(attachment) + return _attachment_payload(attachment) + + +@router.get( + "/v1/workspaces/{workspace_id}/consultations/{consultation_id}" + "/attachments/{attachment_id}/content" +) +def download_consultation_attachment( + workspace_id: UUID, + consultation_id: UUID, + attachment_id: UUID, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize( + session, principal, workspace_id, Action.ATTACHMENT_READ + ) + download = AttachmentService(session).download( + context, consultation_id, attachment_id + ) + return Response( + content=download.content, + media_type="application/octet-stream", + headers={ + "ETag": attachment_etag(download.attachment), + "Content-Disposition": ( + f'attachment; filename="{download.attachment.file_name}"' + ), + "X-Content-Type-Options": "nosniff", + }, + ) + + +@router.delete( + "/v1/workspaces/{workspace_id}/consultations/{consultation_id}" + "/attachments/{attachment_id}", + status_code=204, +) +def delete_consultation_attachment( + workspace_id: UUID, + consultation_id: UUID, + attachment_id: UUID, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize( + session, principal, workspace_id, Action.ATTACHMENT_DELETE + ) + AttachmentService(session).delete( + context, + consultation_id, + attachment_id, + correlation_id=request_correlation_id, + ) + return Response(status_code=204) + + +@router.post( + "/v1/workspaces/{workspace_id}/consultations/{consultation_id}/evidence", + status_code=201, +) +def create_consultation_evidence( + workspace_id: UUID, + consultation_id: UUID, + payload: CreateEvidenceRequest, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize( + session, principal, workspace_id, Action.EVIDENCE_CREATE + ) + result = EvidenceService(session).create( + context, + consultation_id, + kind=payload.kind, + description=payload.description, + content=payload.content, + attachment_id=payload.attachment_id, + idempotency_key=_idempotency_key(request), + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = '"' + fingerprint( + ["evidence", str(result.evidence.id)] + ) + '"' + response.headers["Location"] = ( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}" + f"/evidence/{result.evidence.id}" + ) + return _evidence_payload(result.evidence) + + +@router.get( + "/v1/workspaces/{workspace_id}/consultations/{consultation_id}/evidence" +) +def list_consultation_evidence( + workspace_id: UUID, + consultation_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize( + session, principal, workspace_id, Action.EVIDENCE_READ + ) + items = EvidenceService(session).list_for_consultation(context, consultation_id) + response.headers["ETag"] = _collection_etag( + workspace_id, [str(item.id) for item in items], None + ) + return {"items": [_evidence_payload(item) for item in items]} + + +@router.get( + "/v1/workspaces/{workspace_id}/consultations/{consultation_id}/history" +) +def list_consultation_history( + workspace_id: UUID, + consultation_id: UUID, + response: Response, + limit: int = Query(default=50, ge=1, le=200), + cursor: str | None = Query(default=None), + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize( + session, principal, workspace_id, Action.HISTORY_READ + ) + records, next_cursor = ConsultationHistoryService(session).list_page( + context, consultation_id, limit=limit, cursor=cursor + ) + response.headers["ETag"] = _collection_etag( + workspace_id, + [f"{record.id}{record.snapshot_hash}" for record in records], + next_cursor, + ) + return { + "items": [_history_payload(record) for record in records], + "page_info": {"has_more": next_cursor is not None, "next_cursor": next_cursor}, + } diff --git a/services/work-management/src/eventflow_work_management/authorization/policy.py b/services/work-management/src/eventflow_work_management/authorization/policy.py index c2a8097..a4398fe 100644 --- a/services/work-management/src/eventflow_work_management/authorization/policy.py +++ b/services/work-management/src/eventflow_work_management/authorization/policy.py @@ -44,6 +44,13 @@ class Action(str, Enum): CONSULTATION_SUBMIT = "consultation:submit" CONSULTATION_TRIAGE = "consultation:triage" + ATTACHMENT_READ = "attachment:read" + ATTACHMENT_UPLOAD = "attachment:upload" + ATTACHMENT_DELETE = "attachment:delete" + EVIDENCE_READ = "evidence:read" + EVIDENCE_CREATE = "evidence:create" + HISTORY_READ = "history:read" + PROPOSAL_READ = "proposal:read" PROPOSAL_CREATE = "proposal:create" PROPOSAL_SUBMIT = "proposal:submit" @@ -61,6 +68,9 @@ class Action(str, Enum): Action.PROJECT_READ, Action.MEMBERSHIP_READ, Action.CONSULTATION_READ, + Action.ATTACHMENT_READ, + Action.EVIDENCE_READ, + Action.HISTORY_READ, Action.PROPOSAL_READ, Action.WORK_ITEM_READ, Action.BOARD_READ, @@ -82,6 +92,9 @@ class Action(str, Enum): Action.CONSULTATION_UPDATE, Action.CONSULTATION_SUBMIT, Action.CONSULTATION_TRIAGE, + Action.ATTACHMENT_UPLOAD, + Action.ATTACHMENT_DELETE, + Action.EVIDENCE_CREATE, Action.PROPOSAL_CREATE, Action.PROPOSAL_SUBMIT, Action.APPROVAL_DECIDE, @@ -101,6 +114,9 @@ class Action(str, Enum): Action.CONSULTATION_UPDATE, Action.CONSULTATION_SUBMIT, Action.CONSULTATION_TRIAGE, + Action.ATTACHMENT_UPLOAD, + Action.ATTACHMENT_DELETE, + Action.EVIDENCE_CREATE, Action.PROPOSAL_CREATE, Action.PROPOSAL_SUBMIT, Action.APPROVAL_DECIDE, @@ -114,6 +130,9 @@ class Action(str, Enum): Action.CONSULTATION_UPDATE, Action.CONSULTATION_SUBMIT, Action.CONSULTATION_TRIAGE, + Action.ATTACHMENT_UPLOAD, + Action.ATTACHMENT_DELETE, + Action.EVIDENCE_CREATE, Action.PROPOSAL_CREATE, Action.PROPOSAL_SUBMIT, Action.WORK_ITEM_UPDATE, @@ -124,6 +143,9 @@ class Action(str, Enum): Action.CONSULTATION_CREATE, Action.CONSULTATION_UPDATE, Action.CONSULTATION_SUBMIT, + Action.ATTACHMENT_UPLOAD, + Action.ATTACHMENT_DELETE, + Action.EVIDENCE_CREATE, Action.PROPOSAL_CREATE, Action.PROPOSAL_SUBMIT, Action.APPROVAL_DECIDE, @@ -134,6 +156,9 @@ class Action(str, Enum): Action.CONSULTATION_CREATE, Action.CONSULTATION_UPDATE, Action.CONSULTATION_SUBMIT, + Action.ATTACHMENT_UPLOAD, + Action.ATTACHMENT_DELETE, + Action.EVIDENCE_CREATE, Action.PROPOSAL_CREATE, Action.PROPOSAL_SUBMIT, Action.WORK_ITEM_UPDATE, diff --git a/services/work-management/src/eventflow_work_management/migrations/versions/0006_consultation_attachments_evidence.py b/services/work-management/src/eventflow_work_management/migrations/versions/0006_consultation_attachments_evidence.py new file mode 100644 index 0000000..251480b --- /dev/null +++ b/services/work-management/src/eventflow_work_management/migrations/versions/0006_consultation_attachments_evidence.py @@ -0,0 +1,263 @@ +"""Add consultation attachments and evidence (Issue #4). + +Attachments store hostile content behind a fail-closed lifecycle: metadata in +``consultation_attachments``, bytes in ``consultation_attachment_blobs``, and a +per-tenant composite foreign key so an attachment can never cross a workspace +boundary (AT-04). ``consultation_evidence`` records structured justification and +may reference an attachment. +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0006_attachments_evidence" +down_revision = "0005_consultation_drafts" +branch_labels = None +depends_on = None + +_ATTACHMENT_STATUS = "status IN ('quarantined', 'available', 'rejected')" +_SCAN_VERDICT = "scan_verdict IN ('clean', 'infected', 'unknown')" +_KIND = "kind IN ('text', 'link', 'attachment')" +_KIND_CONTENT_CONSISTENCY = ( + "(kind = 'attachment' AND attachment_id IS NOT NULL AND content IS NULL) " + "OR (kind IN ('text', 'link') AND content IS NOT NULL AND attachment_id IS NULL)" +) + + +def upgrade() -> None: + op.create_table( + "consultation_attachments", + sa.Column( + "id", + sa.Uuid(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("workspace_id", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("consultation_id", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("uploaded_by", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("file_name", sa.String(length=255), nullable=False), + sa.Column("declared_content_type", sa.String(length=255), nullable=False), + sa.Column("detected_content_type", sa.String(length=255), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("checksum_sha256", sa.String(length=64), nullable=False), + sa.Column("storage_key", sa.String(length=64), nullable=False), + sa.Column( + "status", + sa.String(length=16), + server_default=sa.text("'quarantined'"), + nullable=False, + ), + sa.Column("scan_verdict", sa.String(length=16), nullable=False), + sa.Column("rejection_reason", sa.String(length=120), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + _ATTACHMENT_STATUS, name=op.f("ck_consultation_attachments_status_values") + ), + sa.CheckConstraint( + _SCAN_VERDICT, name=op.f("ck_consultation_attachments_scan_verdict_values") + ), + sa.CheckConstraint( + "size_bytes >= 0", + name=op.f("ck_consultation_attachments_size_non_negative"), + ), + sa.CheckConstraint( + "char_length(file_name) BETWEEN 1 AND 255", + name=op.f("ck_consultation_attachments_file_name_length"), + ), + sa.CheckConstraint( + "char_length(declared_content_type) BETWEEN 1 AND 255", + name=op.f("ck_consultation_attachments_declared_content_type_length"), + ), + sa.CheckConstraint( + "char_length(detected_content_type) BETWEEN 1 AND 255", + name=op.f("ck_consultation_attachments_detected_content_type_length"), + ), + sa.CheckConstraint( + "checksum_sha256 ~ '^[0-9a-f]{64}$'", + name=op.f("ck_consultation_attachments_checksum_format"), + ), + sa.CheckConstraint( + "storage_key ~ '^[0-9a-f]{32}$'", + name=op.f("ck_consultation_attachments_storage_key_format"), + ), + sa.CheckConstraint( + "rejection_reason IS NULL OR char_length(rejection_reason) BETWEEN 1 AND 120", + name=op.f("ck_consultation_attachments_rejection_reason_length"), + ), + sa.CheckConstraint( + "(status = 'rejected') = (rejection_reason IS NOT NULL)", + name=op.f("ck_consultation_attachments_rejection_reason_consistency"), + ), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspaces.id"], + name=op.f("fk_consultation_attachments_workspace_id_workspaces"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["workspace_id", "consultation_id"], + ["consultations.workspace_id", "consultations.id"], + name="fk_consultation_attachments_workspace_consultation", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_consultation_attachments")), + sa.UniqueConstraint( + "workspace_id", "id", name="uq_consultation_attachments_workspace_id" + ), + sa.UniqueConstraint( + "storage_key", name="uq_consultation_attachments_storage_key" + ), + ) + op.create_index( + "ix_consultation_attachments_workspace_id", + "consultation_attachments", + ["workspace_id"], + ) + op.create_index( + "ix_consultation_attachments_consultation_id", + "consultation_attachments", + ["consultation_id"], + ) + + op.create_table( + "consultation_attachment_blobs", + sa.Column("attachment_id", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("workspace_id", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("data", sa.LargeBinary(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["attachment_id"], + ["consultation_attachments.id"], + name=op.f( + "fk_consultation_attachment_blobs_attachment_id_consultation_attachments" + ), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspaces.id"], + name=op.f("fk_consultation_attachment_blobs_workspace_id_workspaces"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "attachment_id", name=op.f("pk_consultation_attachment_blobs") + ), + ) + op.create_index( + "ix_consultation_attachment_blobs_workspace_id", + "consultation_attachment_blobs", + ["workspace_id"], + ) + + op.create_table( + "consultation_evidence", + sa.Column( + "id", + sa.Uuid(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("workspace_id", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("consultation_id", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("created_by", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("description", sa.String(length=500), nullable=True), + sa.Column("content", sa.Text(), nullable=True), + sa.Column("attachment_id", sa.Uuid(as_uuid=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint(_KIND, name=op.f("ck_consultation_evidence_kind_values")), + sa.CheckConstraint( + _KIND_CONTENT_CONSISTENCY, + name=op.f("ck_consultation_evidence_kind_content_consistency"), + ), + sa.CheckConstraint( + "description IS NULL OR char_length(description) BETWEEN 1 AND 500", + name=op.f("ck_consultation_evidence_description_length"), + ), + sa.CheckConstraint( + "content IS NULL OR char_length(content) BETWEEN 1 AND 4096", + name=op.f("ck_consultation_evidence_content_length"), + ), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspaces.id"], + name=op.f("fk_consultation_evidence_workspace_id_workspaces"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["workspace_id", "consultation_id"], + ["consultations.workspace_id", "consultations.id"], + name="fk_consultation_evidence_workspace_consultation", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["attachment_id"], + ["consultation_attachments.id"], + name=op.f( + "fk_consultation_evidence_attachment_id_consultation_attachments" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_consultation_evidence")), + sa.UniqueConstraint( + "workspace_id", "id", name="uq_consultation_evidence_workspace_id" + ), + ) + op.create_index( + "ix_consultation_evidence_workspace_id", + "consultation_evidence", + ["workspace_id"], + ) + op.create_index( + "ix_consultation_evidence_consultation_id", + "consultation_evidence", + ["consultation_id"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_consultation_evidence_consultation_id", table_name="consultation_evidence" + ) + op.drop_index( + "ix_consultation_evidence_workspace_id", table_name="consultation_evidence" + ) + op.drop_table("consultation_evidence") + op.drop_index( + "ix_consultation_attachment_blobs_workspace_id", + table_name="consultation_attachment_blobs", + ) + op.drop_table("consultation_attachment_blobs") + op.drop_index( + "ix_consultation_attachments_consultation_id", + table_name="consultation_attachments", + ) + op.drop_index( + "ix_consultation_attachments_workspace_id", + table_name="consultation_attachments", + ) + op.drop_table("consultation_attachments") diff --git a/services/work-management/src/eventflow_work_management/models/__init__.py b/services/work-management/src/eventflow_work_management/models/__init__.py index 620dfb7..78897b5 100644 --- a/services/work-management/src/eventflow_work_management/models/__init__.py +++ b/services/work-management/src/eventflow_work_management/models/__init__.py @@ -7,6 +7,10 @@ from __future__ import annotations from eventflow_work_management.models.approval import Approval +from eventflow_work_management.models.attachment import ( + ConsultationAttachment, + ConsultationAttachmentBlob, +) from eventflow_work_management.models.authentication import ( AuthenticationSecurityEvent, AuthenticationSession, @@ -16,6 +20,7 @@ from eventflow_work_management.models.audit import AuditRecord from eventflow_work_management.models.board import Board, BoardCard from eventflow_work_management.models.consultation import Consultation +from eventflow_work_management.models.evidence import ConsultationEvidence from eventflow_work_management.models.idempotency import IdempotencyRecord from eventflow_work_management.models.integration import RemoteIntegrationReference from eventflow_work_management.models.membership import ( @@ -34,6 +39,9 @@ "Board", "BoardCard", "Consultation", + "ConsultationAttachment", + "ConsultationAttachmentBlob", + "ConsultationEvidence", "IdempotencyRecord", "ExternalIdentity", "OIDCLoginTransaction", diff --git a/services/work-management/src/eventflow_work_management/models/attachment.py b/services/work-management/src/eventflow_work_management/models/attachment.py new file mode 100644 index 0000000..13c8f3f --- /dev/null +++ b/services/work-management/src/eventflow_work_management/models/attachment.py @@ -0,0 +1,143 @@ +"""Consultation attachment metadata and stored object models (Issue #4). + +Attachments are hostile by default. Metadata is separated from the stored bytes +so list and detail reads never load object content, and the object is addressed +by an opaque ``storage_key`` that is never derived from the client filename +(AT-07). Content is stored in a per-tenant namespace with no public path (AT-04). +""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from sqlalchemy import ( + CheckConstraint, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + LargeBinary, + String, + UniqueConstraint, + Uuid, + text, +) +from sqlalchemy.orm import Mapped, mapped_column + +from eventflow_work_management.db.base import ( + Base, + CreatedAtMixin, + TimestampMixin, + check_in, + check_regex, +) +from eventflow_work_management.models.enums import ( + ATTACHMENT_SCAN_VERDICT_VALUES, + ATTACHMENT_STATUS_VALUES, +) + +SHA256_PATTERN = "^[0-9a-f]{64}$" +STORAGE_KEY_PATTERN = "^[0-9a-f]{32}$" + + +class ConsultationAttachment(TimestampMixin, Base): + """Metadata for one uploaded consultation attachment.""" + + __tablename__ = "consultation_attachments" + + id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + primary_key=True, + default=uuid4, + server_default=text("gen_random_uuid()"), + ) + workspace_id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + consultation_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), nullable=False) + uploaded_by: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), nullable=False) + file_name: Mapped[str] = mapped_column(String(255), nullable=False) + declared_content_type: Mapped[str] = mapped_column(String(255), nullable=False) + detected_content_type: Mapped[str] = mapped_column(String(255), nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + storage_key: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column( + String(16), nullable=False, server_default=text("'quarantined'") + ) + scan_verdict: Mapped[str] = mapped_column(String(16), nullable=False) + rejection_reason: Mapped[str | None] = mapped_column(String(120), nullable=True) + + __table_args__ = ( + ForeignKeyConstraint( + ["workspace_id", "consultation_id"], + ["consultations.workspace_id", "consultations.id"], + name="fk_consultation_attachments_workspace_consultation", + ondelete="CASCADE", + ), + UniqueConstraint( + "workspace_id", "id", name="uq_consultation_attachments_workspace_id" + ), + UniqueConstraint("storage_key", name="uq_consultation_attachments_storage_key"), + CheckConstraint( + check_in("status", ATTACHMENT_STATUS_VALUES), name="status_values" + ), + CheckConstraint( + check_in("scan_verdict", ATTACHMENT_SCAN_VERDICT_VALUES), + name="scan_verdict_values", + ), + CheckConstraint("size_bytes >= 0", name="size_non_negative"), + CheckConstraint( + "char_length(file_name) BETWEEN 1 AND 255", name="file_name_length" + ), + CheckConstraint( + "char_length(declared_content_type) BETWEEN 1 AND 255", + name="declared_content_type_length", + ), + CheckConstraint( + "char_length(detected_content_type) BETWEEN 1 AND 255", + name="detected_content_type_length", + ), + CheckConstraint( + check_regex("checksum_sha256", SHA256_PATTERN), name="checksum_format" + ), + CheckConstraint( + check_regex("storage_key", STORAGE_KEY_PATTERN), name="storage_key_format" + ), + CheckConstraint( + "rejection_reason IS NULL OR char_length(rejection_reason) BETWEEN 1 AND 120", + name="rejection_reason_length", + ), + # A quarantined or available attachment must have been scanned; a rejected + # attachment records why it was refused. + CheckConstraint( + "(status = 'rejected') = (rejection_reason IS NOT NULL)", + name="rejection_reason_consistency", + ), + Index("ix_consultation_attachments_workspace_id", "workspace_id"), + Index("ix_consultation_attachments_consultation_id", "consultation_id"), + ) + + +class ConsultationAttachmentBlob(CreatedAtMixin, Base): + """The stored bytes for one attachment, kept out of metadata reads.""" + + __tablename__ = "consultation_attachment_blobs" + + attachment_id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("consultation_attachments.id", ondelete="CASCADE"), + primary_key=True, + ) + workspace_id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + + __table_args__ = ( + Index("ix_consultation_attachment_blobs_workspace_id", "workspace_id"), + ) diff --git a/services/work-management/src/eventflow_work_management/models/enums.py b/services/work-management/src/eventflow_work_management/models/enums.py index 2c05975..1d8eae2 100644 --- a/services/work-management/src/eventflow_work_management/models/enums.py +++ b/services/work-management/src/eventflow_work_management/models/enums.py @@ -39,6 +39,13 @@ REMOTE_PROVIDER_VALUES = ("github", "gitlab") REMOTE_RESOURCE_TYPE_VALUES = ("issue", "pull_request", "merge_request") +# Attachment lifecycle (Issue #4). Fail-closed: an upload starts QUARANTINED and +# only becomes AVAILABLE after an approved malware scanner returns a clean +# verdict. With no scanner configured every upload stays QUARANTINED. +ATTACHMENT_STATUS_VALUES = ("quarantined", "available", "rejected") +ATTACHMENT_SCAN_VERDICT_VALUES = ("clean", "infected", "unknown") +EVIDENCE_KIND_VALUES = ("text", "link", "attachment") + # Work Management authorization vocabulary (Issue #9). The role order is # intentional: it is roughly ascending privilege, with ``owner`` last so # assignability and elevation checks can compare positions. diff --git a/services/work-management/src/eventflow_work_management/models/evidence.py b/services/work-management/src/eventflow_work_management/models/evidence.py new file mode 100644 index 0000000..db27cc6 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/models/evidence.py @@ -0,0 +1,88 @@ +"""Consultation evidence model (Issue #4). + +Evidence captures the structured justification attached to a consultation: free +text, an external link, or a reference to a stored attachment. A link or text +entry carries inline ``content``; an attachment entry carries only an +``attachment_id`` so the bytes stay behind the attachment access controls. +""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from sqlalchemy import ( + CheckConstraint, + ForeignKey, + ForeignKeyConstraint, + Index, + String, + Text, + UniqueConstraint, + Uuid, + text, +) +from sqlalchemy.orm import Mapped, mapped_column + +from eventflow_work_management.db.base import Base, CreatedAtMixin, check_in +from eventflow_work_management.models.enums import EVIDENCE_KIND_VALUES + +_KIND_CONTENT_CONSISTENCY = ( + "(kind = 'attachment' AND attachment_id IS NOT NULL AND content IS NULL) " + "OR (kind IN ('text', 'link') AND content IS NOT NULL AND attachment_id IS NULL)" +) + + +class ConsultationEvidence(CreatedAtMixin, Base): + """One piece of evidence attached to a consultation.""" + + __tablename__ = "consultation_evidence" + + id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + primary_key=True, + default=uuid4, + server_default=text("gen_random_uuid()"), + ) + workspace_id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + consultation_id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), nullable=False) + created_by: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), nullable=False) + kind: Mapped[str] = mapped_column(String(16), nullable=False) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + attachment_id: Mapped[UUID | None] = mapped_column( + Uuid(as_uuid=True), nullable=True + ) + + __table_args__ = ( + ForeignKeyConstraint( + ["workspace_id", "consultation_id"], + ["consultations.workspace_id", "consultations.id"], + name="fk_consultation_evidence_workspace_consultation", + ondelete="CASCADE", + ), + ForeignKeyConstraint( + ["attachment_id"], + ["consultation_attachments.id"], + name="fk_consultation_evidence_attachment_id_consultation_attachments", + ondelete="CASCADE", + ), + UniqueConstraint( + "workspace_id", "id", name="uq_consultation_evidence_workspace_id" + ), + CheckConstraint(check_in("kind", EVIDENCE_KIND_VALUES), name="kind_values"), + CheckConstraint(_KIND_CONTENT_CONSISTENCY, name="kind_content_consistency"), + CheckConstraint( + "description IS NULL OR char_length(description) BETWEEN 1 AND 500", + name="description_length", + ), + CheckConstraint( + "content IS NULL OR char_length(content) BETWEEN 1 AND 4096", + name="content_length", + ), + Index("ix_consultation_evidence_workspace_id", "workspace_id"), + Index("ix_consultation_evidence_consultation_id", "consultation_id"), + ) diff --git a/services/work-management/src/eventflow_work_management/repositories/attachment_repository.py b/services/work-management/src/eventflow_work_management/repositories/attachment_repository.py new file mode 100644 index 0000000..39493ea --- /dev/null +++ b/services/work-management/src/eventflow_work_management/repositories/attachment_repository.py @@ -0,0 +1,184 @@ +"""Attachment and evidence repositories (Issue #4). + +These repositories only flush; the owning service commits once so an upload, its +audit record, and its idempotency reservation land in a single transaction. +""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from eventflow_work_management.models import ( + ConsultationAttachment, + ConsultationAttachmentBlob, + ConsultationEvidence, +) +from eventflow_work_management.repositories.base import Repository + + +class AttachmentRepository(Repository): + def __init__(self, session: Session) -> None: + super().__init__(session) + + def add( + self, + *, + workspace_id: UUID, + consultation_id: UUID, + uploaded_by: UUID, + file_name: str, + declared_content_type: str, + detected_content_type: str, + size_bytes: int, + checksum_sha256: str, + storage_key: str, + status: str, + scan_verdict: str, + rejection_reason: str | None = None, + ) -> ConsultationAttachment: + attachment = ConsultationAttachment( + id=uuid4(), + workspace_id=workspace_id, + consultation_id=consultation_id, + uploaded_by=uploaded_by, + file_name=file_name, + declared_content_type=declared_content_type, + detected_content_type=detected_content_type, + size_bytes=size_bytes, + checksum_sha256=checksum_sha256, + storage_key=storage_key, + status=status, + scan_verdict=scan_verdict, + rejection_reason=rejection_reason, + ) + self._session.add(attachment) + self._session.flush() + return attachment + + def add_blob( + self, *, attachment_id: UUID, workspace_id: UUID, data: bytes + ) -> ConsultationAttachmentBlob: + blob = ConsultationAttachmentBlob( + attachment_id=attachment_id, workspace_id=workspace_id, data=data + ) + self._session.add(blob) + self._session.flush() + return blob + + def get( + self, *, workspace_id: UUID, consultation_id: UUID, attachment_id: UUID + ) -> ConsultationAttachment | None: + statement = select(ConsultationAttachment).where( + ConsultationAttachment.workspace_id == workspace_id, + ConsultationAttachment.consultation_id == consultation_id, + ConsultationAttachment.id == attachment_id, + ) + return self._session.execute(statement).scalar_one_or_none() + + def get_by_id( + self, *, workspace_id: UUID, attachment_id: UUID + ) -> ConsultationAttachment | None: + statement = select(ConsultationAttachment).where( + ConsultationAttachment.workspace_id == workspace_id, + ConsultationAttachment.id == attachment_id, + ) + return self._session.execute(statement).scalar_one_or_none() + + def get_blob(self, *, attachment_id: UUID) -> ConsultationAttachmentBlob | None: + statement = select(ConsultationAttachmentBlob).where( + ConsultationAttachmentBlob.attachment_id == attachment_id + ) + return self._session.execute(statement).scalar_one_or_none() + + def list_for_consultation( + self, *, workspace_id: UUID, consultation_id: UUID + ) -> list[ConsultationAttachment]: + statement = ( + select(ConsultationAttachment) + .where( + ConsultationAttachment.workspace_id == workspace_id, + ConsultationAttachment.consultation_id == consultation_id, + ) + .order_by( + ConsultationAttachment.created_at, ConsultationAttachment.id + ) + ) + return list(self._session.execute(statement).scalars()) + + def count_for_consultation( + self, *, workspace_id: UUID, consultation_id: UUID + ) -> int: + statement = select(func.count()).select_from(ConsultationAttachment).where( + ConsultationAttachment.workspace_id == workspace_id, + ConsultationAttachment.consultation_id == consultation_id, + ) + return int(self._session.execute(statement).scalar_one()) + + def delete(self, attachment: ConsultationAttachment) -> None: + self._session.delete(attachment) + self._session.flush() + + +class EvidenceRepository(Repository): + def __init__(self, session: Session) -> None: + super().__init__(session) + + def add( + self, + *, + workspace_id: UUID, + consultation_id: UUID, + created_by: UUID, + kind: str, + description: str | None, + content: str | None, + attachment_id: UUID | None, + ) -> ConsultationEvidence: + evidence = ConsultationEvidence( + id=uuid4(), + workspace_id=workspace_id, + consultation_id=consultation_id, + created_by=created_by, + kind=kind, + description=description, + content=content, + attachment_id=attachment_id, + ) + self._session.add(evidence) + self._session.flush() + return evidence + + def get( + self, *, workspace_id: UUID, consultation_id: UUID, evidence_id: UUID + ) -> ConsultationEvidence | None: + statement = select(ConsultationEvidence).where( + ConsultationEvidence.workspace_id == workspace_id, + ConsultationEvidence.consultation_id == consultation_id, + ConsultationEvidence.id == evidence_id, + ) + return self._session.execute(statement).scalar_one_or_none() + + def get_by_id( + self, *, workspace_id: UUID, evidence_id: UUID + ) -> ConsultationEvidence | None: + statement = select(ConsultationEvidence).where( + ConsultationEvidence.workspace_id == workspace_id, + ConsultationEvidence.id == evidence_id, + ) + return self._session.execute(statement).scalar_one_or_none() + + def list_for_consultation( + self, *, workspace_id: UUID, consultation_id: UUID + ) -> list[ConsultationEvidence]: + statement = ( + select(ConsultationEvidence) + .where( + ConsultationEvidence.workspace_id == workspace_id, + ConsultationEvidence.consultation_id == consultation_id, + ) + .order_by(ConsultationEvidence.created_at, ConsultationEvidence.id) + ) + return list(self._session.execute(statement).scalars()) diff --git a/services/work-management/src/eventflow_work_management/repositories/audit_repository.py b/services/work-management/src/eventflow_work_management/repositories/audit_repository.py index fdf9efd..ae7549d 100644 --- a/services/work-management/src/eventflow_work_management/repositories/audit_repository.py +++ b/services/work-management/src/eventflow_work_management/repositories/audit_repository.py @@ -5,7 +5,7 @@ from datetime import UTC, datetime from uuid import UUID, uuid4 -from sqlalchemy import select +from sqlalchemy import select, tuple_ from sqlalchemy.orm import Session from eventflow_work_management.hashing import fingerprint @@ -68,3 +68,29 @@ def list_for_entity( .order_by(AuditRecord.occurred_at, AuditRecord.created_at) ) return list(self._session.execute(statement).scalars()) + + def list_page_for_entity( + self, + workspace_id: UUID, + entity_type: str, + entity_id: UUID, + *, + limit: int, + after: tuple[datetime, UUID] | None = None, + ) -> list[AuditRecord]: + """Return a keyset page of the audit trail, oldest first.""" + statement = select(AuditRecord).where( + AuditRecord.workspace_id == workspace_id, + AuditRecord.entity_type == entity_type, + AuditRecord.entity_id == entity_id, + ) + if after is not None: + occurred_at, after_id = after + statement = statement.where( + tuple_(AuditRecord.occurred_at, AuditRecord.id) + > tuple_(occurred_at, after_id) + ) + statement = statement.order_by( + AuditRecord.occurred_at, AuditRecord.id + ).limit(limit) + return list(self._session.execute(statement).scalars()) diff --git a/services/work-management/src/eventflow_work_management/security/attachment_scanner.py b/services/work-management/src/eventflow_work_management/security/attachment_scanner.py new file mode 100644 index 0000000..44b116d --- /dev/null +++ b/services/work-management/src/eventflow_work_management/security/attachment_scanner.py @@ -0,0 +1,61 @@ +"""Malware scanning boundary for consultation attachments (Issue #4). + +The scanner is an injected dependency so the attachment pipeline is fail-closed +by construction: with no scanner configured every upload is reported as +``unknown`` and therefore quarantined. Nothing in this module inspects or trusts +the uploaded bytes; it only classifies the verdict. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +CLEAN = "clean" +INFECTED = "infected" +UNKNOWN = "unknown" + +SCAN_VERDICT_VALUES = (CLEAN, INFECTED, UNKNOWN) + + +@dataclass(frozen=True, slots=True) +class ScanResult: + """The verdict for one scanned object.""" + + verdict: str + detail: str | None = None + + +class MalwareScanner(Protocol): + """Structural interface every configured scanner must satisfy.""" + + def scan(self, *, content: bytes, content_type: str) -> ScanResult: + """Return a verdict for the object without raising for a positive match.""" + ... + + +class UnconfiguredMalwareScanner: + """Default scanner used when no scanning backend is configured. + + It never reports ``clean``: an unscanned object must never become available. + """ + + def scan(self, *, content: bytes, content_type: str) -> ScanResult: + return ScanResult(verdict=UNKNOWN, detail="no_scanner_configured") + + +class StaticMalwareScanner: + """Deterministic scanner for tests and local development. + + A verdict is chosen by an injected callable so tests can drive clean, + infected, and unknown outcomes without a real scanning backend. + """ + + def __init__(self, verdict: str, detail: str | None = None) -> None: + if verdict not in SCAN_VERDICT_VALUES: + raise ValueError(f"unknown scan verdict: {verdict}") + self._verdict = verdict + self._detail = detail + + def scan(self, *, content: bytes, content_type: str) -> ScanResult: + return ScanResult(verdict=self._verdict, detail=self._detail) diff --git a/services/work-management/src/eventflow_work_management/services/attachment.py b/services/work-management/src/eventflow_work_management/services/attachment.py new file mode 100644 index 0000000..25650b4 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/services/attachment.py @@ -0,0 +1,449 @@ +"""Attachment application service (Issue #4). + +Uploads are hostile until proven otherwise. The service: + +* derives the tenant and actor from the verified principal, never the body; +* validates the filename (no path material), size, and content type; +* sniffs the real content type and requires it to match the declared type; +* scans through an injected :class:`MalwareScanner` and fails closed: an + ``unknown`` verdict is quarantined and never becomes available; +* stores object bytes only after they pass the allowlist, addressed by an opaque + random ``storage_key`` that is never derived from the client filename; +* records every accepted, quarantined, rejected, and deleted object in the + append-only audit trail. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +from dataclasses import dataclass +from uuid import UUID, uuid4 + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from eventflow_work_management.authorization.policy import ADMINISTRATOR_ROLES +from eventflow_work_management.authorization.service import AuthorizationContext +from eventflow_work_management.hashing import fingerprint +from eventflow_work_management.models import ConsultationAttachment +from eventflow_work_management.repositories.attachment_repository import ( + AttachmentRepository, +) +from eventflow_work_management.repositories.audit_repository import AuditRepository +from eventflow_work_management.repositories.consultation_repository import ( + ConsultationRepository, +) +from eventflow_work_management.repositories.idempotency_repository import ( + IdempotencyRepository, +) +from eventflow_work_management.security.attachment_scanner import ( + CLEAN, + INFECTED, + MalwareScanner, + UnconfiguredMalwareScanner, +) +from eventflow_work_management.security.errors import ProblemError + +UPLOAD_OPERATION = "attachment.upload" + +MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 +MAX_ATTACHMENTS_PER_CONSULTATION = 25 +MAX_FILENAME_LENGTH = 255 + +AVAILABLE_STATUS = "available" +QUARANTINED_STATUS = "quarantined" +REJECTED_STATUS = "rejected" + +MALWARE_REJECTION_REASON = "malware_detected" + +ALLOWED_CONTENT_TYPES = frozenset( + { + "image/png", + "image/jpeg", + "image/gif", + "application/pdf", + "text/plain", + } +) + +_MAGIC_SIGNATURES: tuple[tuple[bytes, str], ...] = ( + (b"\x89PNG\r\n\x1a\n", "image/png"), + (b"\xff\xd8\xff", "image/jpeg"), + (b"GIF87a", "image/gif"), + (b"GIF89a", "image/gif"), + (b"%PDF-", "application/pdf"), +) + +_NOT_FOUND = "The requested resource was not found." + + +def _not_found() -> ProblemError: + return ProblemError( + status_code=404, code="NOT_FOUND", detail=_NOT_FOUND, title="Not found" + ) + + +def _validation(detail: str, *, status_code: int = 400) -> ProblemError: + return ProblemError( + status_code=status_code, + code="VALIDATION_ERROR", + detail=detail, + title="Invalid request" if status_code == 400 else "Unprocessable entity", + ) + + +def _conflict(detail: str, *, code: str = "CONFLICT") -> ProblemError: + return ProblemError(status_code=409, code=code, detail=detail, title="Conflict") + + +def attachment_etag(attachment: ConsultationAttachment) -> str: + """Strong validator over the persisted attachment version.""" + return ( + '"' + + fingerprint( + [ + "attachment", + str(attachment.id), + attachment.checksum_sha256, + attachment.status, + ] + ) + + '"' + ) + + +def _normalize_file_name(file_name: str) -> str: + candidate = file_name.strip() + if not candidate: + raise _validation("The file name is required.") + if len(candidate) > MAX_FILENAME_LENGTH: + raise _validation("The file name is too long.", status_code=422) + if candidate in {".", ".."}: + raise _validation("The file name is invalid.", status_code=422) + if any(separator in candidate for separator in ("/", "\\")): + raise _validation("The file name must not contain a path.", status_code=422) + if any(ord(character) < 32 for character in candidate): + raise _validation( + "The file name must not contain control characters.", status_code=422 + ) + return candidate + + +def _decode_content(content_base64: str) -> bytes: + try: + data = base64.b64decode(content_base64, validate=True) + except (binascii.Error, ValueError) as exc: + raise _validation("The attachment content is not valid base64.") from exc + if not data: + raise _validation("The attachment content is empty.") + if len(data) > MAX_ATTACHMENT_BYTES: + raise _validation( + "The attachment exceeds the maximum size.", status_code=422 + ) + return data + + +def _looks_like_text(data: bytes) -> bool: + if b"\x00" in data: + return False + try: + data.decode("utf-8") + except UnicodeDecodeError: + return False + return True + + +def _detect_content_type(data: bytes, declared: str) -> str | None: + for signature, content_type in _MAGIC_SIGNATURES: + if data.startswith(signature): + return content_type + if declared == "text/plain" and _looks_like_text(data): + return "text/plain" + return None + + +@dataclass(frozen=True, slots=True) +class AttachmentResult: + attachment: ConsultationAttachment + replayed: bool + + +@dataclass(frozen=True, slots=True) +class AttachmentDownload: + attachment: ConsultationAttachment + content: bytes + + +class AttachmentService: + """Authorization, validation, scanning, and persistence for attachments.""" + + def __init__( + self, session: Session, scanner: MalwareScanner | None = None + ) -> None: + self._session = session + self._scanner: MalwareScanner = scanner or UnconfiguredMalwareScanner() + self.attachments = AttachmentRepository(session) + self.consultations = ConsultationRepository(session) + self.audit = AuditRepository(session) + self.idempotency = IdempotencyRepository(session) + + # -- upload ----------------------------------------------------------- + + def upload( + self, + context: AuthorizationContext, + consultation_id: UUID, + *, + file_name: str, + content_type: str, + content_base64: str, + idempotency_key: str | None = None, + correlation_id: UUID | None = None, + ) -> AttachmentResult: + normalized_name = _normalize_file_name(file_name) + if content_type not in ALLOWED_CONTENT_TYPES: + raise _validation( + "The attachment content type is not permitted.", status_code=422 + ) + data = _decode_content(content_base64) + detected = _detect_content_type(data, content_type) + if detected is None or detected != content_type: + raise _validation( + "The attachment content does not match its declared content type.", + status_code=422, + ) + + checksum = hashlib.sha256(data).hexdigest() + request_fingerprint = fingerprint( + { + "consultation_id": str(consultation_id), + "file_name": normalized_name, + "content_type": content_type, + "checksum_sha256": checksum, + "size_bytes": len(data), + } + ) + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, UPLOAD_OPERATION, idempotency_key, request_fingerprint + ) + if replay is not None: + return replay + + try: + self._require_consultation(context.workspace_id, consultation_id) + if ( + self.attachments.count_for_consultation( + workspace_id=context.workspace_id, + consultation_id=consultation_id, + ) + >= MAX_ATTACHMENTS_PER_CONSULTATION + ): + raise _conflict( + "The consultation has reached its attachment limit.", + code="ATTACHMENT_LIMIT_REACHED", + ) + record = ( + self.idempotency.reserve( + workspace_id=context.workspace_id, + operation=UPLOAD_OPERATION, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) + if idempotency_key is not None + else None + ) + + scan = self._scanner.scan(content=data, content_type=detected) + status, rejection_reason, audit_action = _classify(scan.verdict) + attachment = self.attachments.add( + workspace_id=context.workspace_id, + consultation_id=consultation_id, + uploaded_by=context.actor_id, + file_name=normalized_name, + declared_content_type=content_type, + detected_content_type=detected, + size_bytes=len(data), + checksum_sha256=checksum, + storage_key=uuid4().hex, + status=status, + scan_verdict=scan.verdict, + rejection_reason=rejection_reason, + ) + if status != REJECTED_STATUS: + self.attachments.add_blob( + attachment_id=attachment.id, + workspace_id=context.workspace_id, + data=data, + ) + self.audit.append( + workspace_id=context.workspace_id, + action=audit_action, + entity_type="consultation", + entity_id=consultation_id, + actor_id=context.actor_id, + snapshot={ + "consultation_id": str(consultation_id), + "attachment_id": str(attachment.id), + "file_name": normalized_name, + "content_type": detected, + "size_bytes": len(data), + "status": status, + }, + correlation_id=correlation_id, + ) + if record is not None: + self.idempotency.complete( + record, + response_status=201, + response_body={"attachment_id": str(attachment.id)}, + ) + self._session.commit() + except IntegrityError: + self._session.rollback() + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, + UPLOAD_OPERATION, + idempotency_key, + request_fingerprint, + ) + if replay is not None: + return replay + raise + except ProblemError: + self._session.rollback() + raise + return AttachmentResult(attachment=attachment, replayed=False) + + # -- reads ------------------------------------------------------------ + + def list_for_consultation( + self, context: AuthorizationContext, consultation_id: UUID + ) -> list[ConsultationAttachment]: + self._require_consultation(context.workspace_id, consultation_id) + return self.attachments.list_for_consultation( + workspace_id=context.workspace_id, consultation_id=consultation_id + ) + + def get( + self, context: AuthorizationContext, consultation_id: UUID, attachment_id: UUID + ) -> ConsultationAttachment: + self._require_consultation(context.workspace_id, consultation_id) + attachment = self.attachments.get( + workspace_id=context.workspace_id, + consultation_id=consultation_id, + attachment_id=attachment_id, + ) + if attachment is None: + raise _not_found() + return attachment + + def download( + self, context: AuthorizationContext, consultation_id: UUID, attachment_id: UUID + ) -> AttachmentDownload: + attachment = self.get(context, consultation_id, attachment_id) + if attachment.status != AVAILABLE_STATUS: + raise _conflict( + "The attachment is not available for download.", + code="ATTACHMENT_QUARANTINED", + ) + blob = self.attachments.get_blob(attachment_id=attachment.id) + if blob is None: + raise _not_found() + return AttachmentDownload(attachment=attachment, content=blob.data) + + # -- delete ----------------------------------------------------------- + + def delete( + self, + context: AuthorizationContext, + consultation_id: UUID, + attachment_id: UUID, + *, + correlation_id: UUID | None = None, + ) -> None: + try: + attachment = self.get(context, consultation_id, attachment_id) + if ( + attachment.uploaded_by != context.actor_id + and context.role not in ADMINISTRATOR_ROLES + ): + raise ProblemError( + status_code=403, + code="FORBIDDEN", + detail="Only the uploader or a workspace administrator may delete this.", + title="Forbidden", + ) + self.audit.append( + workspace_id=context.workspace_id, + action="attachment.deleted", + entity_type="consultation", + entity_id=consultation_id, + actor_id=context.actor_id, + snapshot={ + "consultation_id": str(consultation_id), + "attachment_id": str(attachment.id), + "file_name": attachment.file_name, + }, + correlation_id=correlation_id, + ) + self.attachments.delete(attachment) + self._session.commit() + except ProblemError: + self._session.rollback() + raise + + # -- internals -------------------------------------------------------- + + def _require_consultation(self, workspace_id: UUID, consultation_id: UUID) -> None: + if ( + self.consultations.get( + workspace_id=workspace_id, consultation_id=consultation_id + ) + is None + ): + raise _not_found() + + def _replay( + self, + workspace_id: UUID, + operation: str, + idempotency_key: str, + request_fingerprint: str, + ) -> AttachmentResult | None: + stored = self.idempotency.get( + workspace_id=workspace_id, + operation=operation, + idempotency_key=idempotency_key, + ) + if stored is None: + return None + if stored.request_fingerprint != request_fingerprint: + raise ProblemError( + status_code=409, + code="IDEMPOTENCY_CONFLICT", + detail="The idempotency key was reused with a different request.", + title="Conflict", + ) + attachment_id = ( + stored.response_body.get("attachment_id") if stored.response_body else None + ) + if attachment_id is None: + return None + attachment = self.attachments.get_by_id( + workspace_id=stored.workspace_id, attachment_id=UUID(attachment_id) + ) + if attachment is None: + return None + return AttachmentResult(attachment=attachment, replayed=True) + + +def _classify(verdict: str) -> tuple[str, str | None, str]: + if verdict == CLEAN: + return AVAILABLE_STATUS, None, "attachment.uploaded" + if verdict == INFECTED: + return REJECTED_STATUS, MALWARE_REJECTION_REASON, "attachment.rejected" + return QUARANTINED_STATUS, None, "attachment.quarantined" diff --git a/services/work-management/src/eventflow_work_management/services/evidence.py b/services/work-management/src/eventflow_work_management/services/evidence.py new file mode 100644 index 0000000..021d7ca --- /dev/null +++ b/services/work-management/src/eventflow_work_management/services/evidence.py @@ -0,0 +1,257 @@ +"""Consultation evidence application service (Issue #4). + +Evidence is structured justification for a consultation. It is either free text, +an external link, or a reference to an available attachment; a reference is only +allowed to an attachment that passed scanning, so quarantined or rejected bytes +can never be cited as evidence. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from eventflow_work_management.authorization.service import AuthorizationContext +from eventflow_work_management.hashing import fingerprint +from eventflow_work_management.models import ConsultationEvidence +from eventflow_work_management.models.enums import EVIDENCE_KIND_VALUES +from eventflow_work_management.repositories.attachment_repository import ( + AttachmentRepository, + EvidenceRepository, +) +from eventflow_work_management.repositories.audit_repository import AuditRepository +from eventflow_work_management.repositories.consultation_repository import ( + ConsultationRepository, +) +from eventflow_work_management.repositories.idempotency_repository import ( + IdempotencyRepository, +) +from eventflow_work_management.security.errors import ProblemError +from eventflow_work_management.services.attachment import AVAILABLE_STATUS + +CREATE_OPERATION = "evidence.create" + +ATTACHMENT_KIND = "attachment" + +_NOT_FOUND = "The requested resource was not found." + + +def _not_found() -> ProblemError: + return ProblemError( + status_code=404, code="NOT_FOUND", detail=_NOT_FOUND, title="Not found" + ) + + +def _validation(detail: str, *, status_code: int = 400) -> ProblemError: + return ProblemError( + status_code=status_code, + code="VALIDATION_ERROR", + detail=detail, + title="Invalid request" if status_code == 400 else "Unprocessable entity", + ) + + +@dataclass(frozen=True, slots=True) +class EvidenceResult: + evidence: ConsultationEvidence + replayed: bool + + +class EvidenceService: + """Authorization, validation, and persistence for consultation evidence.""" + + def __init__(self, session: Session) -> None: + self._session = session + self.evidence = EvidenceRepository(session) + self.attachments = AttachmentRepository(session) + self.consultations = ConsultationRepository(session) + self.audit = AuditRepository(session) + self.idempotency = IdempotencyRepository(session) + + def create( + self, + context: AuthorizationContext, + consultation_id: UUID, + *, + kind: str, + description: str | None = None, + content: str | None = None, + attachment_id: UUID | None = None, + idempotency_key: str | None = None, + correlation_id: UUID | None = None, + ) -> EvidenceResult: + self._validate_shape( + kind=kind, content=content, attachment_id=attachment_id + ) + request_fingerprint = fingerprint( + { + "consultation_id": str(consultation_id), + "kind": kind, + "description": description, + "content": content, + "attachment_id": str(attachment_id) if attachment_id else None, + } + ) + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, CREATE_OPERATION, idempotency_key, request_fingerprint + ) + if replay is not None: + return replay + + try: + self._require_consultation(context.workspace_id, consultation_id) + if kind == ATTACHMENT_KIND and attachment_id is not None: + attachment = self.attachments.get( + workspace_id=context.workspace_id, + consultation_id=consultation_id, + attachment_id=attachment_id, + ) + if attachment is None: + raise _not_found() + if attachment.status != AVAILABLE_STATUS: + raise ProblemError( + status_code=409, + code="INVALID_STATE", + detail="Only an available attachment can be cited as evidence.", + title="Conflict", + ) + record = ( + self.idempotency.reserve( + workspace_id=context.workspace_id, + operation=CREATE_OPERATION, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) + if idempotency_key is not None + else None + ) + evidence = self.evidence.add( + workspace_id=context.workspace_id, + consultation_id=consultation_id, + created_by=context.actor_id, + kind=kind, + description=description, + content=content, + attachment_id=attachment_id, + ) + self.audit.append( + workspace_id=context.workspace_id, + action="evidence.added", + entity_type="consultation", + entity_id=consultation_id, + actor_id=context.actor_id, + snapshot={ + "consultation_id": str(consultation_id), + "evidence_id": str(evidence.id), + "kind": kind, + "attachment_id": str(attachment_id) if attachment_id else None, + }, + correlation_id=correlation_id, + ) + if record is not None: + self.idempotency.complete( + record, + response_status=201, + response_body={"evidence_id": str(evidence.id)}, + ) + self._session.commit() + except IntegrityError: + self._session.rollback() + if idempotency_key is not None: + replay = self._replay( + context.workspace_id, + CREATE_OPERATION, + idempotency_key, + request_fingerprint, + ) + if replay is not None: + return replay + raise + except ProblemError: + self._session.rollback() + raise + return EvidenceResult(evidence=evidence, replayed=False) + + def list_for_consultation( + self, context: AuthorizationContext, consultation_id: UUID + ) -> list[ConsultationEvidence]: + self._require_consultation(context.workspace_id, consultation_id) + return self.evidence.list_for_consultation( + workspace_id=context.workspace_id, consultation_id=consultation_id + ) + + # -- internals -------------------------------------------------------- + + @staticmethod + def _validate_shape( + *, kind: str, content: str | None, attachment_id: UUID | None + ) -> None: + if kind not in EVIDENCE_KIND_VALUES: + raise _validation("Unknown evidence kind.") + if kind == ATTACHMENT_KIND: + if attachment_id is None: + raise _validation( + "Attachment evidence requires an attachment_id.", + status_code=422, + ) + if content is not None: + raise _validation( + "Attachment evidence must not include inline content.", + status_code=422, + ) + return + if content is None: + raise _validation( + "Text and link evidence require content.", status_code=422 + ) + if attachment_id is not None: + raise _validation( + "Text and link evidence must not reference an attachment.", + status_code=422, + ) + + def _require_consultation(self, workspace_id: UUID, consultation_id: UUID) -> None: + if ( + self.consultations.get( + workspace_id=workspace_id, consultation_id=consultation_id + ) + is None + ): + raise _not_found() + + def _replay( + self, + workspace_id: UUID, + operation: str, + idempotency_key: str, + request_fingerprint: str, + ) -> EvidenceResult | None: + stored = self.idempotency.get( + workspace_id=workspace_id, + operation=operation, + idempotency_key=idempotency_key, + ) + if stored is None: + return None + if stored.request_fingerprint != request_fingerprint: + raise ProblemError( + status_code=409, + code="IDEMPOTENCY_CONFLICT", + detail="The idempotency key was reused with a different request.", + title="Conflict", + ) + evidence_id = ( + stored.response_body.get("evidence_id") if stored.response_body else None + ) + if evidence_id is None: + return None + evidence = self.evidence.get_by_id( + workspace_id=stored.workspace_id, evidence_id=UUID(evidence_id) + ) + if evidence is None: + return None + return EvidenceResult(evidence=evidence, replayed=True) diff --git a/services/work-management/src/eventflow_work_management/services/history.py b/services/work-management/src/eventflow_work_management/services/history.py new file mode 100644 index 0000000..3519af7 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/services/history.py @@ -0,0 +1,111 @@ +"""Consultation history application service (Issue #4). + +History is a read-only projection of the append-only audit trail for one +consultation. Because attachments and evidence record their lifecycle under the +consultation entity, a single timeline shows intake, draft edits, triage, +uploads, evidence, and deletion in order. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +from datetime import datetime +from uuid import UUID + +from sqlalchemy.orm import Session + +from eventflow_work_management.authorization.service import AuthorizationContext +from eventflow_work_management.models import AuditRecord +from eventflow_work_management.repositories.audit_repository import AuditRepository +from eventflow_work_management.repositories.consultation_repository import ( + ConsultationRepository, +) +from eventflow_work_management.security.errors import ProblemError + +DEFAULT_PAGE_SIZE = 50 +MAX_PAGE_SIZE = 200 + +ENTITY_TYPE = "consultation" + +_NOT_FOUND = "The requested resource was not found." + + +def _not_found() -> ProblemError: + return ProblemError( + status_code=404, code="NOT_FOUND", detail=_NOT_FOUND, title="Not found" + ) + + +def _validation(detail: str) -> ProblemError: + return ProblemError( + status_code=400, + code="VALIDATION_ERROR", + detail=detail, + title="Invalid request", + ) + + +def _encode_cursor(occurred_at: datetime, audit_id: UUID) -> str: + payload = {"t": occurred_at.isoformat(), "id": str(audit_id)} + raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii") + + +def _decode_cursor(cursor: str) -> tuple[datetime, UUID]: + try: + raw = base64.urlsafe_b64decode(cursor.encode("ascii")) + payload = json.loads(raw) + return datetime.fromisoformat(payload["t"]), UUID(payload["id"]) + except ( + binascii.Error, + UnicodeDecodeError, + json.JSONDecodeError, + KeyError, + TypeError, + ValueError, + ) as exc: + raise _validation("The pagination cursor is invalid.") from exc + + +class ConsultationHistoryService: + """Read-only access to the consultation audit timeline.""" + + def __init__(self, session: Session) -> None: + self._session = session + self.consultations = ConsultationRepository(session) + self.audit = AuditRepository(session) + + def list_page( + self, + context: AuthorizationContext, + consultation_id: UUID, + *, + limit: int = DEFAULT_PAGE_SIZE, + cursor: str | None = None, + ) -> tuple[list[AuditRecord], str | None]: + if ( + self.consultations.get( + workspace_id=context.workspace_id, consultation_id=consultation_id + ) + is None + ): + raise _not_found() + if not 1 <= limit <= MAX_PAGE_SIZE: + raise _validation("The page limit is out of range.") + after = _decode_cursor(cursor) if cursor is not None else None + rows = self.audit.list_page_for_entity( + context.workspace_id, + ENTITY_TYPE, + consultation_id, + limit=limit + 1, + after=after, + ) + has_more = len(rows) > limit + page = rows[:limit] + next_cursor = None + if has_more and page: + last = page[-1] + next_cursor = _encode_cursor(last.occurred_at, last.id) + return page, next_cursor diff --git a/services/work-management/tests/test_consultation_attachment_integration.py b/services/work-management/tests/test_consultation_attachment_integration.py new file mode 100644 index 0000000..94bf4d3 --- /dev/null +++ b/services/work-management/tests/test_consultation_attachment_integration.py @@ -0,0 +1,685 @@ +"""HTTP integration tests for consultation attachments, evidence, history (Issue #4). + +These tests exercise the public contract of the multisource intake evidence +pipeline: hostile uploads fail closed, the content-type allowlist and size limit +are enforced on decoded bytes, quarantined bytes can never be downloaded or cited +as evidence, tenant/role boundaries hold, and the append-only audit trail is +projected back as a paginated consultation history. They skip themselves when no +test database is configured. +""" + +from __future__ import annotations + +import base64 +from uuid import uuid4 + +from fastapi.testclient import TestClient +from eventflow_work_management.api.app import create_app +from eventflow_work_management.db.engine import session_factory +from eventflow_work_management.security.attachment_scanner import ( + CLEAN, + INFECTED, + StaticMalwareScanner, +) +from eventflow_work_management.security.config import AuthSettings +from eventflow_work_management.security.crypto import TokenCipher +from support import require_database_url, temporary_database + +ISSUER = "https://id.example.test/realms/eventflow" + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 +TEXT_BYTES = b"hello world" + + +class FakeOIDC: + """Maps each bearer token to a distinct internal identity.""" + + def validate_access_token(self, token: str) -> dict: + return {"iss": ISSUER, "sub": token} + + def close(self) -> None: # pragma: no cover - lifecycle no-op + return None + + +def _settings() -> AuthSettings: + return AuthSettings( + environment="test", + issuer=ISSUER, + audience="eventflow-api", + web_client_id="eventflow-web-bff", + native_client_ids=("eventflow-desktop", "eventflow-vscode"), + redirect_uri="https://api.example.test/v1/auth/oidc/callback", + frontend_redirect_uri="https://web.example.test/auth/complete", + allowed_origins=("https://web.example.test",), + token_encryption_keys=(TokenCipher.generate_key(),), + ) + + +def _bearer(subject: str) -> dict: + return {"Authorization": f"Bearer {subject}"} + + +def _client(engine, scanner=None) -> TestClient: + app = create_app( + settings=_settings(), + session_factory_override=session_factory(engine), + oidc_client=FakeOIDC(), + malware_scanner=scanner, + ) + return TestClient(app) + + +def _principal_id(client: TestClient, subject: str) -> str: + response = client.get("/v1/auth/principal", headers=_bearer(subject)) + assert response.status_code == 200, response.text + return response.json()["user_id"] + + +def _make_workspace(client: TestClient, subject: str, name: str) -> str: + created = client.post( + "/v1/workspaces", + json={"name": name, "slug": f"{name.lower().replace(' ', '-')}-{uuid4().hex[:6]}"}, + headers=_bearer(subject), + ) + assert created.status_code == 201, created.text + return created.json()["workspace_id"] + + +def _make_project(client: TestClient, subject: str, workspace_id: str) -> str: + created = client.post( + f"/v1/workspaces/{workspace_id}/projects", + json={"key": "PAY", "name": "Payments"}, + headers=_bearer(subject), + ) + assert created.status_code == 201, created.text + return created.json()["project_id"] + + +def _invite_and_accept( + client: TestClient, owner: str, workspace_id: str, invitee: str, role: str +) -> None: + invitee_id = _principal_id(client, invitee) + invitation = client.post( + f"/v1/workspaces/{workspace_id}/invitations", + json={"invited_user_id": invitee_id, "role": role}, + headers=_bearer(owner), + ) + assert invitation.status_code == 201, invitation.text + body = invitation.json() + accepted = client.post( + f"/v1/workspaces/{workspace_id}/invitations/{body['invitation']['invitation_id']}/accept", + json={"token": body["token"]}, + headers=_bearer(invitee), + ) + assert accepted.status_code == 201, accepted.text + + +def _create_consultation( + client: TestClient, subject: str, workspace_id: str, project_id: str +) -> str: + created = client.post( + f"/v1/workspaces/{workspace_id}/consultations", + json={ + "project_id": project_id, + "origin": "web", + "title": "Export button crashes the dashboard", + "description": "Clicking export raises an unhandled 500 error.", + }, + headers=_bearer(subject), + ) + assert created.status_code == 201, created.text + return created.json()["consultation_id"] + + +def _attachments_url(workspace_id: str, consultation_id: str) -> str: + return f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/attachments" + + +def _upload( + client: TestClient, + subject: str, + workspace_id: str, + consultation_id: str, + *, + file_name: str = "diagram.png", + content_type: str = "image/png", + data: bytes = PNG_BYTES, + key: str | None = None, +): + headers = _bearer(subject) + if key is not None: + headers["Idempotency-Key"] = key + return client.post( + _attachments_url(workspace_id, consultation_id), + json={ + "file_name": file_name, + "content_type": content_type, + "content_base64": base64.b64encode(data).decode("ascii"), + }, + headers=headers, + ) + + +def _evidence_url(workspace_id: str, consultation_id: str) -> str: + return f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/evidence" + + +def test_attachment_clean_upload_download_and_delete(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine, StaticMalwareScanner(CLEAN)) as client: + workspace_id = _make_workspace(client, "alice", "Attach Co") + project_id = _make_project(client, "alice", workspace_id) + consultation_id = _create_consultation( + client, "alice", workspace_id, project_id + ) + + uploaded = _upload(client, "alice", workspace_id, consultation_id) + assert uploaded.status_code == 201, uploaded.text + body = uploaded.json() + attachment_id = body["attachment_id"] + assert body["status"] == "available" + assert body["scan_verdict"] == "clean" + assert body["rejection_reason"] is None + assert body["content_type"] == "image/png" + assert body["size_bytes"] == len(PNG_BYTES) + assert uploaded.headers["ETag"].startswith('"') + assert uploaded.headers["Location"].endswith(f"/attachments/{attachment_id}") + + listed = client.get( + _attachments_url(workspace_id, consultation_id), headers=_bearer("alice") + ) + assert listed.status_code == 200, listed.text + assert [item["attachment_id"] for item in listed.json()["items"]] == [ + attachment_id + ] + assert listed.headers["ETag"].startswith('"') + + fetched = client.get( + f"{_attachments_url(workspace_id, consultation_id)}/{attachment_id}", + headers=_bearer("alice"), + ) + assert fetched.status_code == 200, fetched.text + assert fetched.json()["status"] == "available" + + downloaded = client.get( + f"{_attachments_url(workspace_id, consultation_id)}/{attachment_id}/content", + headers=_bearer("alice"), + ) + assert downloaded.status_code == 200, downloaded.text + assert downloaded.content == PNG_BYTES + assert downloaded.headers["X-Content-Type-Options"] == "nosniff" + assert downloaded.headers["Content-Disposition"].startswith("attachment;") + + deleted = client.delete( + f"{_attachments_url(workspace_id, consultation_id)}/{attachment_id}", + headers=_bearer("alice"), + ) + assert deleted.status_code == 204, deleted.text + + gone = client.get( + f"{_attachments_url(workspace_id, consultation_id)}/{attachment_id}", + headers=_bearer("alice"), + ) + assert gone.status_code == 404 + assert ( + client.get( + f"{_attachments_url(workspace_id, consultation_id)}/{attachment_id}/content", + headers=_bearer("alice"), + ).status_code + == 404 + ) + + +def test_attachment_fails_closed_without_a_scanner(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine) as client: + workspace_id = _make_workspace(client, "alice", "Quarantine Co") + project_id = _make_project(client, "alice", workspace_id) + consultation_id = _create_consultation( + client, "alice", workspace_id, project_id + ) + + uploaded = _upload(client, "alice", workspace_id, consultation_id) + assert uploaded.status_code == 201, uploaded.text + body = uploaded.json() + assert body["status"] == "quarantined" + assert body["scan_verdict"] == "unknown" + + blocked = client.get( + f"{_attachments_url(workspace_id, consultation_id)}" + f"/{body['attachment_id']}/content", + headers=_bearer("alice"), + ) + assert blocked.status_code == 409 + assert blocked.json()["code"] == "ATTACHMENT_QUARANTINED" + + +def test_infected_attachment_is_rejected_and_never_stored(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine, StaticMalwareScanner(INFECTED)) as client: + workspace_id = _make_workspace(client, "alice", "Malware Co") + project_id = _make_project(client, "alice", workspace_id) + consultation_id = _create_consultation( + client, "alice", workspace_id, project_id + ) + + uploaded = _upload(client, "alice", workspace_id, consultation_id) + assert uploaded.status_code == 201, uploaded.text + body = uploaded.json() + assert body["status"] == "rejected" + assert body["scan_verdict"] == "infected" + assert body["rejection_reason"] == "malware_detected" + + blocked = client.get( + f"{_attachments_url(workspace_id, consultation_id)}" + f"/{body['attachment_id']}/content", + headers=_bearer("alice"), + ) + assert blocked.status_code == 409 + assert blocked.json()["code"] == "ATTACHMENT_QUARANTINED" + + +def test_attachment_validation_rejects_bad_input(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine, StaticMalwareScanner(CLEAN)) as client: + workspace_id = _make_workspace(client, "alice", "Validate Co") + project_id = _make_project(client, "alice", workspace_id) + consultation_id = _create_consultation( + client, "alice", workspace_id, project_id + ) + url = _attachments_url(workspace_id, consultation_id) + + def post(payload): + return client.post(url, json=payload, headers=_bearer("alice")) + + bad_base64 = post( + { + "file_name": "diagram.png", + "content_type": "image/png", + "content_base64": "not valid base64!!", + } + ) + assert bad_base64.status_code == 400, bad_base64.text + assert bad_base64.headers["content-type"] == "application/problem+json" + + unsupported = post( + { + "file_name": "archive.zip", + "content_type": "application/zip", + "content_base64": base64.b64encode(b"PK\x03\x04").decode("ascii"), + } + ) + assert unsupported.status_code == 422 + + svg = post( + { + "file_name": "evil.svg", + "content_type": "image/svg+xml", + "content_base64": base64.b64encode(b"").decode("ascii"), + } + ) + assert svg.status_code == 422 + + mismatch = post( + { + "file_name": "diagram.png", + "content_type": "image/png", + "content_base64": base64.b64encode(TEXT_BYTES).decode("ascii"), + } + ) + assert mismatch.status_code == 422 + + traversal = post( + { + "file_name": "../etc/passwd", + "content_type": "image/png", + "content_base64": base64.b64encode(PNG_BYTES).decode("ascii"), + } + ) + assert traversal.status_code == 422 + + empty = post( + { + "file_name": "diagram.png", + "content_type": "image/png", + "content_base64": "", + } + ) + assert empty.status_code == 400 + + oversize = post( + { + "file_name": "diagram.png", + "content_type": "image/png", + "content_base64": base64.b64encode( + b"\x89PNG\r\n\x1a\n" + b"\x00" * (5 * 1024 * 1024 + 1) + ).decode("ascii"), + } + ) + assert oversize.status_code == 422 + + listed = client.get(url, headers=_bearer("alice")) + assert listed.json()["items"] == [] + + +def test_attachment_quota_is_enforced(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine, StaticMalwareScanner(CLEAN)) as client: + workspace_id = _make_workspace(client, "alice", "Quota Co") + project_id = _make_project(client, "alice", workspace_id) + consultation_id = _create_consultation( + client, "alice", workspace_id, project_id + ) + + for index in range(25): + response = _upload( + client, + "alice", + workspace_id, + consultation_id, + file_name=f"file-{index}.png", + ) + assert response.status_code == 201, response.text + + overflow = _upload( + client, "alice", workspace_id, consultation_id, file_name="one-too-many.png" + ) + assert overflow.status_code == 409 + assert overflow.json()["code"] == "ATTACHMENT_LIMIT_REACHED" + + +def test_evidence_kinds_and_attachment_reference(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine, StaticMalwareScanner(CLEAN)) as client: + workspace_id = _make_workspace(client, "alice", "Evidence Co") + project_id = _make_project(client, "alice", workspace_id) + consultation_id = _create_consultation( + client, "alice", workspace_id, project_id + ) + url = _evidence_url(workspace_id, consultation_id) + + text = client.post( + url, + json={"kind": "text", "content": "Reproduced on the staging build."}, + headers=_bearer("alice"), + ) + assert text.status_code == 201, text.text + assert text.json()["kind"] == "text" + + link = client.post( + url, + json={ + "kind": "link", + "content": "https://logs.example.test/trace/12345", + "description": "Server trace", + }, + headers=_bearer("alice"), + ) + assert link.status_code == 201, link.text + assert link.json()["kind"] == "link" + + attachment_id = _upload( + client, "alice", workspace_id, consultation_id + ).json()["attachment_id"] + attached = client.post( + url, + json={"kind": "attachment", "attachment_id": attachment_id}, + headers=_bearer("alice"), + ) + assert attached.status_code == 201, attached.text + assert attached.json()["attachment_id"] == attachment_id + + listed = client.get(url, headers=_bearer("alice")) + assert listed.status_code == 200 + assert [item["kind"] for item in listed.json()["items"]] == [ + "text", + "link", + "attachment", + ] + + missing = client.post( + url, + json={"kind": "attachment", "attachment_id": str(uuid4())}, + headers=_bearer("alice"), + ) + assert missing.status_code == 404 + + assert ( + client.post( + url, json={"kind": "text"}, headers=_bearer("alice") + ).status_code + == 422 + ) + assert ( + client.post( + url, json={"kind": "attachment"}, headers=_bearer("alice") + ).status_code + == 422 + ) + assert ( + client.post( + url, + json={ + "kind": "text", + "content": "x", + "attachment_id": attachment_id, + }, + headers=_bearer("alice"), + ).status_code + == 422 + ) + + +def test_evidence_cannot_cite_quarantined_attachment(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine) as client: + workspace_id = _make_workspace(client, "alice", "Cite Co") + project_id = _make_project(client, "alice", workspace_id) + consultation_id = _create_consultation( + client, "alice", workspace_id, project_id + ) + + attachment_id = _upload( + client, "alice", workspace_id, consultation_id + ).json()["attachment_id"] + cited = client.post( + _evidence_url(workspace_id, consultation_id), + json={"kind": "attachment", "attachment_id": attachment_id}, + headers=_bearer("alice"), + ) + assert cited.status_code == 409 + assert cited.json()["code"] == "INVALID_STATE" + + +def test_attachment_and_evidence_idempotency(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine, StaticMalwareScanner(CLEAN)) as client: + workspace_id = _make_workspace(client, "alice", "Idem Co") + project_id = _make_project(client, "alice", workspace_id) + consultation_id = _create_consultation( + client, "alice", workspace_id, project_id + ) + + first = _upload( + client, "alice", workspace_id, consultation_id, key="upload-key-0001" + ) + assert first.status_code == 201, first.text + attachment_id = first.json()["attachment_id"] + + replay = _upload( + client, "alice", workspace_id, consultation_id, key="upload-key-0001" + ) + assert replay.status_code == 201 + assert replay.json()["attachment_id"] == attachment_id + + conflict = _upload( + client, + "alice", + workspace_id, + consultation_id, + file_name="different.png", + key="upload-key-0001", + ) + assert conflict.status_code == 409 + assert conflict.json()["code"] == "IDEMPOTENCY_CONFLICT" + + evidence_url = _evidence_url(workspace_id, consultation_id) + evidence_headers = {**_bearer("alice"), "Idempotency-Key": "evidence-key-0001"} + created = client.post( + evidence_url, + json={"kind": "text", "content": "First and only."}, + headers=evidence_headers, + ) + assert created.status_code == 201, created.text + evidence_id = created.json()["evidence_id"] + evidence_replay = client.post( + evidence_url, + json={"kind": "text", "content": "First and only."}, + headers=evidence_headers, + ) + assert evidence_replay.status_code == 201 + assert evidence_replay.json()["evidence_id"] == evidence_id + + assert len(client.get(evidence_url, headers=_bearer("alice")).json()["items"]) == 1 + + +def test_attachment_rbac_and_tenant_isolation(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine, StaticMalwareScanner(CLEAN)) as client: + workspace_id = _make_workspace(client, "owner", "Authz Co") + project_id = _make_project(client, "owner", workspace_id) + _invite_and_accept(client, "owner", workspace_id, "author", "contributor") + _invite_and_accept(client, "owner", workspace_id, "peer", "contributor") + _invite_and_accept(client, "owner", workspace_id, "root", "admin") + _invite_and_accept(client, "owner", workspace_id, "watch", "viewer") + consultation_id = _create_consultation( + client, "owner", workspace_id, project_id + ) + + assert ( + _upload(client, "watch", workspace_id, consultation_id).status_code == 403 + ) + + uploaded = _upload(client, "author", workspace_id, consultation_id) + assert uploaded.status_code == 201, uploaded.text + attachment_id = uploaded.json()["attachment_id"] + + assert ( + client.get( + _attachments_url(workspace_id, consultation_id), + headers=_bearer("watch"), + ).status_code + == 200 + ) + + assert ( + client.get( + _attachments_url(workspace_id, consultation_id), + headers=_bearer("mallory"), + ).status_code + == 404 + ) + assert ( + _upload(client, "mallory", workspace_id, consultation_id).status_code + == 404 + ) + + url = f"{_attachments_url(workspace_id, consultation_id)}/{attachment_id}" + assert client.delete(url, headers=_bearer("peer")).status_code == 403 + assert client.delete(url, headers=_bearer("author")).status_code == 204 + + second = _upload(client, "author", workspace_id, consultation_id) + second_id = second.json()["attachment_id"] + second_url = ( + f"{_attachments_url(workspace_id, consultation_id)}/{second_id}" + ) + assert client.delete(second_url, headers=_bearer("root")).status_code == 204 + + other_workspace = _make_workspace(client, "alice", "Other Co") + assert ( + client.get( + _attachments_url(other_workspace, consultation_id), + headers=_bearer("alice"), + ).status_code + == 404 + ) + + +def test_consultation_history_is_paginated_and_audited(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine, StaticMalwareScanner(CLEAN)) as client: + workspace_id = _make_workspace(client, "owner", "History Co") + project_id = _make_project(client, "owner", workspace_id) + consultation_id = _create_consultation( + client, "owner", workspace_id, project_id + ) + + for index in range(3): + _upload( + client, + "owner", + workspace_id, + consultation_id, + file_name=f"shot-{index}.png", + ) + client.post( + _evidence_url(workspace_id, consultation_id), + json={"kind": "text", "content": "Noted."}, + headers=_bearer("owner"), + ) + + history_url = ( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/history" + ) + first_page = client.get( + history_url, params={"limit": 2}, headers=_bearer("owner") + ) + assert first_page.status_code == 200, first_page.text + body = first_page.json() + assert len(body["items"]) == 2 + assert body["page_info"]["has_more"] is True + cursor = body["page_info"]["next_cursor"] + assert cursor + + collected = list(body["items"]) + while cursor: + page = client.get( + history_url, + params={"limit": 2, "cursor": cursor}, + headers=_bearer("owner"), + ) + assert page.status_code == 200, page.text + collected.extend(page.json()["items"]) + cursor = page.json()["page_info"]["next_cursor"] + + actions = [item["action"] for item in collected] + assert actions.count("attachment.uploaded") == 3 + assert "evidence.added" in actions + + invalid = client.get( + history_url, params={"cursor": "!!not-a-cursor!!"}, headers=_bearer("owner") + ) + assert invalid.status_code == 400 + + assert ( + client.get(history_url, headers=_bearer("mallory")).status_code == 404 + ) + + +def test_rejected_upload_is_recorded_in_history(): + with temporary_database(require_database_url()) as (_, engine): + with _client(engine, StaticMalwareScanner(INFECTED)) as client: + workspace_id = _make_workspace(client, "owner", "Reject Co") + project_id = _make_project(client, "owner", workspace_id) + consultation_id = _create_consultation( + client, "owner", workspace_id, project_id + ) + + _upload(client, "owner", workspace_id, consultation_id) + history = client.get( + f"/v1/workspaces/{workspace_id}/consultations/{consultation_id}/history", + headers=_bearer("owner"), + ) + assert history.status_code == 200, history.text + actions = [item["action"] for item in history.json()["items"]] + assert "attachment.rejected" in actions diff --git a/services/work-management/tests/test_migrations_integration.py b/services/work-management/tests/test_migrations_integration.py index 72bd942..d3242e4 100644 --- a/services/work-management/tests/test_migrations_integration.py +++ b/services/work-management/tests/test_migrations_integration.py @@ -29,6 +29,9 @@ "authentication_security_events", "workspace_memberships", "workspace_invitations", + "consultation_attachments", + "consultation_attachment_blobs", + "consultation_evidence", } EXPECTED_TRIGGERS = { @@ -98,7 +101,7 @@ def test_upgrade_creates_schema_guards_and_revision(): tables = _tables(engine) assert EXPECTED_TABLES <= tables assert "alembic_version" in tables - assert _revision(engine) == "0005_consultation_drafts" + assert _revision(engine) == "0006_attachments_evidence" assert EXPECTED_TRIGGERS <= _triggers(engine) assert EXPECTED_FUNCTIONS <= _functions(engine) assert EXPECTED_CONSTRAINTS <= _constraints(engine) @@ -109,7 +112,7 @@ def test_upgrade_is_idempotent(): with temporary_empty_database(admin_url) as (url, engine): run_migrations(url) run_migrations(url) - assert _revision(engine) == "0005_consultation_drafts" + assert _revision(engine) == "0006_attachments_evidence" def test_downgrade_removes_tables_triggers_and_functions(): diff --git a/services/work-management/tests/test_models_unit.py b/services/work-management/tests/test_models_unit.py index ccf3486..7561490 100644 --- a/services/work-management/tests/test_models_unit.py +++ b/services/work-management/tests/test_models_unit.py @@ -27,6 +27,9 @@ "remote_integration_references", "workspace_memberships", "workspace_invitations", + "consultation_attachments", + "consultation_attachment_blobs", + "consultation_evidence", } AUTHENTICATION_TABLES = { @@ -136,6 +139,9 @@ def test_enum_check_values_match_contract(): "ProposalStatus": model_enums.PROPOSAL_STATUS_VALUES, "ApprovalDecision": model_enums.APPROVAL_DECISION_VALUES, "WorkItemStatus": model_enums.WORK_ITEM_STATUS_VALUES, + "AttachmentStatus": model_enums.ATTACHMENT_STATUS_VALUES, + "AttachmentScanVerdict": model_enums.ATTACHMENT_SCAN_VERDICT_VALUES, + "EvidenceKind": model_enums.EVIDENCE_KIND_VALUES, } for name, expected in expectations.items(): assert tuple(defs[name]["enum"]) == expected, ( diff --git a/services/work-management/tests/test_rbac_policy_unit.py b/services/work-management/tests/test_rbac_policy_unit.py index 55e8855..bd211ff 100644 --- a/services/work-management/tests/test_rbac_policy_unit.py +++ b/services/work-management/tests/test_rbac_policy_unit.py @@ -95,6 +95,14 @@ def test_policy_matches_declared_openapi_roles(): "updateConsultationDraft": Action.CONSULTATION_UPDATE, "submitConsultationDraft": Action.CONSULTATION_SUBMIT, "triageConsultation": Action.CONSULTATION_TRIAGE, + "createConsultationAttachment": Action.ATTACHMENT_UPLOAD, + "listConsultationAttachments": Action.ATTACHMENT_READ, + "getConsultationAttachment": Action.ATTACHMENT_READ, + "downloadConsultationAttachment": Action.ATTACHMENT_READ, + "deleteConsultationAttachment": Action.ATTACHMENT_DELETE, + "createConsultationEvidence": Action.EVIDENCE_CREATE, + "listConsultationEvidence": Action.EVIDENCE_READ, + "listConsultationHistory": Action.HISTORY_READ, "decideProposal": Action.APPROVAL_DECIDE, "moveBoardCard": Action.BOARD_MANAGE, } diff --git a/tests/contracts/test_work_management_api.py b/tests/contracts/test_work_management_api.py index 5023bef..5ed5b4c 100644 --- a/tests/contracts/test_work_management_api.py +++ b/tests/contracts/test_work_management_api.py @@ -63,6 +63,8 @@ "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", "PATCH /v1/workspaces/{workspace_id}/consultations/{consultation_id}", + "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/attachments", + "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/evidence", "POST /v1/workspaces/{workspace_id}/proposals", "POST /v1/workspaces/{workspace_id}/proposals/{proposal_id}/submit", "POST /v1/workspaces/{workspace_id}/proposals/{proposal_id}/decide", diff --git a/tests/security/test_security_contracts.py b/tests/security/test_security_contracts.py index b6db5f4..a524e81 100644 --- a/tests/security/test_security_contracts.py +++ b/tests/security/test_security_contracts.py @@ -313,6 +313,8 @@ def test_idempotency_key_declared_on_retryable_operations(wm_openapi): "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/triage", "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/submit", "PATCH /v1/workspaces/{workspace_id}/consultations/{consultation_id}", + "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/attachments", + "POST /v1/workspaces/{workspace_id}/consultations/{consultation_id}/evidence", "POST /v1/workspaces/{workspace_id}/proposals", "POST /v1/workspaces/{workspace_id}/proposals/{proposal_id}/submit", "POST /v1/workspaces/{workspace_id}/proposals/{proposal_id}/decide",