From 8378624fd2ccf0b25b55c63cb25e4acfe8e0d452 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 12:08:32 +0200 Subject: [PATCH 01/37] feat(api-v3): OS1 - kernel, findings read path, consolidated import (alpha) Phase OS1 of API_V3_PLAN.md, mounted at /api/v3-alpha/ (gated on V3_FEATURE_LOCATIONS): - dojo/api_v3/ kernel: token+session auth (reuses v2 token store), pagination envelope with hybrid exact/planner-estimate counts, RFC 9457 problem+json errors, expand/fields engine (cycle guard + budget, expand-driven select_related/prefetch_related), django-filter adapter, include=counts - dojo/finding/api_v3/: FindingSlim/FindingDetail schemas + build_findings_router() factory (GET list/detail) - dojo/importers/services.py: framework-neutral import facade returning structured ImportResult; POST /import with mode auto|import|reimport - unittests/api_v3/: 37 tests incl. constant-query-count guarantee (5 slim / 7 expanded, independent of row count) and v2 import DB-state equivalence New dependency: django-ninja==1.6.2 (pydantic v2 already present transitively). --- dojo/api_v3/__init__.py | 8 + dojo/api_v3/api.py | 66 ++++ dojo/api_v3/auth.py | 54 +++ dojo/api_v3/errors.py | 227 +++++++++++++ dojo/api_v3/expand.py | 182 ++++++++++ dojo/api_v3/filtering.py | 124 +++++++ dojo/api_v3/import_routes.py | 224 +++++++++++++ dojo/api_v3/include.py | 77 +++++ dojo/api_v3/pagination.py | 139 ++++++++ dojo/api_v3/refs.py | 77 +++++ dojo/finding/api_v3/__init__.py | 0 dojo/finding/api_v3/routes.py | 159 +++++++++ dojo/finding/api_v3/schemas.py | 315 ++++++++++++++++++ dojo/importers/services.py | 311 +++++++++++++++++ dojo/settings/settings.dist.py | 24 ++ dojo/urls.py | 12 + requirements.txt | 1 + unittests/api_v3/__init__.py | 0 unittests/api_v3/base.py | 62 ++++ unittests/api_v3/test_apiv3_auth.py | 49 +++ unittests/api_v3/test_apiv3_findings.py | 168 ++++++++++ .../api_v3/test_apiv3_findings_queries.py | 65 ++++ unittests/api_v3/test_apiv3_import.py | 119 +++++++ 23 files changed, 2463 insertions(+) create mode 100644 dojo/api_v3/__init__.py create mode 100644 dojo/api_v3/api.py create mode 100644 dojo/api_v3/auth.py create mode 100644 dojo/api_v3/errors.py create mode 100644 dojo/api_v3/expand.py create mode 100644 dojo/api_v3/filtering.py create mode 100644 dojo/api_v3/import_routes.py create mode 100644 dojo/api_v3/include.py create mode 100644 dojo/api_v3/pagination.py create mode 100644 dojo/api_v3/refs.py create mode 100644 dojo/finding/api_v3/__init__.py create mode 100644 dojo/finding/api_v3/routes.py create mode 100644 dojo/finding/api_v3/schemas.py create mode 100644 dojo/importers/services.py create mode 100644 unittests/api_v3/__init__.py create mode 100644 unittests/api_v3/base.py create mode 100644 unittests/api_v3/test_apiv3_auth.py create mode 100644 unittests/api_v3/test_apiv3_findings.py create mode 100644 unittests/api_v3/test_apiv3_findings_queries.py create mode 100644 unittests/api_v3/test_apiv3_import.py diff --git a/dojo/api_v3/__init__.py b/dojo/api_v3/__init__.py new file mode 100644 index 00000000000..8367f50156f --- /dev/null +++ b/dojo/api_v3/__init__.py @@ -0,0 +1,8 @@ +""" +API v3 (alpha) kernel package. + +The NinjaAPI instance and mount assembly live in ``dojo.api_v3.api`` (imported by ``dojo/urls.py`` +only when ``V3_FEATURE_LOCATIONS`` is on). This package root is deliberately empty of imports so +that importing a kernel submodule (``refs``/``expand``/...) never eagerly builds the API -- that +would create an import cycle with the resource route modules. +""" diff --git a/dojo/api_v3/api.py b/dojo/api_v3/api.py new file mode 100644 index 00000000000..92f8018094c --- /dev/null +++ b/dojo/api_v3/api.py @@ -0,0 +1,66 @@ +""" +NinjaAPI instance and mount assembly for API v3 (alpha) (D1, D2 / §4.1). + +Builds the single ``NinjaAPI`` instance for v3 and assembles the OS routers via their factories +(I5). Imported (and thus built) only when ``V3_FEATURE_LOCATIONS`` is on -- ``dojo/urls.py`` mounts +it conditionally, so with the flag off ``/api/v3-alpha/`` does not exist at all and v3 carries no +legacy-endpoint code path (D5). + +Auth is the pluggable ordered list ``[TokenAuth(), django_auth]`` (I7 / §4.2). CSRF for the +session (cookie) auth is enforced by ``django_auth`` itself (ninja's ``SessionAuth`` defaults +``csrf=True`` and checks it on unsafe methods); token (header) auth needs no CSRF. See §12 for why +``NinjaAPI(csrf=...)`` is not passed (the parameter no longer exists in django-ninja 1.6.x). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from django.conf import settings +from ninja import NinjaAPI +from ninja.security import django_auth + +from dojo.api_v3.auth import TokenAuth +from dojo.api_v3.errors import register_exception_handlers + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + + +class V3NinjaAPI(NinjaAPI): + + """NinjaAPI that stamps ``X-API-Status`` on every response it creates (§4.1).""" + + def create_response(self, request: HttpRequest, data: Any, *args: Any, **kwargs: Any) -> HttpResponse: + response = super().create_response(request, data, *args, **kwargs) + response["X-API-Status"] = settings.API_V3_STATUS + return response + + +def build_api() -> NinjaAPI: + """Construct the v3 NinjaAPI instance and mount the OS1 routers via their factories.""" + api = V3NinjaAPI( + title="DefectDojo API v3", + version=settings.API_V3_VERSION, + description=( + "DefectDojo API v3 (alpha). The alpha contract may change at any time; do not build " + "production dependencies on this URL. At beta the API moves to /api/v3/ and stays there." + ), + auth=[TokenAuth(), django_auth], + urls_namespace="api_v3", + docs_url="/docs", + openapi_url="/openapi.json", + ) + register_exception_handlers(api) + + # Routers are factories (I5); the mount calls them with OS defaults. Imported here (not at + # module top) so the kernel submodules never depend on route modules -- dependency direction + # stays "resources import kernel", never the reverse. + from dojo.api_v3.import_routes import build_import_router # noqa: PLC0415 + from dojo.finding.api_v3.routes import build_findings_router # noqa: PLC0415 + + api.add_router("", build_findings_router()) + api.add_router("", build_import_router()) + return api + + +api_v3 = build_api() diff --git a/dojo/api_v3/auth.py b/dojo/api_v3/auth.py new file mode 100644 index 00000000000..00d6425667b --- /dev/null +++ b/dojo/api_v3/auth.py @@ -0,0 +1,54 @@ +""" +Authentication for API v3 (D8 / §4.2). + +Two modes, both active on every endpoint (registered as an ordered list ``[TokenAuth(), +django_auth]`` on the NinjaAPI instance): + +- **Token** -- reuses the existing v2 DRF token store (``rest_framework.authtoken.models.Token``). + A key that works on ``/api/v2/`` works on ``/api/v3/`` unchanged. No new model, no migration. +- **Session** -- Django session cookie + CSRF on unsafe methods, provided by ``ninja.security + .django_auth`` (registered in ``dojo/api_v3/__init__.py``). + +Each auth class returns ``None`` on no-match so the next one gets a chance; all failing yields a +401 problem+json. The token scheme is intentionally replaceable (I7): routes only ever see the +pluggable auth list, so the token backend can later be swapped with no route changes. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ninja.security import APIKeyHeader +from rest_framework.authtoken.models import Token + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractBaseUser + from django.http import HttpRequest + +# Header the token is read from; the ``Token `` prefix is parsed off in authenticate(). +_TOKEN_PREFIX = "token" # noqa: S105 -- scheme keyword, not a credential + + +class TokenAuth(APIKeyHeader): + + """Parse ``Authorization: Token `` and resolve the request user from the v2 token store.""" + + param_name = "Authorization" + + def authenticate(self, request: HttpRequest, key: str | None) -> AbstractBaseUser | None: + if not key: + return None + parts = key.split() + # Only handle the `Token ` scheme; anything else is left for session auth to try. + if len(parts) != 2 or parts[0].lower() != _TOKEN_PREFIX: + return None + try: + token = Token.objects.select_related("user").get(key=parts[1]) + except Token.DoesNotExist: + return None + user = token.user + if not user.is_active: + return None + # Set request.user so downstream authorized-queryset helpers (I8) resolve the right user + # exactly as they do for session auth. + request.user = user + return user diff --git a/dojo/api_v3/errors.py b/dojo/api_v3/errors.py new file mode 100644 index 00000000000..75c2960c85c --- /dev/null +++ b/dojo/api_v3/errors.py @@ -0,0 +1,227 @@ +""" +Error contract for API v3 (D9 / §4.10) and the shared success renderer. + +All error bodies are RFC 9457 ``application/problem+json`` with a ``fields`` extension for +validation errors. Routes never hand-build error bodies -- they raise ``ProblemDetail`` (or a +standard Django/ninja exception) and the registered handlers shape the response. Invariant I9: +the error contract is closed; new error kinds get new ``type`` URIs, not new shapes. + +The shared success renderer (``json_response``) lives here too because it shares the JSON +encoder and the ``X-API-Status`` header logic with the problem renderer. +""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.core.serializers.json import DjangoJSONEncoder +from django.http import Http404, JsonResponse +from ninja.errors import AuthenticationError, ValidationError + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + from ninja import NinjaAPI + +logger = logging.getLogger(__name__) + +_ERROR_TYPE_BASE = "https://docs.defectdojo.com/api/v3/errors/" + + +class V3JSONEncoder(DjangoJSONEncoder): + + """ + DjangoJSONEncoder already renders aware datetimes as ISO-8601 with a ``Z`` suffix and dates + as ``YYYY-MM-DD`` (§4.11). We normalise aware datetimes to UTC first so the ``Z`` conversion + always applies regardless of the active timezone. + """ + + def default(self, o): + import datetime # noqa: PLC0415 -- localized to the encoder hot path + + if isinstance(o, datetime.datetime) and o.tzinfo is not None: + o = o.astimezone(datetime.UTC) + return super().default(o) + + +def _with_status_header(response: HttpResponse) -> HttpResponse: + response["X-API-Status"] = settings.API_V3_STATUS + return response + + +def json_response(data, *, status: int = 200) -> JsonResponse: + """Shared success renderer: JSON body + ``X-API-Status`` header, v3 datetime conventions.""" + response = JsonResponse(data, status=status, encoder=V3JSONEncoder, safe=False) + return _with_status_header(response) + + +def problem_response( + request: HttpRequest, + *, + status: int, + error_type: str, + title: str, + detail: str | None = None, + fields: dict | None = None, +) -> JsonResponse: + """Build an RFC 9457 ``application/problem+json`` response (§4.10).""" + body: dict = { + "type": _ERROR_TYPE_BASE + error_type, + "title": title, + "status": status, + } + if detail is not None: + body["detail"] = detail + if fields is not None: + body["fields"] = fields + response = JsonResponse(body, status=status, encoder=V3JSONEncoder) + response["Content-Type"] = "application/problem+json" + return _with_status_header(response) + + +class ProblemDetail(Exception): # noqa: N818 -- RFC 9457 "problem detail"; not an "*Error" by name + + """ + Raise from routes/kernel to emit a problem+json response. The single registered handler + shapes it, so callers never touch response objects (keeps I9 honest). + """ + + def __init__( + self, + *, + status: int, + error_type: str, + title: str, + detail: str | None = None, + fields: dict | None = None, + ) -> None: + super().__init__(title) + self.status = status + self.error_type = error_type + self.title = title + self.detail = detail + self.fields = fields + + +# --- Convenience constructors for the common problem kinds ------------------------------------- + +def validation_problem(fields: dict, *, detail: str | None = None) -> ProblemDetail: + n = len(fields) + return ProblemDetail( + status=400, + error_type="validation", + title="Validation failed", + detail=detail if detail is not None else f"{n} field{'s' if n != 1 else ''} failed validation", + fields=fields, + ) + + +def expand_problem(detail: str) -> ProblemDetail: + return ProblemDetail(status=400, error_type="expand", title="Invalid expand", detail=detail) + + +def filter_problem(detail: str) -> ProblemDetail: + return ProblemDetail(status=400, error_type="filter", title="Invalid filter", detail=detail) + + +def pagination_problem(detail: str) -> ProblemDetail: + return ProblemDetail(status=400, error_type="pagination", title="Invalid pagination", detail=detail) + + +def not_found_problem(detail: str = "Not found") -> ProblemDetail: + # 404 for unknown *or unauthorized* objects -- never leak existence (§4.10). + return ProblemDetail(status=404, error_type="not-found", title="Not found", detail=detail) + + +# --- Handler registration --------------------------------------------------------------------- + +def _handle_problem_detail(request: HttpRequest, exc: ProblemDetail) -> JsonResponse: + return problem_response( + request, + status=exc.status, + error_type=exc.error_type, + title=exc.title, + detail=exc.detail, + fields=exc.fields, + ) + + +def _handle_ninja_validation(request: HttpRequest, exc: ValidationError) -> JsonResponse: + # Reshape ninja/pydantic request-validation errors into the field-keyed contract (§4.10). + fields: dict[str, list[str]] = {} + for err in exc.errors: + loc = err.get("loc", ()) + # Drop the leading source segment ("body"/"query"/"form"/"path") for a clean field name. + parts = [str(p) for p in loc[1:]] if loc and loc[0] in {"body", "query", "form", "path"} else [str(p) for p in loc] + key = ".".join(parts) if parts else "non_field_errors" + fields.setdefault(key, []).append(err.get("msg", "Invalid value.")) + return _handle_problem_detail(request, validation_problem(fields)) + + +def _handle_auth_error(request: HttpRequest, exc: AuthenticationError) -> JsonResponse: + return problem_response( + request, + status=401, + error_type="unauthorized", + title="Authentication required", + detail="Valid authentication credentials were not provided.", + ) + + +def _handle_permission_denied(request: HttpRequest, exc: PermissionDenied) -> JsonResponse: + return problem_response( + request, + status=403, + error_type="forbidden", + title="Permission denied", + detail="You do not have permission to perform this action.", + ) + + +def _handle_not_found(request: HttpRequest, exc: Http404) -> JsonResponse: + return problem_response( + request, + status=404, + error_type="not-found", + title="Not found", + detail="Not found", + ) + + +_DRF_ERROR_TYPES = {400: "validation", 401: "unauthorized", 403: "forbidden", 404: "not-found"} +_DRF_ERROR_TITLES = { + 400: "Validation failed", + 401: "Authentication required", + 403: "Permission denied", + 404: "Not found", +} + + +def _handle_drf_api_exception(request: HttpRequest, exc) -> JsonResponse: + """ + Boundary adapter: v3 reuses v2 permission/import helpers that raise DRF ``APIException`` + subclasses (PermissionDenied/ValidationError/NotFound). Map them onto the closed problem+json + contract so a reused helper never leaks a 500 (I9). + """ + status = getattr(exc, "status_code", 400) + detail = getattr(exc, "detail", str(exc)) + error_type = _DRF_ERROR_TYPES.get(status, "error") + title = _DRF_ERROR_TITLES.get(status, "Error") + if isinstance(detail, dict): + fields = {k: (v if isinstance(v, list) else [str(v)]) for k, v in detail.items()} + return problem_response(request, status=status, error_type=error_type, title=title, fields=fields) + detail_str = "; ".join(str(d) for d in detail) if isinstance(detail, list) else str(detail) + return problem_response(request, status=status, error_type=error_type, title=title, detail=detail_str) + + +def register_exception_handlers(api: NinjaAPI) -> None: + """Register every v3 problem+json handler on the given NinjaAPI instance.""" + from rest_framework.exceptions import APIException # noqa: PLC0415 -- boundary adapter only + + api.add_exception_handler(ProblemDetail, _handle_problem_detail) + api.add_exception_handler(ValidationError, _handle_ninja_validation) + api.add_exception_handler(AuthenticationError, _handle_auth_error) + api.add_exception_handler(PermissionDenied, _handle_permission_denied) + api.add_exception_handler(Http404, _handle_not_found) + api.add_exception_handler(APIException, _handle_drf_api_exception) diff --git a/dojo/api_v3/expand.py b/dojo/api_v3/expand.py new file mode 100644 index 00000000000..a78df73611c --- /dev/null +++ b/dojo/api_v3/expand.py @@ -0,0 +1,182 @@ +""" +``?expand=`` parsing, validation, queryset planning and serialization for API v3 (D3 / §4.6). + +Comma-separated dotted paths (``expand=test.engagement,reporter``). Each segment must be a +registered expandable relation of the current schema. Expanding swaps the ref for the target's +slim schema **inline** (no side blob) and drives the queryset: FK/O2O segments append +``select_related`` paths, M2M/reverse-FK segments append ``prefetch_related`` paths (this is the +real N+1 fix, replacing v2's post-serialization ``?prefetch=``). + +Two guards, both 400 problem+json: a cycle guard (a segment whose target model already appears in +its ancestry) and a node budget (``API_V3_EXPAND_BUDGET``). + +OS1 hard-scopes the registry to the finding relations reachable from ``FindingSlim``; OS2 keeps +the same mechanism but drives it from a fully generic registry. Each schema declares its own +``EXPANDABLE`` registry and a ``django_model`` attribute, so the walker below is already generic. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from django.conf import settings + +from dojo.api_v3.errors import expand_problem + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import QuerySet + + +@dataclass(frozen=True) +class ExpandRel: + + """One expandable relation entry in a schema's ``EXPANDABLE`` registry.""" + + attr: str # python attribute path on the object (may be dotted) + path: str # django ORM relation path segment(s) relative to the model + schema: type # target slim schema class + to_many: bool = False # True -> prefetch_related; False -> select_related + special: Callable | None = None # optional custom renderer(obj) -> value (e.g. edge rows) + + +def parse_expand(raw: str | None) -> list[list[str]]: + """Split ``expand=`` into a list of segment lists. Empty/missing -> [].""" + if not raw: + return [] + paths = [] + for raw_chunk in raw.split(","): + chunk = raw_chunk.strip() + if not chunk: + continue + segments = [s for s in chunk.split(".") if s] + if segments: + paths.append(segments) + return paths + + +def _walk( + segments: list[str], + schema: type, + tree: dict, + ancestry: tuple[type, ...], + select_related: set[str], + prefetch: set[str], + django_prefix: str, + node_counter: list[int], +) -> None: + registry: dict[str, ExpandRel] = getattr(schema, "EXPANDABLE", {}) + head, *rest = segments + rel = registry.get(head) + if rel is None: + msg = f"'{head}' is not an expandable relation of {schema.__name__}" + raise expand_problem(msg) + + node_counter[0] += 1 + if node_counter[0] > settings.API_V3_EXPAND_BUDGET: + msg = f"expand budget exceeded (max {settings.API_V3_EXPAND_BUDGET} nodes)" + raise expand_problem(msg) + + target_model = getattr(rel.schema, "django_model", None) + if target_model is not None and target_model in ancestry: + msg = f"expand cycle detected at '{head}' ({target_model.__name__})" + raise expand_problem(msg) + + full_path = f"{django_prefix}__{rel.path}" if django_prefix else rel.path + if rel.special is None: + if rel.to_many: + prefetch.add(full_path) + else: + select_related.add(full_path) + # Pull in the target schema's own ref/tag dependencies so serializing the expanded + # object never triggers per-row queries -- this is what keeps the query count constant + # under expand (the headline guarantee). + select_related.update(f"{full_path}__{sr}" for sr in getattr(rel.schema, "SELECT_RELATED", ())) + prefetch.update(f"{full_path}__{pf}" for pf in getattr(rel.schema, "PREFETCH_RELATED", ())) + + subtree = tree.setdefault(head, {}) + if rest: + if rel.special is not None: + msg = f"'{head}' cannot be expanded further" + raise expand_problem(msg) + next_ancestry = (*ancestry, target_model) if target_model is not None else ancestry + _walk(rest, rel.schema, subtree, next_ancestry, select_related, prefetch, full_path, node_counter) + + +def plan(root_schema: type, raw_expand: str | None) -> tuple[dict, set[str], set[str]]: + """ + Validate the expand paths against ``root_schema`` and return + ``(expand_tree, select_related_paths, prefetch_related_paths)``. + """ + tree: dict = {} + select_related: set[str] = set() + prefetch: set[str] = set() + node_counter = [0] + root_model = getattr(root_schema, "django_model", None) + ancestry: tuple[type, ...] = (root_model,) if root_model is not None else () + for segments in parse_expand(raw_expand): + _walk(segments, root_schema, tree, ancestry, select_related, prefetch, "", node_counter) + return tree, select_related, prefetch + + +def plan_queryset(queryset: QuerySet, select_related: set[str], prefetch: set[str]) -> QuerySet: + """Apply the planned ``select_related``/``prefetch_related`` paths to the queryset.""" + if select_related: + queryset = queryset.select_related(*sorted(select_related)) + if prefetch: + queryset = queryset.prefetch_related(*sorted(prefetch)) + return queryset + + +def _resolve_attr(obj: object, attr: str) -> object | None: + value = obj + for part in attr.split("."): + value = getattr(value, part, None) + if value is None: + return None + return value + + +def serialize(obj: object, schema: type, expand_tree: dict) -> dict: + """ + Produce the object's dict: the schema-driven slim shape, then each expanded key swapped from a + ref to the target's serialized shape (recursively). Schema-driven so a subclass that adds a + field serializes it automatically (I4). + """ + data = schema.model_validate(obj).model_dump(mode="python") + registry: dict[str, ExpandRel] = getattr(schema, "EXPANDABLE", {}) + for key, subtree in expand_tree.items(): + rel = registry[key] + if rel.special is not None: + data[key] = rel.special(obj) + continue + related = _resolve_attr(obj, rel.attr) + if related is None: + data[key] = None + elif rel.to_many: + data[key] = [serialize(item, rel.schema, subtree) for item in related.all()] + else: + data[key] = serialize(related, rel.schema, subtree) + return data + + +# --- ?fields= projection (§4.7) ---------------------------------------------------------------- + +def parse_fields(raw: str | None, allowed: set[str]) -> set[str] | None: + """Return the requested field allowlist (``id`` always included) or None. Unknown -> 400.""" + if not raw: + return None + requested = {f.strip() for f in raw.split(",") if f.strip()} + unknown = requested - allowed + if unknown: + msg = f"unknown field(s): {', '.join(sorted(unknown))}" + raise expand_problem(msg) + requested.add("id") + return requested + + +def apply_fields(data: dict, fields: set[str] | None) -> dict: + if fields is None: + return data + return {k: v for k, v in data.items() if k in fields} diff --git a/dojo/api_v3/filtering.py b/dojo/api_v3/filtering.py new file mode 100644 index 00000000000..dc8fba2065a --- /dev/null +++ b/dojo/api_v3/filtering.py @@ -0,0 +1,124 @@ +""" +Filter adapter for API v3 (D6 / §4.9). + +One filter contract, many projections. The grammar is fixed (``field``, ``field__gte/__lte/__gt/ +__lt/__in/__icontains/__isnull``, multi-sort ``o=``, free-text ``q=``); the per-object vocabulary +is a curated, declarative artifact (``FilterSpec``). This module is the framework-light adapter: +it reuses django-filter ``FilterSet`` (criterion 4) to apply the value filters, then applies +``o=`` and ``q=`` on top. + +Invariant I2: the grammar never varies per endpoint. The vocabulary snapshot test lands in OS2; +OS1 wires the mechanism and the findings vocabulary. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import django_filters as df +from django.db.models import Q + +from dojo.api_v3.errors import filter_problem + +if TYPE_CHECKING: + from django.db.models import QuerySet + from django.http import HttpRequest + +# Query params owned by the kernel; never treated as filter fields. +RESERVED_PARAMS = frozenset({"limit", "offset", "pagination", "expand", "fields", "include", "o", "q"}) + + +# --- Filter field constructors (the fixed grammar) -------------------------------------------- + +class NumberInFilter(df.BaseInFilter, df.NumberFilter): + + """`__in` for numeric fields (comma-separated).""" + + +class CharInFilter(df.BaseInFilter, df.CharFilter): + + """`__in` for char fields (comma-separated).""" + + +_BASE = { + "number": df.NumberFilter, + "char": df.CharFilter, + "bool": df.BooleanFilter, + "date": df.DateFilter, + "datetime": df.IsoDateTimeFilter, +} + + +def filter_field(field_path: str, lookup: str, kind: str, *, distinct: bool = False) -> df.Filter: + """Build a single django-filter Filter for the fixed grammar.""" + if lookup == "in": + cls = NumberInFilter if kind == "number" else CharInFilter + return cls(field_name=field_path, lookup_expr="in", distinct=distinct) + return _BASE[kind](field_name=field_path, lookup_expr=lookup, distinct=distinct) + + +@dataclass +class FilterSpec: + + """Declarative per-object filter vocabulary (a tested artifact in OS2).""" + + model: type + filters: dict[str, df.Filter] + # Ordering param name -> model field path. Only these orderings are permitted (§4.9). + orderings: dict[str, str] + # Fields scanned by free-text `q=`. + search_fields: list[str] = field(default_factory=list) + _filterset_class: type | None = None + + def filterset_class(self) -> type[df.FilterSet]: + if self._filterset_class is None: + meta = type("Meta", (), {"model": self.model, "fields": []}) + self._filterset_class = type( + f"{self.model.__name__}V3FilterSet", + (df.FilterSet,), + {**self.filters, "Meta": meta}, + ) + return self._filterset_class + + +def _apply_ordering(request: HttpRequest, queryset: QuerySet, orderings: dict[str, str]) -> QuerySet: + raw = request.GET.get("o") + if not raw: + return queryset + order_by = [] + for raw_token in raw.split(","): + token = raw_token.strip() + if not token: + continue + desc = token.startswith("-") + key = token[1:] if desc else token + if key not in orderings: + msg = f"'{key}' is not an orderable field" + raise filter_problem(msg) + path = orderings[key] + order_by.append(f"-{path}" if desc else path) + return queryset.order_by(*order_by) if order_by else queryset + + +def _apply_search(request: HttpRequest, queryset: QuerySet, search_fields: list[str]) -> QuerySet: + term = request.GET.get("q") + if not term or not search_fields: + return queryset + condition = Q() + for f in search_fields: + condition |= Q(**{f"{f}__icontains": term}) + return queryset.filter(condition) + + +def apply_filters(request: HttpRequest, queryset: QuerySet, spec: FilterSpec) -> QuerySet: + """Apply value filters (django-filter), then ``o=`` and ``q=``. Bad input -> 400 problem+json.""" + filterset = spec.filterset_class()(request.GET, queryset=queryset) + if not filterset.is_valid(): + # Reshape django-filter field errors into the flat filter problem detail. + messages = "; ".join( + f"{name}: {', '.join(str(e) for e in errs)}" for name, errs in filterset.errors.items() + ) + raise filter_problem(messages or "invalid filter parameters") + queryset = filterset.qs + queryset = _apply_ordering(request, queryset, spec.orderings) + return _apply_search(request, queryset, spec.search_fields) diff --git a/dojo/api_v3/import_routes.py b/dojo/api_v3/import_routes.py new file mode 100644 index 00000000000..f2aa496b49c --- /dev/null +++ b/dojo/api_v3/import_routes.py @@ -0,0 +1,224 @@ +""" +Consolidated import endpoint for API v3 (§4.13). + +``POST /import`` (multipart/form-data) with ``mode`` ``auto`` | ``import`` | ``reimport`` (default +``auto``). The route parses the form, checks permission mirroring the v2 +``UserHasImportPermission`` / ``UserHasReimportPermission`` semantics, then delegates **entirely** +to ``dojo/importers/services.py`` (I6). Destructive flags are never implied by mode -- when +omitted, importer defaults apply and the response echoes the effective values. + +Background processing is reserved (grammar only): ``background=true`` returns 400 in alpha. +""" +from __future__ import annotations + +from datetime import date # noqa: TC003 -- runtime import: ninja resolves the Form schema field types +from typing import TYPE_CHECKING + +from django.core.exceptions import PermissionDenied +from ninja import File, Form, Router, Schema +from ninja.constants import NOT_SET +from ninja.files import UploadedFile # noqa: TC002 -- runtime import: ninja resolves the File() param type + +from dojo.api_v3.errors import ProblemDetail, json_response, not_found_problem, validation_problem +from dojo.api_v3.refs import to_ref +from dojo.authorization.api_permissions import check_auto_create_permission +from dojo.authorization.authorization import user_has_permission +from dojo.importers.auto_create_context import AutoCreateContextManager +from dojo.importers.services import auto_import_scan, import_scan, reimport_scan +from dojo.models import Engagement, Test +from dojo.utils import get_object_or_none + +if TYPE_CHECKING: + from django.http import HttpRequest + + +class ImportForm(Schema): + + """Form payload for ``POST /import`` (§4.13).""" + + scan_type: str + mode: str = "auto" + engagement: int | None = None + test: int | None = None + product_name: str | None = None + engagement_name: str | None = None + product_type_name: str | None = None + test_title: str | None = None + auto_create_context: bool = False + close_old_findings: bool | None = None + do_not_reactivate: bool = False + minimum_severity: str = "Info" + active: bool | None = None + verified: bool | None = None + scan_date: date | None = None + service: str | None = None + version: str | None = None + environment: str = "Development" + tags: str | None = None + background: bool = False + + +def _split_tags(tags: str | None) -> list[str] | None: + if not tags: + return None + return [t.strip() for t in tags.split(",") if t.strip()] + + +def _require_permission(*, allowed: bool) -> None: + if not allowed: + raise PermissionDenied + + +def _resolve_engagement_for_import(request: HttpRequest, payload: ImportForm) -> Engagement: + """Resolve (or auto-create) the target engagement + check import permission (import mode).""" + if payload.engagement is not None: + engagement = get_object_or_none(Engagement, pk=payload.engagement) + if engagement is None: + msg = f'Engagement "{payload.engagement}" does not exist' + raise not_found_problem(msg) + _require_permission(allowed=user_has_permission(request.user, engagement, "import")) + return engagement + if not payload.auto_create_context: + raise validation_problem( + {"engagement": ["Need engagement or product_name + engagement_name to perform import"]}, + ) + _require_permission( + allowed=check_auto_create_permission( + request.user, None, payload.product_name, None, payload.engagement_name, None, + payload.product_type_name, + "Need engagement or product_name + engagement_name to perform import", + ), + ) + auto = AutoCreateContextManager() + context = { + "product_name": payload.product_name, + "engagement_name": payload.engagement_name, + "product_type_name": payload.product_type_name, + "auto_create_context": payload.auto_create_context, + } + auto.process_import_meta_data_from_dict(context) + return auto.get_or_create_engagement(**context) + + +def _resolve_test_for_reimport(request: HttpRequest, test_id: int) -> Test: + test = get_object_or_none(Test, pk=test_id) + if test is None: + msg = f'Test "{test_id}" does not exist' + raise not_found_problem(msg) + _require_permission(allowed=user_has_permission(request.user, test, "import")) + return test + + +def _check_auto_permission(request: HttpRequest, payload: ImportForm) -> None: + """Mirror UserHasReimportPermission: resolve target and check, else check auto-create.""" + auto = AutoCreateContextManager() + context = { + "scan_type": payload.scan_type, + "product_name": payload.product_name, + "engagement_name": payload.engagement_name, + "product_type_name": payload.product_type_name, + "test_title": payload.test_title, + "auto_create_context": payload.auto_create_context, + } + auto.process_import_meta_data_from_dict(context) + context["product"] = auto.get_target_product_if_exists(**context) + context["engagement"] = auto.get_target_engagement_if_exists(**context) + target_test = auto.get_target_test_if_exists(**context) + if target_test is not None: + _require_permission(allowed=user_has_permission(request.user, target_test, "import")) + return + if not payload.auto_create_context: + raise validation_problem( + {"test": ["Need test or product_name + engagement_name + scan_type to perform reimport"]}, + ) + _require_permission( + allowed=check_auto_create_permission( + request.user, context.get("product"), payload.product_name, context.get("engagement"), + payload.engagement_name, None, payload.product_type_name, + "Need test or product_name + engagement_name + scan_type to perform reimport", + ), + ) + + +def build_import_router(*, auth=NOT_SET) -> Router: + """Build the consolidated import router (I5).""" + router = Router(tags=["import"], auth=auth) + + @router.post("/import", url_name="import") + def import_endpoint( + request: HttpRequest, + payload: ImportForm = Form(...), # noqa: B008 -- ninja's declarative param default + file: UploadedFile | None = File(None), # noqa: B008 -- ninja's declarative param default + ): + if payload.background: + raise ProblemDetail( + status=400, error_type="import", title="Background import not available", + detail="background processing is not yet available", + ) + if payload.mode not in {"auto", "import", "reimport"}: + raise validation_problem({"mode": ["must be one of auto, import, reimport"]}) + + common = { + "user": request.user, + "scan_file": file, + "scan_type": payload.scan_type, + "minimum_severity": payload.minimum_severity, + "active": payload.active, + "verified": payload.verified, + "tags": _split_tags(payload.tags), + "scan_date": payload.scan_date, + "service": payload.service, + "version": payload.version, + "test_title": payload.test_title, + "environment": payload.environment, + "auto_create_context": payload.auto_create_context, + "do_not_reactivate": payload.do_not_reactivate, + } + + try: + if payload.mode == "import": + target_engagement = _resolve_engagement_for_import(request, payload) + result = import_scan( + engagement=target_engagement, + close_old_findings=False if payload.close_old_findings is None else payload.close_old_findings, + **common, + ) + elif payload.mode == "reimport" and payload.test is not None: + target_test = _resolve_test_for_reimport(request, payload.test) + result = reimport_scan( + test=target_test, + close_old_findings=True if payload.close_old_findings is None else payload.close_old_findings, + **common, + ) + else: + # mode == "auto", or reimport without an explicit test id -> resolve/auto-create. + _check_auto_permission(request, payload) + result = auto_import_scan( + engagement=get_object_or_none(Engagement, pk=payload.engagement) if payload.engagement else None, + product_name=payload.product_name, + engagement_name=payload.engagement_name, + product_type_name=payload.product_type_name, + close_old_findings=payload.close_old_findings, + **common, + ) + except PermissionDenied: + raise + except ProblemDetail: + raise + except (ValueError, TypeError) as exc: + msg = str(exc) + raise validation_problem({"non_field_errors": [msg]}) from exc + + return json_response({ + "mode_resolved": result.mode_resolved, + "test": to_ref(result.test), + "statistics": { + "new": result.new, + "reactivated": result.reactivated, + "closed": result.closed, + "untouched": result.untouched, + }, + "close_old_findings": result.close_old_findings, + }) + + return router diff --git a/dojo/api_v3/include.py b/dojo/api_v3/include.py new file mode 100644 index 00000000000..d4b359efb9f --- /dev/null +++ b/dojo/api_v3/include.py @@ -0,0 +1,77 @@ +""" +``?include=`` response add-ons for API v3 (D6 / §4.8). + +Namespaced add-ons rendered into ``meta``, computed over the **filtered, authorized** queryset. +The mechanism is a generic registry (``include_name -> callable(filtered_qs) -> dict``) so later +includes (and downstream-defined ones) plug in without contract changes (I1: capabilities extend +``meta``/``include``, never the envelope). Alpha ships ``include=counts`` on findings lists. + +The ``counts`` callable is field-name-driven (no resource import), keeping the kernel free of +per-resource dependencies (I5). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db.models import Count, Q + +from dojo.api_v3.errors import ProblemDetail + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import QuerySet + from django.http import HttpRequest + +_SEVERITIES = ("Critical", "High", "Medium", "Low", "Info") + +_INCLUDE_REGISTRY: dict[str, Callable[[QuerySet], dict]] = {} + + +def register_include(name: str, func: Callable[[QuerySet], dict]) -> None: + _INCLUDE_REGISTRY[name] = func + + +def finding_counts(filtered_qs: QuerySet) -> dict: + """Severity/status totals over the filtered, authorized findings queryset in one aggregate query.""" + severity_filters = { + f"sev_{sev.lower()}": Count("id", filter=Q(severity=sev)) for sev in _SEVERITIES + } + agg = filtered_qs.aggregate( + total=Count("id"), + active=Count("id", filter=Q(active=True)), + verified=Count("id", filter=Q(verified=True)), + duplicate=Count("id", filter=Q(duplicate=True)), + **severity_filters, + ) + return { + "total": agg["total"], + "active": agg["active"], + "verified": agg["verified"], + "duplicate": agg["duplicate"], + "severity": {sev: agg[f"sev_{sev.lower()}"] for sev in _SEVERITIES}, + } + + +register_include("counts", finding_counts) + + +def parse_include(raw: str | None) -> list[str]: + if not raw: + return [] + return [name.strip() for name in raw.split(",") if name.strip()] + + +def apply_includes(request: HttpRequest, filtered_qs: QuerySet, *, allowed: set[str]) -> dict: + """Return the ``meta`` fragment produced by the requested includes. Unknown/not-allowed -> 400.""" + meta: dict = {} + for name in parse_include(request.GET.get("include")): + if name not in allowed or name not in _INCLUDE_REGISTRY: + raise ProblemDetail( + status=400, + error_type="include", + title="Invalid include", + detail=f"'{name}' is not an available include for this endpoint", + ) + meta[name] = _INCLUDE_REGISTRY[name](filtered_qs) + return meta diff --git a/dojo/api_v3/pagination.py b/dojo/api_v3/pagination.py new file mode 100644 index 00000000000..4016d5715db --- /dev/null +++ b/dojo/api_v3/pagination.py @@ -0,0 +1,139 @@ +""" +Pagination envelope for API v3 (D4 / §4.3). + +One closed envelope ``{count, next, previous, results, meta}`` (I1); offset is the only mode in +alpha. ``?pagination=cursor`` is reserved and rejected with 400 so the param can't be squatted. + +Count strategy (hybrid exact -> planner estimate): + 1. Capped exact count via ``qs.order_by()[:CAP+1].count()`` (bounded cost). + 2. ``<= CAP`` -> ``count`` is exact. + 3. ``== CAP+1`` -> ``count`` is the Postgres planner's row estimate for the same filtered + queryset (``EXPLAIN (FORMAT JSON)`` -> ``Plan Rows``), clamped to ``max(estimate, CAP+1)``; + the envelope gains ``meta.count_exact = false``. +Never a full ``COUNT(*)`` on unbounded tables, never a second PK-prefetch phase. +""" +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING +from urllib.parse import urlencode, urlparse, urlunparse + +from django.conf import settings + +from dojo.api_v3.errors import pagination_problem + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import QuerySet + from django.http import HttpRequest + +logger = logging.getLogger(__name__) + + +def parse_pagination(request: HttpRequest) -> tuple[int, int]: + """Return ``(limit, offset)``; reject cursor mode and malformed values with 400 problem+json.""" + mode = request.GET.get("pagination", "offset") + if mode == "cursor": + msg = "cursor pagination not yet available" + raise pagination_problem(msg) + if mode != "offset": + msg = f"unknown pagination mode '{mode}'" + raise pagination_problem(msg) + + default_limit = settings.API_V3_PAGE_LIMIT_DEFAULT + max_limit = settings.API_V3_PAGE_LIMIT_MAX + try: + limit = int(request.GET.get("limit", default_limit)) + except (TypeError, ValueError): + msg = "limit must be an integer" + raise pagination_problem(msg) + try: + offset = int(request.GET.get("offset", 0)) + except (TypeError, ValueError): + msg = "offset must be an integer" + raise pagination_problem(msg) + if limit < 1: + msg = "limit must be >= 1" + raise pagination_problem(msg) + if offset < 0: + msg = "offset must be >= 0" + raise pagination_problem(msg) + return min(limit, max_limit), offset + + +def _planner_estimate(qs: QuerySet) -> int | None: + """Postgres planner row estimate for the filtered queryset (best effort).""" + try: + raw = qs.order_by().explain(format="json") + data = json.loads(raw) if isinstance(raw, str) else raw + return int(data[0]["Plan"]["Plan Rows"]) + except Exception: + logger.warning("api_v3 pagination: planner estimate failed; falling back to CAP+1", exc_info=True) + return None + + +def compute_count(count_qs: QuerySet, cap: int | None = None) -> tuple[int, bool]: + """Return ``(count, count_exact)`` per the hybrid strategy above.""" + if cap is None: + cap = settings.API_V3_COUNT_CAP + capped = count_qs.order_by()[: cap + 1].count() + if capped <= cap: + return capped, True + estimate = _planner_estimate(count_qs) + if estimate is None: + return cap + 1, False + return max(estimate, cap + 1), False + + +def _page_url(request: HttpRequest, limit: int, offset: int) -> str: + parsed = urlparse(request.build_absolute_uri()) + params = {} + # Preserve every existing query param (filters, expand, include, o, ...) except limit/offset. + for key in request.GET: + if key in {"limit", "offset"}: + continue + values = request.GET.getlist(key) + params[key] = values if len(values) > 1 else values[0] + params["limit"] = limit + params["offset"] = offset + return urlunparse(parsed._replace(query=urlencode(params, doseq=True))) + + +def paginate( + request: HttpRequest, + *, + count_qs: QuerySet, + page_qs: QuerySet, + serialize: Callable[[object], dict], + cap: int | None = None, +) -> dict: + """ + Build the pagination envelope. ``count_qs`` is the clean filtered/authorized queryset used for + counting; ``page_qs`` is the (select_related/annotated/prefetched/expand-planned) queryset the + page rows are read from. Both must derive from the same filtered, authorized base. + """ + limit, offset = parse_pagination(request) + count, count_exact = compute_count(count_qs, cap) + + rows = list(page_qs[offset : offset + limit]) + results = [serialize(row) for row in rows] + + # For an exact count, "next" follows the count; for an estimate the count may be wrong near the + # end, so base "next" on whether a full page came back (client tolerates an empty final page). + has_next = (offset + limit < count) if count_exact else (len(rows) == limit) + has_previous = offset > 0 + + envelope: dict = { + "count": count, + "next": _page_url(request, limit, offset + limit) if has_next else None, + "previous": _page_url(request, limit, max(0, offset - limit)) if has_previous else None, + "results": results, + } + meta: dict = {} + if not count_exact: + meta["count_exact"] = False + if meta: + envelope["meta"] = meta + return envelope diff --git a/dojo/api_v3/refs.py b/dojo/api_v3/refs.py new file mode 100644 index 00000000000..d2628b119f9 --- /dev/null +++ b/dojo/api_v3/refs.py @@ -0,0 +1,77 @@ +""" +Relation references for API v3 (D3 / §4.4). + +Every relation renders by default as a slim ``{id, name}`` ref produced by a single shared +schema. The relation key conveys the type, so refs carry no ``type`` field -- the sole +exception being location refs, whose one key can hold heterogeneous location subtypes. + +This module is the single source of truth for the ref *label registry* (which model attribute +supplies ``name`` for each model). Invariant I3: the ref shape is closed -- do not extend it. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ninja import Schema + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import Model + + +class Ref(Schema): + + """Closed ref shape (I3): identity + human label.""" + + id: int + name: str + + +class LocationRef(Ref): + + """Location refs additionally carry ``type`` because one key holds heterogeneous subtypes.""" + + type: str + + +def _test_label(obj: Model) -> str: + # Test: .title if set, else str(.test_type) (§4.4). + return obj.title or str(obj.test_type) + + +# Label registry (§4.4): model class -> callable(obj) -> name. Registered lazily by import path +# name to avoid importing every model at kernel-import time (keeps the kernel resource-agnostic). +_LABELERS: dict[str, Callable[[Model], str]] = { + "Product_Type": lambda o: o.name, + "Product": lambda o: o.name, + "Engagement": lambda o: o.name, + "Test": _test_label, + "Finding": lambda o: o.title, + "Dojo_User": lambda o: o.username, + "Location": lambda o: o.location_value, + "Test_Type": lambda o: o.name, + "Development_Environment": lambda o: o.name, +} + + +def ref_label(obj: Model) -> str: + """Return the human label for ``obj`` per the registry, falling back to ``str(obj)``.""" + labeler = _LABELERS.get(type(obj).__name__) + if labeler is None: + return str(obj) + return labeler(obj) + + +def to_ref(obj: Model | None) -> dict | None: + """Render ``obj`` as a closed ``{id, name}`` ref, or ``None`` when ``obj`` is ``None``.""" + if obj is None: + return None + return {"id": obj.pk, "name": ref_label(obj)} + + +def to_location_ref(location: Model | None) -> dict | None: + """Render a Location as ``{id, name, type}`` (the one ref subtype carrying ``type``).""" + if location is None: + return None + return {"id": location.pk, "name": location.location_value, "type": location.location_type} diff --git a/dojo/finding/api_v3/__init__.py b/dojo/finding/api_v3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/finding/api_v3/routes.py b/dojo/finding/api_v3/routes.py new file mode 100644 index 00000000000..89ee2534869 --- /dev/null +++ b/dojo/finding/api_v3/routes.py @@ -0,0 +1,159 @@ +""" +Findings read routes for API v3 (§4.5, §4.6, §4.8, OS1). + +``build_findings_router()`` is a router *factory* (I5): the OS mount calls it with defaults; a +downstream distribution can call it with a subclassed schema / extra filters / a queryset hook and +mount the result under its own prefix -- no fork. Routes are thin (I6): authorize -> filter -> +plan queryset -> serialize -> shape; all RBAC flows through ``get_authorized_findings`` (I8). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db.models import Count +from ninja import Router, Schema +from ninja.constants import NOT_SET + +from dojo.api_v3.errors import json_response, not_found_problem +from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.filtering import FilterSpec, apply_filters, filter_field +from dojo.api_v3.include import apply_includes +from dojo.api_v3.pagination import paginate +from dojo.authorization.roles_permissions import Permissions +from dojo.finding.api_v3.schemas import FindingDetail, FindingSlim +from dojo.finding.queries import get_authorized_findings +from dojo.models import Finding + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import QuerySet + from django.http import HttpRequest + +# --- Findings filter vocabulary (§4.9) -------------------------------------------------------- + +FINDING_FILTER_SPEC = FilterSpec( + model=Finding, + filters={ + "id__in": filter_field("id", "in", "number"), + "title__icontains": filter_field("title", "icontains", "char"), + "severity": filter_field("severity", "exact", "char"), + "severity__in": filter_field("severity", "in", "char"), + "active": filter_field("active", "exact", "bool"), + "verified": filter_field("verified", "exact", "bool"), + "duplicate": filter_field("duplicate", "exact", "bool"), + "false_p": filter_field("false_p", "exact", "bool"), + "risk_accepted": filter_field("risk_accepted", "exact", "bool"), + "out_of_scope": filter_field("out_of_scope", "exact", "bool"), + "is_mitigated": filter_field("is_mitigated", "exact", "bool"), + "date__gte": filter_field("date", "gte", "date"), + "date__lte": filter_field("date", "lte", "date"), + "cwe": filter_field("cwe", "exact", "number"), + "cwe__in": filter_field("cwe", "in", "number"), + "product": filter_field("test__engagement__product", "exact", "number"), + "product__in": filter_field("test__engagement__product", "in", "number"), + "product_type": filter_field("test__engagement__product__prod_type", "exact", "number"), + "engagement": filter_field("test__engagement", "exact", "number"), + "test": filter_field("test", "exact", "number"), + "reporter": filter_field("reporter", "exact", "number"), + "tags__in": filter_field("tags__name", "in", "char", distinct=True), + "created__gte": filter_field("created", "gte", "datetime"), + "created__lte": filter_field("created", "lte", "datetime"), + "updated__gte": filter_field("updated", "gte", "datetime"), + "updated__lte": filter_field("updated", "lte", "datetime"), + }, + orderings={ + "id": "id", + "date": "date", + "severity": "severity", + "title": "title", + "created": "created", + "updated": "updated", + }, + search_fields=["title", "description"], +) + +_ALLOWED_INCLUDES = {"counts"} + + +class FindingListResponse(Schema): + + """ + OpenAPI documentation of the list envelope (I1). Runtime serialization is manual so + ``?expand=``/``?fields=`` can reshape ``results`` dynamically; this schema documents the base + slim shape for client codegen. + """ + + count: int + next: str | None + previous: str | None + results: list[FindingSlim] + meta: dict | None = None + + +def _base_queryset(request: HttpRequest, queryset_hook: Callable | None) -> QuerySet: + qs = get_authorized_findings(Permissions.Finding_View, user=request.user) + if queryset_hook is not None: + qs = queryset_hook(qs, request) + return qs + + +def build_findings_router( + *, + schema: type = FindingSlim, + detail_schema: type = FindingDetail, + filter_spec: FilterSpec = FINDING_FILTER_SPEC, + queryset_hook: Callable | None = None, + auth=NOT_SET, +) -> Router: + """Build the findings router (I5).""" + router = Router(tags=["findings"], auth=auth) + + @router.get("/findings", response=FindingListResponse, url_name="findings_list") + def list_findings(request: HttpRequest): + filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) + + expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) + page_qs = ( + filtered.select_related(*schema.SELECT_RELATED) + .prefetch_related(*schema.PREFETCH_RELATED) + .annotate(locations_count=Count("locations", distinct=True)) + ) + page_qs = plan_queryset(page_qs, select_related, prefetch) + + allowed_fields = set(schema.model_fields) + fields = parse_fields(request.GET.get("fields"), allowed_fields) + + def serialize_row(obj: object) -> dict: + return apply_fields(serialize(obj, schema, expand_tree), fields) + + envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) + + include_meta = apply_includes(request, filtered, allowed=_ALLOWED_INCLUDES) + if include_meta: + envelope.setdefault("meta", {}).update(include_meta) + + return json_response(envelope) + + @router.get("/findings/{int:finding_id}", response=detail_schema, url_name="findings_detail") + def get_finding(request: HttpRequest, finding_id: int): + expand_tree, select_related, prefetch = plan(detail_schema, request.GET.get("expand")) + qs = ( + _base_queryset(request, queryset_hook) + .select_related(*detail_schema.SELECT_RELATED) + .prefetch_related(*detail_schema.PREFETCH_RELATED) + .annotate(locations_count=Count("locations", distinct=True)) + ) + qs = plan_queryset(qs, select_related, prefetch) + obj = qs.filter(pk=finding_id).first() + if obj is None: + # 404 for unknown *or unauthorized* -- never leak existence (§4.10). + msg = f"Finding {finding_id} not found" + raise not_found_problem(msg) + + allowed_fields = set(detail_schema.model_fields) + fields = parse_fields(request.GET.get("fields"), allowed_fields) + data = apply_fields(serialize(obj, detail_schema, expand_tree), fields) + return json_response(data) + + return router diff --git a/dojo/finding/api_v3/schemas.py b/dojo/finding/api_v3/schemas.py new file mode 100644 index 00000000000..f84b960a19b --- /dev/null +++ b/dojo/finding/api_v3/schemas.py @@ -0,0 +1,315 @@ +""" +Finding response schemas for API v3 (§4.5). + +``FindingSlim`` (list) and ``FindingDetail`` (retrieve) plus the parent slim schemas needed to +serve ``?expand=`` targets (§4.6). Every schema is a named, importable, subclassable ninja Schema +(I4) and declares (as ``ClassVar`` so pydantic does not treat them as fields): + +- ``django_model`` -- the mapped model (used by the expand cycle guard), +- ``SELECT_RELATED`` / ``PREFETCH_RELATED`` -- the relation paths its resolvers read, so the + expand planner can keep the query count constant, +- ``EXPANDABLE`` -- its expandable relations (§4.6). + +Slim = identity + primary status fields + parent refs + timestamps, with no per-row computed +fields (``locations_count`` is a queryset annotation, which is allowed). The parent slims live +here for OS1 (findings-only); OS3 relocates the canonical copies to their resource modules. +""" +from __future__ import annotations + +from datetime import date, datetime # noqa: TC003 -- runtime import: pydantic resolves the schema field types +from typing import ClassVar + +from ninja import Schema + +from dojo.api_v3.expand import ExpandRel +from dojo.api_v3.refs import Ref, to_ref +from dojo.models import ( + Development_Environment, + Dojo_User, + Engagement, + Finding, + Product, + Product_Type, + Test, + Test_Type, +) + + +class UserSlim(Schema): + django_model: ClassVar = Dojo_User + SELECT_RELATED: ClassVar[tuple] = () + PREFETCH_RELATED: ClassVar[tuple] = () + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + username: str + first_name: str + last_name: str + email: str + is_active: bool + is_superuser: bool + last_login: datetime | None + + +class ProductTypeSlim(Schema): + django_model: ClassVar = Product_Type + SELECT_RELATED: ClassVar[tuple] = () + PREFETCH_RELATED: ClassVar[tuple] = () + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str + description: str | None + critical_product: bool | None + key_product: bool | None + created: datetime | None + updated: datetime | None + + +class ProductSlim(Schema): + django_model: ClassVar = Product + SELECT_RELATED: ClassVar[tuple] = ("prod_type",) + PREFETCH_RELATED: ClassVar[tuple] = ("tags",) + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str + description: str | None + product_type: Ref + lifecycle: str | None + tags: list[str] + created: datetime | None + updated: datetime | None + + @staticmethod + def resolve_product_type(obj) -> dict | None: + return to_ref(obj.prod_type) + + @staticmethod + def resolve_tags(obj) -> list[str]: + return [t.name for t in obj.tags.all()] + + +ProductSlim.EXPANDABLE = { + "product_type": ExpandRel(attr="prod_type", path="prod_type", schema=ProductTypeSlim), +} + + +class EngagementSlim(Schema): + django_model: ClassVar = Engagement + SELECT_RELATED: ClassVar[tuple] = ("product__prod_type", "lead") + PREFETCH_RELATED: ClassVar[tuple] = ("tags",) + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str | None + product: Ref + product_type: Ref + lead: Ref | None + status: str | None + engagement_type: str | None + target_start: date | None + target_end: date | None + active: bool | None + tags: list[str] + created: datetime | None + updated: datetime | None + + @staticmethod + def resolve_product(obj) -> dict | None: + return to_ref(obj.product) + + @staticmethod + def resolve_product_type(obj) -> dict | None: + return to_ref(obj.product.prod_type) + + @staticmethod + def resolve_lead(obj) -> dict | None: + return to_ref(obj.lead) + + @staticmethod + def resolve_tags(obj) -> list[str]: + return [t.name for t in obj.tags.all()] + + +EngagementSlim.EXPANDABLE = { + "product": ExpandRel(attr="product", path="product", schema=ProductSlim), + "product_type": ExpandRel(attr="product.prod_type", path="product__prod_type", schema=ProductTypeSlim), + "lead": ExpandRel(attr="lead", path="lead", schema=UserSlim), +} + + +class TestTypeSlim(Schema): + django_model: ClassVar = Test_Type + SELECT_RELATED: ClassVar[tuple] = () + PREFETCH_RELATED: ClassVar[tuple] = () + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str + active: bool | None + + +class EnvironmentSlim(Schema): + django_model: ClassVar = Development_Environment + SELECT_RELATED: ClassVar[tuple] = () + PREFETCH_RELATED: ClassVar[tuple] = () + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str + + +class TestSlim(Schema): + django_model: ClassVar = Test + SELECT_RELATED: ClassVar[tuple] = ("test_type", "engagement__product__prod_type", "environment", "lead") + PREFETCH_RELATED: ClassVar[tuple] = ("tags",) + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str | None + test_type: Ref + engagement: Ref + product: Ref + product_type: Ref + environment: Ref | None + lead: Ref | None + target_start: datetime | None + target_end: datetime | None + percent_complete: int | None + tags: list[str] + created: datetime | None + updated: datetime | None + + @staticmethod + def resolve_name(obj) -> str | None: + return obj.title + + @staticmethod + def resolve_test_type(obj) -> dict | None: + return to_ref(obj.test_type) + + @staticmethod + def resolve_engagement(obj) -> dict | None: + return to_ref(obj.engagement) + + @staticmethod + def resolve_product(obj) -> dict | None: + return to_ref(obj.engagement.product) + + @staticmethod + def resolve_product_type(obj) -> dict | None: + return to_ref(obj.engagement.product.prod_type) + + @staticmethod + def resolve_environment(obj) -> dict | None: + return to_ref(obj.environment) + + @staticmethod + def resolve_lead(obj) -> dict | None: + return to_ref(obj.lead) + + @staticmethod + def resolve_tags(obj) -> list[str]: + return [t.name for t in obj.tags.all()] + + +TestSlim.EXPANDABLE = { + "test_type": ExpandRel(attr="test_type", path="test_type", schema=TestTypeSlim), + "engagement": ExpandRel(attr="engagement", path="engagement", schema=EngagementSlim), + "product": ExpandRel(attr="engagement.product", path="engagement__product", schema=ProductSlim), + "product_type": ExpandRel(attr="engagement.product.prod_type", path="engagement__product__prod_type", schema=ProductTypeSlim), + "lead": ExpandRel(attr="lead", path="lead", schema=UserSlim), + "environment": ExpandRel(attr="environment", path="environment", schema=EnvironmentSlim), +} + + +class FindingSlim(Schema): + django_model: ClassVar = Finding + # Base relation paths the finding resolvers read; applied by the route regardless of expand. + SELECT_RELATED: ClassVar[tuple] = ("test__test_type", "test__engagement__product__prod_type", "reporter") + PREFETCH_RELATED: ClassVar[tuple] = ("tags",) + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + title: str + severity: str + active: bool + verified: bool + false_p: bool + duplicate: bool + risk_accepted: bool + out_of_scope: bool + is_mitigated: bool + date: date | None + cwe: int | None + test: Ref + engagement: Ref + product: Ref + product_type: Ref + reporter: Ref | None + locations_count: int + tags: list[str] + created: datetime | None + updated: datetime | None + + @staticmethod + def resolve_test(obj) -> dict | None: + return to_ref(obj.test) + + @staticmethod + def resolve_engagement(obj) -> dict | None: + return to_ref(obj.test.engagement) + + @staticmethod + def resolve_product(obj) -> dict | None: + return to_ref(obj.test.engagement.product) + + @staticmethod + def resolve_product_type(obj) -> dict | None: + return to_ref(obj.test.engagement.product.prod_type) + + @staticmethod + def resolve_reporter(obj) -> dict | None: + return to_ref(obj.reporter) + + @staticmethod + def resolve_locations_count(obj) -> int: + return getattr(obj, "locations_count", 0) or 0 + + @staticmethod + def resolve_tags(obj) -> list[str]: + return [t.name for t in obj.tags.all()] + + +FindingSlim.EXPANDABLE = { + "test": ExpandRel(attr="test", path="test", schema=TestSlim), + "reporter": ExpandRel(attr="reporter", path="reporter", schema=UserSlim), + "engagement": ExpandRel(attr="test.engagement", path="test__engagement", schema=EngagementSlim), + "product": ExpandRel(attr="test.engagement.product", path="test__engagement__product", schema=ProductSlim), + "product_type": ExpandRel( + attr="test.engagement.product.prod_type", + path="test__engagement__product__prod_type", + schema=ProductTypeSlim, + ), +} + + +class FindingDetail(FindingSlim): + + """Slim + the documented heavier fields (§4.5). List returns slim; retrieve returns detail.""" + + description: str | None + mitigation: str | None + impact: str | None + steps_to_reproduce: str | None + severity_justification: str | None + references: str | None + file_path: str | None + line: int | None + mitigated: datetime | None + mitigated_by: Ref | None + + @staticmethod + def resolve_mitigated_by(obj) -> dict | None: + return to_ref(obj.mitigated_by) diff --git a/dojo/importers/services.py b/dojo/importers/services.py new file mode 100644 index 00000000000..932fefc412d --- /dev/null +++ b/dojo/importers/services.py @@ -0,0 +1,311 @@ +""" +Importer service facade (D7 / §6 OS1). + +The current importer returns a 7-tuple and **no service wrapper exists today** -- the v2 +``ImportScanSerializer`` calls ``DefaultImporter`` directly. This facade is framework-neutral (no +HTTP/DRF context): it constructs the importer options exactly as the v2 serializers do today +(``dojo/api_v2/serializers.py:526-903`` are the reference implementations; they are not modified) +and unpacks the 7-tuple into a structured ``ImportResult``. + +The importer 7-tuple is ``(test, updated_count, new, closed, reactivated, untouched, +test_import)`` for both the importer and the reimporter (the importer always reports +reactivated/untouched as 0). See ``default_importer.py:165`` / ``default_reimporter.py:165``. + +I6: keyword-only args, explicit ``user``, returns a result dataclass, callable without any HTTP +context. In the alpha PR only v3 calls this; v2 migrates onto it in the CONV1 convergence track. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from django.utils import timezone + +from dojo.importers.auto_create_context import AutoCreateContextManager +from dojo.importers.default_importer import DefaultImporter +from dojo.importers.default_reimporter import DefaultReImporter +from dojo.models import Development_Environment, Dojo_User + +if TYPE_CHECKING: + from datetime import date + + from django.core.files.uploadedfile import UploadedFile + + from dojo.models import Engagement, Test, Test_Import + + +@dataclass(frozen=True) +class ImportResult: + + """Structured import/reimport outcome (§4.13).""" + + test: Test + mode_resolved: str # "import" | "reimport" + new: int + closed: int + reactivated: int + untouched: int + test_import: Test_Import | None + close_old_findings: bool # effective value + do_not_reactivate: bool # effective value + + +def _resolve_environment(name: str | None, *, auto_create: bool) -> Development_Environment: + """Mirror ``setup_common_context`` environment resolution (§ v2 serializer).""" + name = name or "Development" + if auto_create: + return Development_Environment.objects.get_or_create(name=name)[0] + try: + return Development_Environment.objects.get(name=name) + except Development_Environment.DoesNotExist: + msg = f"Environment named {name} does not exist." + raise ValueError(msg) + + +def _aware_scan_date(scan_date: date | datetime | None) -> datetime | None: + """Make the scan date timezone-aware exactly as the v2 serializer does.""" + if scan_date is None: + return None + if isinstance(scan_date, datetime): + return timezone.make_aware(scan_date) if timezone.is_naive(scan_date) else scan_date + return timezone.make_aware(datetime.combine(scan_date, datetime.min.time())) + + +def _build_options( + *, + user: Dojo_User, + scan_type: str, + environment: Development_Environment, + scan_date: datetime | None, + minimum_severity: str, + active: bool | None, + verified: bool | None, + tags: list[str] | None, + service: str | None, + version: str | None, + group_by: str | None, + test_title: str | None, + deduplication_execution_mode: str | None, + push_to_jira: bool, + close_old_findings: bool, + close_old_findings_product_scope: bool, + do_not_reactivate: bool, + extra: dict[str, Any], +) -> dict[str, Any]: + options: dict[str, Any] = { + "user": user, + "scan_type": scan_type, + "environment": environment, + "scan_date": scan_date, + "minimum_severity": minimum_severity, + "active": active, + "verified": verified, + "tags": tags, + "service": service, + "version": version, + "group_by": group_by, + "test_title": test_title, + "deduplication_execution_mode": Dojo_User.resolve_deduplication_execution_mode( + user, deduplication_execution_mode, + ), + "push_to_jira": push_to_jira, + "close_old_findings": close_old_findings, + "close_old_findings_product_scope": close_old_findings_product_scope, + "do_not_reactivate": do_not_reactivate, + } + # Drop None values so the importer's own defaults apply (matches serializer behaviour where a + # field absent from initial_data is not forced). + options = {k: v for k, v in options.items() if v is not None} + options.update(extra) + return options + + +def _unpack(result_tuple: tuple, *, mode: str, close_old_findings: bool, do_not_reactivate: bool) -> ImportResult: + test, _updated_count, new, closed, reactivated, untouched, test_import = result_tuple + return ImportResult( + test=test, + mode_resolved=mode, + new=new, + closed=closed, + reactivated=reactivated, + untouched=untouched, + test_import=test_import, + close_old_findings=close_old_findings, + do_not_reactivate=do_not_reactivate, + ) + + +def import_scan( + *, + user: Dojo_User, + scan_file: UploadedFile | None, + scan_type: str, + engagement: Engagement, + environment: str | None = "Development", + auto_create_context: bool = False, + minimum_severity: str = "Info", + active: bool | None = None, + verified: bool | None = None, + close_old_findings: bool = False, + close_old_findings_product_scope: bool = False, + do_not_reactivate: bool = False, + tags: list[str] | None = None, + scan_date: date | datetime | None = None, + service: str | None = None, + version: str | None = None, + test_title: str | None = None, + group_by: str | None = None, + deduplication_execution_mode: str | None = None, + push_to_jira: bool = False, + **extra: Any, +) -> ImportResult: + """Import a scan into a new test on ``engagement`` (mirrors ``ImportScanSerializer.save()``).""" + options = _build_options( + user=user, + scan_type=scan_type, + environment=_resolve_environment(environment, auto_create=auto_create_context), + scan_date=_aware_scan_date(scan_date), + minimum_severity=minimum_severity, + active=active, + verified=verified, + tags=tags, + service=service, + version=version, + group_by=group_by, + test_title=test_title, + deduplication_execution_mode=deduplication_execution_mode, + push_to_jira=push_to_jira, + close_old_findings=close_old_findings, + close_old_findings_product_scope=close_old_findings_product_scope, + do_not_reactivate=do_not_reactivate, + extra=extra, + ) + options["engagement"] = engagement + importer = DefaultImporter(**options) + result_tuple = importer.process_scan(scan_file) + return _unpack(result_tuple, mode="import", close_old_findings=close_old_findings, do_not_reactivate=do_not_reactivate) + + +def reimport_scan( + *, + user: Dojo_User, + scan_file: UploadedFile | None, + test: Test, + scan_type: str | None = None, + environment: str | None = "Development", + auto_create_context: bool = False, + minimum_severity: str = "Info", + active: bool | None = None, + verified: bool | None = None, + close_old_findings: bool = True, + close_old_findings_product_scope: bool = False, + do_not_reactivate: bool = False, + tags: list[str] | None = None, + scan_date: date | datetime | None = None, + service: str | None = None, + version: str | None = None, + test_title: str | None = None, + group_by: str | None = None, + deduplication_execution_mode: str | None = None, + push_to_jira: bool = False, + **extra: Any, +) -> ImportResult: + """Reimport a scan into an existing ``test`` (mirrors ``ReImportScanSerializer.save()``).""" + options = _build_options( + user=user, + scan_type=scan_type or test.test_type.name, + environment=_resolve_environment(environment, auto_create=auto_create_context), + scan_date=_aware_scan_date(scan_date), + minimum_severity=minimum_severity, + active=active, + verified=verified, + tags=tags, + service=service, + version=version, + group_by=group_by, + test_title=test_title, + deduplication_execution_mode=deduplication_execution_mode, + push_to_jira=push_to_jira, + close_old_findings=close_old_findings, + close_old_findings_product_scope=close_old_findings_product_scope, + do_not_reactivate=do_not_reactivate, + extra=extra, + ) + options["test"] = test + options["engagement"] = test.engagement + reimporter = DefaultReImporter(**options) + result_tuple = reimporter.process_scan(scan_file) + return _unpack(result_tuple, mode="reimport", close_old_findings=close_old_findings, do_not_reactivate=do_not_reactivate) + + +def auto_import_scan( + *, + user: Dojo_User, + scan_file: UploadedFile | None, + scan_type: str, + engagement: Engagement | None = None, + test: Test | None = None, + product_name: str | None = None, + engagement_name: str | None = None, + product_type_name: str | None = None, + test_title: str | None = None, + auto_create_context: bool = False, + close_old_findings: bool | None = None, + do_not_reactivate: bool = False, + **kwargs: Any, +) -> ImportResult: + """ + Resolve the target test via ``AutoCreateContextManager`` and dispatch to reimport (existing + test) or import (new). Mirrors ``ReImportScanSerializer`` auto-create semantics: when a brand + new test is created, ``close_old_findings`` is forced False (nothing to compare against). + """ + auto = AutoCreateContextManager() + context: dict[str, Any] = { + "scan_type": scan_type, + "engagement": engagement, + "test": test, + "product_name": product_name, + "engagement_name": engagement_name, + "product_type_name": product_type_name, + "test_title": test_title, + "auto_create_context": auto_create_context, + } + try: + auto.process_import_meta_data_from_dict(context) + context["product"] = auto.get_target_product_if_exists(**context) + context["engagement"] = auto.get_target_engagement_if_exists(**context) + target_test = auto.get_target_test_if_exists(**context) + except (ValueError, TypeError) as exc: + raise ValueError(str(exc)) + + if target_test is not None: + return reimport_scan( + user=user, + scan_file=scan_file, + test=target_test, + scan_type=scan_type, + close_old_findings=True if close_old_findings is None else close_old_findings, + do_not_reactivate=do_not_reactivate, + auto_create_context=auto_create_context, + test_title=test_title, + **kwargs, + ) + + if auto_create_context: + resolved_engagement = auto.get_or_create_engagement(**context) + return import_scan( + user=user, + scan_file=scan_file, + scan_type=scan_type, + engagement=resolved_engagement, + # Do not close old findings when creating a brand new test. + close_old_findings=False, + do_not_reactivate=do_not_reactivate, + auto_create_context=auto_create_context, + test_title=test_title, + **kwargs, + ) + + msg = "A test could not be found, and auto_create_context was not enabled to create one." + raise ValueError(msg) diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index b590576a62e..4840816b075 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -288,6 +288,11 @@ DD_REQUESTS_TIMEOUT=(int, 30), # Dictates if v3 functionality will be enabled (on by default as of 3.0.0; set to False to revert to the legacy Endpoint model) DD_V3_FEATURE_LOCATIONS=(bool, True), + # API v3 (alpha) list pagination: threshold below which `count` is exact; above it the + # response reports the Postgres planner's row estimate (flagged count_exact=false). See §4.3. + DD_API_V3_COUNT_CAP=(int, 10000), + # API v3 (alpha) ?expand= guard: maximum number of expanded relation nodes across all paths. See §4.6. + DD_API_V3_EXPAND_BUDGET=(int, 10), # Dictates if v3 org/asset relabeling (+url routing) will be enabled (on by default as of 3.0.0; set to False to restore Product/Product Type labels and URLs) DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL=(bool, True), # Shared cache backend (django.core.cache). When set, Django uses RedisCache @@ -680,6 +685,25 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param # V3 Feature Flags V3_FEATURE_LOCATIONS = env("DD_V3_FEATURE_LOCATIONS") +# ------------------------------------------------------------------------------ +# API v3 (alpha) +# ------------------------------------------------------------------------------ +# Logical name of the API is /api/v3/; during alpha the actual mount prefix carries the +# instability marker (D1/§4.1). This is the single source of truth for the prefix and version +# string -- do not hardcode them anywhere else. +API_V3_URL_PREFIX = "api/v3-alpha" +API_V3_VERSION = "3.0.0-alpha" +API_V3_STATUS = "alpha" +# Count/expand tuning (§4.3, §4.6); settings-overridable per the plan. +API_V3_COUNT_CAP = env("DD_API_V3_COUNT_CAP") +API_V3_EXPAND_BUDGET = env("DD_API_V3_EXPAND_BUDGET") +# List pagination bounds (§4.3). +API_V3_PAGE_LIMIT_DEFAULT = 25 +API_V3_PAGE_LIMIT_MAX = 250 +# v3 handles its own auth (token + session); exempt it from the UI login-redirect middleware +# exactly as /api/v2/ is (so anonymous requests get a 401 problem+json, not a /login redirect). +LOGIN_EXEMPT_URLS += (rf"^{URL_PREFIX}{API_V3_URL_PREFIX}/",) + # ------------------------------------------------------------------------------ # ADMIN diff --git a/dojo/urls.py b/dojo/urls.py index 727662c1f9e..b7ae627daca 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -213,6 +213,18 @@ ), ] +# API v3 (alpha) -- mounted conditionally on V3_FEATURE_LOCATIONS (D5/§4.1). With the flag off the +# whole /api/v3-alpha/ tree is absent. The prefix and version live in settings (single source). +if getattr(settings, "V3_FEATURE_LOCATIONS", False): + from dojo.api_v3.api import api_v3 + + api_v2_urls += [ + re_path( + r"^{}{}/".format(get_system_setting("url_prefix"), settings.API_V3_URL_PREFIX), + api_v3.urls, + ), + ] + urlpatterns = [] # sometimes urlpatterns needed be added from local_settings.py before other URLs of core dojo diff --git a/requirements.txt b/requirements.txt index 73b3d7bef68..5b54c862347 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ django-filter==26.1 django-htmx==1.28.0 django-imagekit==6.1.0 django-multiselectfield==1.0.1 +django-ninja==1.6.2 # API v3 framework (alpha); pulls pydantic v2 (already present transitively) django-polymorphic==4.11.6 django-crispy-forms==2.6 django_extensions==4.1 diff --git a/unittests/api_v3/__init__.py b/unittests/api_v3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/unittests/api_v3/base.py b/unittests/api_v3/base.py new file mode 100644 index 00000000000..df0c8e3ac5a --- /dev/null +++ b/unittests/api_v3/base.py @@ -0,0 +1,62 @@ +""" +Base test case for API v3 (§6 preamble). + +All v3 tests use the standard in-process Django test client (via ``DojoAPITestCase``) so the full +URLconf/middleware/auth/CSRF stack runs, server-side exceptions propagate as real tracebacks, +``assertNumQueries`` works and the test transaction is shared. ``ninja.testing.TestClient`` is +reserved for kernel-internal unit tests only. +""" +from __future__ import annotations + +import json + +from django.conf import settings +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient + +from dojo.models import User +from dojo.utils import get_system_setting +from unittests.dojo_test_case import DojoAPITestCase + + +class ApiV3TestCase(DojoAPITestCase): + + """Shared helpers: v3 URL prefix, token + session/CSRF clients, JSON assertions.""" + + # v3 requires V3_FEATURE_LOCATIONS=True, under which the legacy Endpoint model raises on load; + # use the locations-aware fixture (same products/engagements/tests/findings, no Endpoint rows). + fixtures = ["dojo_testdata_locations.json"] + + def setUp(self): + super().setUp() + self.admin = User.objects.get(username="admin") + self.token, _ = Token.objects.get_or_create(user=self.admin) + # Default client authenticates with a token (mirrors login_as_admin()). + self.client = self.token_client() + + # --- URL helper ------------------------------------------------------------------------- + def v3_url(self, path: str = "") -> str: + prefix = get_system_setting("url_prefix") + return f"/{prefix}{settings.API_V3_URL_PREFIX}/{path.lstrip('/')}" + + # --- client helpers --------------------------------------------------------------------- + def token_client(self, *, user: User | None = None) -> APIClient: + token = self.token if user is None else Token.objects.get_or_create(user=user)[0] + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + return client + + def session_client(self, *, user: User | None = None, enforce_csrf: bool = False) -> APIClient: + client = APIClient(enforce_csrf_checks=enforce_csrf) + client.force_login(user or self.admin) + return client + + def anonymous_client(self) -> APIClient: + return APIClient() + + # --- assertion helpers ------------------------------------------------------------------ + def get_json(self, path: str, *, client: APIClient | None = None, data: dict | None = None, expected: int = 200): + client = client or self.client + response = client.get(self.v3_url(path), data or {}) + self.assertEqual(expected, response.status_code, response.content[:1000]) + return json.loads(response.content) if response.content else None diff --git a/unittests/api_v3/test_apiv3_auth.py b/unittests/api_v3/test_apiv3_auth.py new file mode 100644 index 00000000000..7420c7dd354 --- /dev/null +++ b/unittests/api_v3/test_apiv3_auth.py @@ -0,0 +1,49 @@ +"""Auth contract tests for API v3 (D8 / §4.2): token AND session+CSRF, both on the same endpoint.""" +from __future__ import annotations + +from .base import ApiV3TestCase + + +class TestApiV3Auth(ApiV3TestCase): + + def test_token_auth_get(self): + """A v2 token authenticates a v3 GET (same token store).""" + response = self.token_client().get(self.v3_url("findings")) + self.assertEqual(200, response.status_code, response.content[:500]) + self.assertEqual("alpha", response["X-API-Status"]) + + def test_session_auth_get(self): + """Django session authenticates a v3 GET (safe method needs no CSRF).""" + response = self.session_client().get(self.v3_url("findings")) + self.assertEqual(200, response.status_code, response.content[:500]) + + def test_anonymous_is_401_problem_json(self): + """No credentials -> 401 problem+json (not 403, not a redirect).""" + response = self.anonymous_client().get(self.v3_url("findings")) + self.assertEqual(401, response.status_code, response.content[:500]) + self.assertEqual("application/problem+json", response["Content-Type"]) + body = response.json() + self.assertEqual(401, body["status"]) + self.assertIn("type", body) + self.assertIn("title", body) + + def test_both_auth_modes_on_same_endpoint(self): + """The same endpoint accepts both token and session auth.""" + self.assertEqual(200, self.token_client().get(self.v3_url("findings")).status_code) + self.assertEqual(200, self.session_client().get(self.v3_url("findings")).status_code) + + def test_token_bypasses_csrf_on_unsafe_method(self): + """Header (token) auth needs no CSRF: an unsafe POST reaches the handler (400, not 401/403).""" + response = self.token_client().post( + self.v3_url("import"), {"scan_type": "ZAP Scan", "mode": "import"}, format="multipart", + ) + self.assertNotIn(response.status_code, (401, 403), response.content[:500]) + self.assertEqual(400, response.status_code, response.content[:500]) + + def test_session_csrf_enforced_on_unsafe_method(self): + """Cookie (session) auth on an unsafe method without a CSRF token -> 403.""" + client = self.session_client(enforce_csrf=True) + response = client.post( + self.v3_url("import"), {"scan_type": "ZAP Scan", "mode": "import"}, format="multipart", + ) + self.assertEqual(403, response.status_code, response.content[:500]) diff --git a/unittests/api_v3/test_apiv3_findings.py b/unittests/api_v3/test_apiv3_findings.py new file mode 100644 index 00000000000..f90ef3cfcec --- /dev/null +++ b/unittests/api_v3/test_apiv3_findings.py @@ -0,0 +1,168 @@ +"""Findings read-path contract tests for API v3 (§4.3-§4.9, OS1).""" +from __future__ import annotations + +from django.test import override_settings + +from dojo.models import User + +from .base import ApiV3TestCase + +_SLIM_KEYS = { + "id", "title", "severity", "active", "verified", "false_p", "duplicate", "risk_accepted", + "out_of_scope", "is_mitigated", "date", "cwe", "test", "engagement", "product", + "product_type", "reporter", "locations_count", "tags", "created", "updated", +} + + +class TestApiV3FindingsSlim(ApiV3TestCase): + + def test_list_envelope_shape(self): + body = self.get_json("findings") + self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) + self.assertIsInstance(body["count"], int) + self.assertIsInstance(body["results"], list) + self.assertGreater(body["count"], 0) + + def test_slim_shape_and_denormalized_parent_refs(self): + row = self.get_json("findings")["results"][0] + self.assertEqual(_SLIM_KEYS, set(row)) + # Refs are closed {id, name}; parent chain is denormalized onto the finding. + for key in ("test", "engagement", "product", "product_type"): + self.assertEqual({"id", "name"}, set(row[key]), key) + self.assertIsInstance(row["locations_count"], int) + self.assertIsInstance(row["tags"], list) + + def test_datetime_is_iso_z(self): + row = self.get_json("findings")["results"][0] + if row["created"]: + self.assertTrue(row["created"].endswith("Z"), row["created"]) + + def test_detail_adds_heavy_fields(self): + row = self.get_json("findings")["results"][0] + detail = self.get_json(f"findings/{row['id']}") + for key in ("description", "mitigation", "impact", "file_path", "line", "mitigated", "mitigated_by"): + self.assertIn(key, detail) + + def test_detail_unknown_or_unauthorized_is_404(self): + self.get_json("findings/99999999", expected=404) + + +class TestApiV3FindingsExpand(ApiV3TestCase): + + def test_expand_test_engagement_inlines_slim(self): + row = self.get_json("findings", data={"expand": "test.engagement"})["results"][0] + # test ref swapped for the test slim shape (has a title-derived name + its own refs). + self.assertIn("test_type", row["test"]) + self.assertIn("engagement", row["test"]) + # engagement nested inside test is itself a slim (carries name, not just id). + self.assertIn("name", row["test"]["engagement"]) + self.assertIn("product", row["test"]["engagement"]) + + def test_expand_reporter(self): + row = self.get_json("findings", data={"expand": "reporter"})["results"][0] + if row["reporter"] is not None: + self.assertIn("username", row["reporter"]) + + def test_expand_unknown_relation_is_400(self): + body = self.get_json("findings", data={"expand": "not_a_relation"}, expected=400) + self.assertEqual(400, body["status"]) + + def test_expand_budget_exceeded_is_400(self): + with override_settings(API_V3_EXPAND_BUDGET=1): + self.get_json("findings", data={"expand": "test.engagement,reporter"}, expected=400) + + +class TestApiV3FindingsFields(ApiV3TestCase): + + def test_fields_subsets_output(self): + row = self.get_json("findings", data={"fields": "id,title"})["results"][0] + self.assertEqual({"id", "title"}, set(row)) + + def test_fields_always_includes_id(self): + row = self.get_json("findings", data={"fields": "title"})["results"][0] + self.assertIn("id", row) + + def test_unknown_field_is_400(self): + self.get_json("findings", data={"fields": "id,not_a_field"}, expected=400) + + +class TestApiV3FindingsFilters(ApiV3TestCase): + + def test_filter_severity(self): + body = self.get_json("findings", data={"severity": "High"}) + for row in body["results"]: + self.assertEqual("High", row["severity"]) + + def test_filter_active_boolean(self): + body = self.get_json("findings", data={"active": "true"}) + for row in body["results"]: + self.assertTrue(row["active"]) + + def test_ordering(self): + body = self.get_json("findings", data={"o": "-id"}) + ids = [r["id"] for r in body["results"]] + self.assertEqual(ids, sorted(ids, reverse=True)) + + def test_unknown_ordering_is_400(self): + self.get_json("findings", data={"o": "not_orderable"}, expected=400) + + +class TestApiV3FindingsInclude(ApiV3TestCase): + + def test_include_counts(self): + body = self.get_json("findings", data={"include": "counts"}) + counts = body["meta"]["counts"] + for key in ("total", "active", "verified", "duplicate", "severity"): + self.assertIn(key, counts) + self.assertEqual(body["count"], counts["total"]) + self.assertEqual( + {"Critical", "High", "Medium", "Low", "Info"}, set(counts["severity"]), + ) + + def test_unknown_include_is_400(self): + self.get_json("findings", data={"include": "bogus"}, expected=400) + + +class TestApiV3FindingsPagination(ApiV3TestCase): + + def test_limit_and_next(self): + body = self.get_json("findings", data={"limit": 2}) + self.assertLessEqual(len(body["results"]), 2) + if body["count"] > 2: + self.assertIsNotNone(body["next"]) + self.assertIsNone(body["previous"]) + + def test_offset_previous(self): + body = self.get_json("findings", data={"limit": 2, "offset": 2}) + self.assertIsNotNone(body["previous"]) + + def test_cursor_pagination_reserved_400(self): + self.get_json("findings", data={"pagination": "cursor"}, expected=400) + + def test_bad_limit_400(self): + self.get_json("findings", data={"limit": "-1"}, expected=400) + + def test_count_exact_below_cap(self): + body = self.get_json("findings") + # Default CAP is large; count is exact and no count_exact flag appears. + self.assertNotIn("count_exact", body.get("meta", {})) + + @override_settings(API_V3_COUNT_CAP=1) + def test_count_switches_to_estimate_above_cap(self): + body = self.get_json("findings") + # Above the (lowered) cap: estimate clamped to >= CAP+1, flagged count_exact false. + self.assertGreaterEqual(body["count"], 2) + self.assertIn("count_exact", body["meta"]) + self.assertFalse(body["meta"]["count_exact"]) + + +class TestApiV3FindingsRbac(ApiV3TestCase): + + def test_include_counts_and_list_respect_authorized_queryset(self): + """A user with no product access sees an empty list and zeroed counts (RBAC via querysets).""" + limited = User.objects.create_user(username="v3_limited", password="x") # noqa: S106 + client = self.token_client(user=limited) + body = self.get_json("findings", client=client, data={"include": "counts"}) + self.assertEqual(0, body["count"]) + self.assertEqual([], body["results"]) + self.assertEqual(0, body["meta"]["counts"]["total"]) diff --git a/unittests/api_v3/test_apiv3_findings_queries.py b/unittests/api_v3/test_apiv3_findings_queries.py new file mode 100644 index 00000000000..e71d1862112 --- /dev/null +++ b/unittests/api_v3/test_apiv3_findings_queries.py @@ -0,0 +1,65 @@ +""" +Query-count regression test for API v3 findings (§4.6, §6 OS1) -- the headline guarantee. + +The number of SQL queries for a findings list must be **independent of the number of rows +returned**, both with and without ``?expand=``. This is what v3's slim+ref+expand model buys over +v2's post-serialization ``?prefetch=`` (which issues per-row-per-field queries). +""" +from __future__ import annotations + +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.utils import timezone + +from dojo.models import Finding, Test + +from .base import ApiV3TestCase + + +class TestApiV3FindingsQueryCount(ApiV3TestCase): + + def _bulk_create_findings(self, count: int, test: Test) -> None: + today = timezone.now().date() + Finding.objects.bulk_create([ + Finding( + title=f"qcount finding {i}", + severity="High", + numerical_severity="S1", + description="query-count fixture finding", + test=test, + reporter=self.admin, + active=True, + verified=False, + date=today, + ) + for i in range(count) + ]) + + def _query_count(self, params: dict) -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url("findings"), params) + self.assertEqual(200, response.status_code, response.content[:500]) + return len(ctx.captured_queries) + + def test_query_count_is_independent_of_row_count(self): + test = Test.objects.first() + + self._bulk_create_findings(10, test) + queries_10 = self._query_count({"limit": 250}) + queries_10_expand = self._query_count({"limit": 250, "expand": "test.engagement"}) + + self._bulk_create_findings(90, test) # now 100+ of our findings plus the fixture rows + queries_100 = self._query_count({"limit": 250}) + queries_100_expand = self._query_count({"limit": 250, "expand": "test.engagement"}) + + # Confirm we actually returned ~100+ rows so the assertion is meaningful. + self.assertGreaterEqual(self.get_json("findings", data={"limit": 250})["count"], 100) + + self.assertEqual( + queries_10, queries_100, + f"query count must not grow with row count (no expand): {queries_10} vs {queries_100}", + ) + self.assertEqual( + queries_10_expand, queries_100_expand, + f"query count must not grow with row count (expand): {queries_10_expand} vs {queries_100_expand}", + ) diff --git a/unittests/api_v3/test_apiv3_import.py b/unittests/api_v3/test_apiv3_import.py new file mode 100644 index 00000000000..7c40ee89067 --- /dev/null +++ b/unittests/api_v3/test_apiv3_import.py @@ -0,0 +1,119 @@ +""" +Import equivalence tests for API v3 (§4.13, §6 OS1). + +The consolidated ``POST /import`` (import/reimport/auto) must reproduce the v2 endpoints' DB state +for identical payloads, including ``close_old_findings``. Both paths run in the shared test +transaction so DB-state assertions are exact. +""" +from __future__ import annotations + +from collections import Counter + +from django.urls import reverse + +from dojo.models import Finding, Test + +from .base import ApiV3TestCase + +_ZAP = "ZAP Scan" + + +def _finding_multiset(test_id: int) -> Counter: + return Counter( + (f.title, f.severity, f.active, f.is_mitigated) + for f in Finding.objects.filter(test_id=test_id) + ) + + +class TestApiV3Import(ApiV3TestCase): + + def _scan(self, name: str): + from unittests.dojo_test_case import get_unit_tests_scans_path # noqa: PLC0415 + + return (get_unit_tests_scans_path("zap") / name).open(encoding="utf-8") + + def _v2_import(self, engagement: int, name: str = "0_zap_sample.xml", **extra) -> dict: + with self._scan(name) as scan: + payload = {"scan_type": _ZAP, "engagement": engagement, "file": scan, + "active": "true", "verified": "true", **extra} + response = self.client.post(reverse("importscan-list"), payload) + self.assertEqual(201, response.status_code, response.content[:1000]) + return response.json() + + def _v2_reimport(self, test_id: int, name: str, **extra) -> dict: + with self._scan(name) as scan: + payload = {"scan_type": _ZAP, "test": test_id, "file": scan, + "active": "true", "verified": "true", **extra} + response = self.client.post(reverse("reimportscan-list"), payload) + self.assertEqual(201, response.status_code, response.content[:1000]) + return response.json() + + def _v3_import(self, name: str = "0_zap_sample.xml", *, mode: str = "import", expected: int = 200, **extra) -> dict: + with self._scan(name) as scan: + payload = {"scan_type": _ZAP, "mode": mode, "file": scan, + "active": "true", "verified": "true", **extra} + response = self.client.post(self.v3_url("import"), payload, format="multipart") + self.assertEqual(expected, response.status_code, response.content[:1000]) + return response.json() + + # --- import equivalence ----------------------------------------------------------------- + def test_import_creates_same_findings_as_v2(self): + v2 = self._v2_import(engagement=1) + v3 = self._v3_import(mode="import", engagement=4) + v2_test = v2.get("test_id") or v2.get("test") + v3_test = v3["test"]["id"] + self.assertEqual(_finding_multiset(v2_test), _finding_multiset(v3_test)) + self.assertGreater(sum(_finding_multiset(v3_test).values()), 0) + + def test_import_response_shape(self): + v3 = self._v3_import(mode="import", engagement=4) + self.assertEqual("import", v3["mode_resolved"]) + self.assertEqual({"id", "name"}, set(v3["test"])) + self.assertEqual({"new", "reactivated", "closed", "untouched"}, set(v3["statistics"])) + self.assertIn("close_old_findings", v3) + # New import: statistics.new equals the number of findings created. + self.assertEqual(v3["statistics"]["new"], Finding.objects.filter(test_id=v3["test"]["id"]).count()) + + # --- reimport equivalence incl. close_old_findings -------------------------------------- + def test_reimport_close_old_findings_equivalence(self): + # v2: import full sample, then reimport a subset with close_old_findings -> some closed. + v2 = self._v2_import(engagement=1) + v2_test = v2.get("test_id") or v2.get("test") + self._v2_reimport(v2_test, "1_zap_sample_0_and_new_absent.xml", close_old_findings="true") + + # v3: same sequence via the consolidated endpoint. + v3 = self._v3_import(mode="import", engagement=4) + v3_test = v3["test"]["id"] + v3_re = self._v3_import("1_zap_sample_0_and_new_absent.xml", mode="reimport", test=v3_test, + close_old_findings="true") + + self.assertEqual("reimport", v3_re["mode_resolved"]) + # Final DB state matches v2 exactly (active + mitigated multisets). + self.assertEqual(_finding_multiset(v2_test), _finding_multiset(v3_test)) + # And the reimport reported the closures. + self.assertGreaterEqual(v3_re["statistics"]["closed"], 0) + self.assertTrue(v3_re["close_old_findings"]) + + def test_reimport_default_close_old_findings_is_true(self): + v3 = self._v3_import(mode="import", engagement=4) + v3_re = self._v3_import("0_zap_sample.xml", mode="reimport", test=v3["test"]["id"]) + # ReImport default for close_old_findings is True (mirrors v2), echoed in the response. + self.assertTrue(v3_re["close_old_findings"]) + + # --- auto mode -------------------------------------------------------------------------- + def test_auto_mode_creates_then_reuses(self): + created = self._v3_import( + mode="auto", product_name="v3 Auto Product", engagement_name="v3 Auto Eng", + product_type_name="v3 Auto PT", auto_create_context="true", + ) + self.assertEqual("import", created["mode_resolved"]) + first_test = created["test"]["id"] + self.assertTrue(Test.objects.filter(pk=first_test).exists()) + + # Auto again with the same identifiers resolves the existing test -> reimport. + reused = self._v3_import( + mode="auto", product_name="v3 Auto Product", engagement_name="v3 Auto Eng", + product_type_name="v3 Auto PT", auto_create_context="true", + ) + self.assertEqual("reimport", reused["mode_resolved"]) + self.assertEqual(first_test, reused["test"]["id"]) From fb91e120ee071e963f4dc0fe01501f86ed1a508a Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 12:35:47 +0200 Subject: [PATCH 02/37] feat(api-v3): OS2 - kernel hardening - severity rank ordering (Case/When, mirrors v2 numerical_severity) - strict unknown-filter-param rejection (400; typo'd filters must not silently return unfiltered data) - FilterSpec registry + vocabulary snapshot test (contract drift fails CI) - expand=locations: swaps locations_count for edge rows [{location, status, audit_time}] via special renderer with declared prefetch paths (query count stays constant) - dedicated 'fields' problem type; full problem+json error-path test sweep - OpenAPI schema-generation guard test 65 tests green (OS1 37 + OS2 28). --- dojo/api_v3/errors.py | 6 + dojo/api_v3/expand.py | 39 ++++-- dojo/api_v3/filtering.py | 91 ++++++++++++-- dojo/finding/api_v3/routes.py | 15 ++- dojo/finding/api_v3/schemas.py | 27 +++- unittests/api_v3/snapshots/filters.json | 45 +++++++ unittests/api_v3/test_apiv3_errors.py | 119 ++++++++++++++++++ .../api_v3/test_apiv3_filter_contract.py | 58 +++++++++ unittests/api_v3/test_apiv3_findings.py | 93 +++++++++++++- unittests/api_v3/test_apiv3_openapi.py | 52 ++++++++ 10 files changed, 516 insertions(+), 29 deletions(-) create mode 100644 unittests/api_v3/snapshots/filters.json create mode 100644 unittests/api_v3/test_apiv3_errors.py create mode 100644 unittests/api_v3/test_apiv3_filter_contract.py create mode 100644 unittests/api_v3/test_apiv3_openapi.py diff --git a/dojo/api_v3/errors.py b/dojo/api_v3/errors.py index 75c2960c85c..c89e7ae52c9 100644 --- a/dojo/api_v3/errors.py +++ b/dojo/api_v3/errors.py @@ -121,6 +121,12 @@ def expand_problem(detail: str) -> ProblemDetail: return ProblemDetail(status=400, error_type="expand", title="Invalid expand", detail=detail) +def fields_problem(detail: str) -> ProblemDetail: + # `?fields=` (§4.7) is a distinct capability from `?expand=`; a distinct type URI keeps the + # error contract closed (I9: new error kinds get new type URIs, not new shapes). + return ProblemDetail(status=400, error_type="fields", title="Invalid fields", detail=detail) + + def filter_problem(detail: str) -> ProblemDetail: return ProblemDetail(status=400, error_type="filter", title="Invalid filter", detail=detail) diff --git a/dojo/api_v3/expand.py b/dojo/api_v3/expand.py index a78df73611c..f2a815e9b91 100644 --- a/dojo/api_v3/expand.py +++ b/dojo/api_v3/expand.py @@ -21,7 +21,7 @@ from django.conf import settings -from dojo.api_v3.errors import expand_problem +from dojo.api_v3.errors import expand_problem, fields_problem if TYPE_CHECKING: from collections.abc import Callable @@ -36,9 +36,16 @@ class ExpandRel: attr: str # python attribute path on the object (may be dotted) path: str # django ORM relation path segment(s) relative to the model - schema: type # target slim schema class + schema: type | None = None # target slim schema class (None for a special renderer) to_many: bool = False # True -> prefetch_related; False -> select_related special: Callable | None = None # optional custom renderer(obj) -> value (e.g. edge rows) + # Prefetch paths a *special* renderer needs so serialising it never triggers per-row queries + # (a special renderer bypasses the schema-driven select/prefetch planning below). Relative to + # this rel's model; the walker prefixes them with the current django path. + prefetch_paths: tuple[str, ...] = () + # Slim field this expansion replaces in the output (e.g. ``locations`` replaces + # ``locations_count`` -- §4.6 "swaps locations_count for edge rows"). None -> pure add. + replaces: str | None = None def parse_expand(raw: str | None) -> list[list[str]]: @@ -84,16 +91,19 @@ def _walk( raise expand_problem(msg) full_path = f"{django_prefix}__{rel.path}" if django_prefix else rel.path - if rel.special is None: - if rel.to_many: - prefetch.add(full_path) - else: - select_related.add(full_path) - # Pull in the target schema's own ref/tag dependencies so serializing the expanded - # object never triggers per-row queries -- this is what keeps the query count constant - # under expand (the headline guarantee). - select_related.update(f"{full_path}__{sr}" for sr in getattr(rel.schema, "SELECT_RELATED", ())) - prefetch.update(f"{full_path}__{pf}" for pf in getattr(rel.schema, "PREFETCH_RELATED", ())) + if rel.special is not None: + # A special renderer produces the value itself; it declares its own prefetch paths so the + # query count stays constant (e.g. locations edge rows via prefetch_related, §4.6). + prefetch.update(f"{django_prefix}__{pf}" if django_prefix else pf for pf in rel.prefetch_paths) + elif rel.to_many: + prefetch.add(full_path) + else: + select_related.add(full_path) + # Pull in the target schema's own ref/tag dependencies so serializing the expanded + # object never triggers per-row queries -- this is what keeps the query count constant + # under expand (the headline guarantee). + select_related.update(f"{full_path}__{sr}" for sr in getattr(rel.schema, "SELECT_RELATED", ())) + prefetch.update(f"{full_path}__{pf}" for pf in getattr(rel.schema, "PREFETCH_RELATED", ())) subtree = tree.setdefault(head, {}) if rest: @@ -148,6 +158,9 @@ def serialize(obj: object, schema: type, expand_tree: dict) -> dict: registry: dict[str, ExpandRel] = getattr(schema, "EXPANDABLE", {}) for key, subtree in expand_tree.items(): rel = registry[key] + if rel.replaces is not None: + # e.g. expand=locations swaps the cheap `locations_count` for the edge rows (§4.6). + data.pop(rel.replaces, None) if rel.special is not None: data[key] = rel.special(obj) continue @@ -171,7 +184,7 @@ def parse_fields(raw: str | None, allowed: set[str]) -> set[str] | None: unknown = requested - allowed if unknown: msg = f"unknown field(s): {', '.join(sorted(unknown))}" - raise expand_problem(msg) + raise fields_problem(msg) requested.add("id") return requested diff --git a/dojo/api_v3/filtering.py b/dojo/api_v3/filtering.py index dc8fba2065a..2c3e969dd30 100644 --- a/dojo/api_v3/filtering.py +++ b/dojo/api_v3/filtering.py @@ -7,8 +7,17 @@ it reuses django-filter ``FilterSet`` (criterion 4) to apply the value filters, then applies ``o=`` and ``q=`` on top. -Invariant I2: the grammar never varies per endpoint. The vocabulary snapshot test lands in OS2; -OS1 wires the mechanism and the findings vocabulary. +Invariant I2: the grammar never varies per endpoint. Each ``FilterSpec`` registers itself +(``register_filter_spec``) so the OS2 vocabulary snapshot test can render every object's contract +without importing route modules (keeps the kernel resource-agnostic, I5). + +Two orderings kinds: +- plain field-path orderings (``orderings``: public key -> model field path), and +- computed orderings (``order_expressions``: public key -> a factory returning a Django ordering + expression). Severity is computed so it sorts by *rank* (Critical > High > Medium > Low > Info), + never alphabetically -- mirroring v2's ``numerical_severity`` ordering (S0=Critical ... S4=Info, + the model's default ordering) but computed at query time so it never depends on that denormalised + column being populated. """ from __future__ import annotations @@ -16,17 +25,32 @@ from typing import TYPE_CHECKING import django_filters as df -from django.db.models import Q +from django.db.models import Case, IntegerField, Q, Value, When from dojo.api_v3.errors import filter_problem if TYPE_CHECKING: + from collections.abc import Callable + from django.db.models import QuerySet from django.http import HttpRequest # Query params owned by the kernel; never treated as filter fields. RESERVED_PARAMS = frozenset({"limit", "offset", "pagination", "expand", "fields", "include", "o", "q"}) +# Severity rank: Critical is the most severe (rank 0) ... Info the least (rank 4). Mirrors v2's +# numerical_severity (S0..S4) and Finding.Meta.ordering. +SEVERITY_RANK = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Info": 4} + + +def severity_rank_order(): + """A Case/When that maps the ``severity`` string to its rank so ``o=severity`` sorts by rank.""" + return Case( + *[When(severity=sev, then=Value(rank)) for sev, rank in SEVERITY_RANK.items()], + default=Value(len(SEVERITY_RANK)), + output_field=IntegerField(), + ) + # --- Filter field constructors (the fixed grammar) -------------------------------------------- @@ -60,12 +84,14 @@ def filter_field(field_path: str, lookup: str, kind: str, *, distinct: bool = Fa @dataclass class FilterSpec: - """Declarative per-object filter vocabulary (a tested artifact in OS2).""" + """Declarative per-object filter vocabulary (a tested artifact -- OS2 snapshot).""" model: type filters: dict[str, df.Filter] - # Ordering param name -> model field path. Only these orderings are permitted (§4.9). + # Ordering param name -> model field path. Only these plain-field orderings are permitted. orderings: dict[str, str] + # Ordering param name -> callable() returning a Django ordering expression (computed orderings). + order_expressions: dict[str, Callable] = field(default_factory=dict) # Fields scanned by free-text `q=`. search_fields: list[str] = field(default_factory=list) _filterset_class: type | None = None @@ -80,8 +106,48 @@ def filterset_class(self) -> type[df.FilterSet]: ) return self._filterset_class + def ordering_keys(self) -> set[str]: + """Every valid ``o=`` key (plain-field + computed).""" + return set(self.orderings) | set(self.order_expressions) + + def vocabulary(self) -> dict: + """The public filter contract of this object (params + orderings + search fields).""" + return { + "model": self.model.__name__, + "params": sorted(self.filters), + "orderings": sorted(self.ordering_keys()), + "search_fields": sorted(self.search_fields), + } + + +# --- FilterSpec registry (populated by resource modules; read by the snapshot test) ----------- + +_FILTER_SPEC_REGISTRY: dict[str, FilterSpec] = {} + + +def register_filter_spec(name: str, spec: FilterSpec) -> FilterSpec: + """Register a resource's ``FilterSpec`` under a stable name and return it (for module-level use).""" + _FILTER_SPEC_REGISTRY[name] = spec + return spec + + +def iter_filter_specs() -> dict[str, FilterSpec]: + """A copy of the registry ``{name: spec}`` (the snapshot test's source of truth).""" + return dict(_FILTER_SPEC_REGISTRY) + + +# --- Application ------------------------------------------------------------------------------- + +def _reject_unknown_params(request: HttpRequest, spec: FilterSpec) -> None: + """Reject query params that are neither reserved nor a declared filter (§4.9 strictness).""" + allowed = RESERVED_PARAMS | set(spec.filters) + unknown = [key for key in request.GET if key not in allowed] + if unknown: + msg = f"unknown filter parameter(s): {', '.join(sorted(unknown))}" + raise filter_problem(msg) + -def _apply_ordering(request: HttpRequest, queryset: QuerySet, orderings: dict[str, str]) -> QuerySet: +def _apply_ordering(request: HttpRequest, queryset: QuerySet, spec: FilterSpec) -> QuerySet: raw = request.GET.get("o") if not raw: return queryset @@ -92,11 +158,15 @@ def _apply_ordering(request: HttpRequest, queryset: QuerySet, orderings: dict[st continue desc = token.startswith("-") key = token[1:] if desc else token - if key not in orderings: + if key in spec.order_expressions: + expr = spec.order_expressions[key]() + order_by.append(expr.desc() if desc else expr.asc()) + elif key in spec.orderings: + path = spec.orderings[key] + order_by.append(f"-{path}" if desc else path) + else: msg = f"'{key}' is not an orderable field" raise filter_problem(msg) - path = orderings[key] - order_by.append(f"-{path}" if desc else path) return queryset.order_by(*order_by) if order_by else queryset @@ -112,6 +182,7 @@ def _apply_search(request: HttpRequest, queryset: QuerySet, search_fields: list[ def apply_filters(request: HttpRequest, queryset: QuerySet, spec: FilterSpec) -> QuerySet: """Apply value filters (django-filter), then ``o=`` and ``q=``. Bad input -> 400 problem+json.""" + _reject_unknown_params(request, spec) filterset = spec.filterset_class()(request.GET, queryset=queryset) if not filterset.is_valid(): # Reshape django-filter field errors into the flat filter problem detail. @@ -120,5 +191,5 @@ def apply_filters(request: HttpRequest, queryset: QuerySet, spec: FilterSpec) -> ) raise filter_problem(messages or "invalid filter parameters") queryset = filterset.qs - queryset = _apply_ordering(request, queryset, spec.orderings) + queryset = _apply_ordering(request, queryset, spec) return _apply_search(request, queryset, spec.search_fields) diff --git a/dojo/finding/api_v3/routes.py b/dojo/finding/api_v3/routes.py index 89ee2534869..225e8deab7a 100644 --- a/dojo/finding/api_v3/routes.py +++ b/dojo/finding/api_v3/routes.py @@ -16,7 +16,13 @@ from dojo.api_v3.errors import json_response, not_found_problem from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize -from dojo.api_v3.filtering import FilterSpec, apply_filters, filter_field +from dojo.api_v3.filtering import ( + FilterSpec, + apply_filters, + filter_field, + register_filter_spec, + severity_rank_order, +) from dojo.api_v3.include import apply_includes from dojo.api_v3.pagination import paginate from dojo.authorization.roles_permissions import Permissions @@ -32,7 +38,7 @@ # --- Findings filter vocabulary (§4.9) -------------------------------------------------------- -FINDING_FILTER_SPEC = FilterSpec( +FINDING_FILTER_SPEC = register_filter_spec("finding", FilterSpec( model=Finding, filters={ "id__in": filter_field("id", "in", "number"), @@ -65,13 +71,14 @@ orderings={ "id": "id", "date": "date", - "severity": "severity", "title": "title", "created": "created", "updated": "updated", }, + # `o=severity` sorts by rank (Critical first), not alphabetically (§4.9). + order_expressions={"severity": severity_rank_order}, search_fields=["title", "description"], -) +)) _ALLOWED_INCLUDES = {"counts"} diff --git a/dojo/finding/api_v3/schemas.py b/dojo/finding/api_v3/schemas.py index f84b960a19b..8527a8c0478 100644 --- a/dojo/finding/api_v3/schemas.py +++ b/dojo/finding/api_v3/schemas.py @@ -22,7 +22,7 @@ from ninja import Schema from dojo.api_v3.expand import ExpandRel -from dojo.api_v3.refs import Ref, to_ref +from dojo.api_v3.refs import Ref, to_location_ref, to_ref from dojo.models import ( Development_Environment, Dojo_User, @@ -282,6 +282,23 @@ def resolve_tags(obj) -> list[str]: return [t.name for t in obj.tags.all()] +def _finding_location_edges(finding) -> list[dict]: + """ + ``expand=locations`` special renderer (§4.6): swap the cheap ``locations_count`` for the edge + rows ``[{location: {id, name, type}, status, audit_time}]``. ``finding.locations`` is the + ``LocationFindingReference`` reverse manager (edge carries ``status``/``audit_time``); the + ``locations__location`` prefetch declared on the ExpandRel keeps the query count constant. + """ + return [ + { + "location": to_location_ref(ref.location), + "status": ref.status, + "audit_time": ref.audit_time, + } + for ref in finding.locations.all() + ] + + FindingSlim.EXPANDABLE = { "test": ExpandRel(attr="test", path="test", schema=TestSlim), "reporter": ExpandRel(attr="reporter", path="reporter", schema=UserSlim), @@ -292,6 +309,14 @@ def resolve_tags(obj) -> list[str]: path="test__engagement__product__prod_type", schema=ProductTypeSlim, ), + "locations": ExpandRel( + attr="locations", + path="locations", + to_many=True, + special=_finding_location_edges, + prefetch_paths=("locations__location",), + replaces="locations_count", + ), } diff --git a/unittests/api_v3/snapshots/filters.json b/unittests/api_v3/snapshots/filters.json new file mode 100644 index 00000000000..028f14d271a --- /dev/null +++ b/unittests/api_v3/snapshots/filters.json @@ -0,0 +1,45 @@ +{ + "finding": { + "model": "Finding", + "orderings": [ + "created", + "date", + "id", + "severity", + "title", + "updated" + ], + "params": [ + "active", + "created__gte", + "created__lte", + "cwe", + "cwe__in", + "date__gte", + "date__lte", + "duplicate", + "engagement", + "false_p", + "id__in", + "is_mitigated", + "out_of_scope", + "product", + "product__in", + "product_type", + "reporter", + "risk_accepted", + "severity", + "severity__in", + "tags__in", + "test", + "title__icontains", + "updated__gte", + "updated__lte", + "verified" + ], + "search_fields": [ + "description", + "title" + ] + } +} diff --git a/unittests/api_v3/test_apiv3_errors.py b/unittests/api_v3/test_apiv3_errors.py new file mode 100644 index 00000000000..7e263356b01 --- /dev/null +++ b/unittests/api_v3/test_apiv3_errors.py @@ -0,0 +1,119 @@ +""" +Error-contract sweep for API v3 (D9 / §4.10, §6 OS2 / I9). + +Every v3-raised failure path must return an RFC 9457 ``application/problem+json`` body with the +correct ``type`` URI, ``status`` and ``title``. This module asserts that for each failure kind. +(Django's default 500 handler is out of scope -- only v3-raised paths.) +""" +from __future__ import annotations + +from typing import ClassVar + +from django.test import SimpleTestCase, override_settings +from ninja import Schema + +from dojo.api_v3.errors import ProblemDetail +from dojo.api_v3.expand import ExpandRel, plan +from dojo.models import Finding + +from .base import ApiV3TestCase + + +class TestApiV3ErrorContract(ApiV3TestCase): + + def _assert_problem(self, path, *, status, type_suffix, client=None, data=None): + client = client or self.client + response = client.get(self.v3_url(path), data or {}) + self.assertEqual(status, response.status_code, response.content[:500]) + self.assertEqual("application/problem+json", response["Content-Type"], response.content[:200]) + body = response.json() + self.assertEqual(status, body["status"]) + self.assertIn("title", body) + self.assertTrue( + body["type"].endswith("/errors/" + type_suffix), + f"expected type .../errors/{type_suffix}, got {body['type']}", + ) + return body + + # --- 400: expand ----------------------------------------------------------------------- + def test_unknown_expand_relation_is_problem(self): + self._assert_problem("findings", status=400, type_suffix="expand", data={"expand": "nope"}) + + def test_expand_budget_exceeded_is_problem(self): + with override_settings(API_V3_EXPAND_BUDGET=1): + self._assert_problem( + "findings", status=400, type_suffix="expand", + data={"expand": "test.engagement,reporter"}, + ) + + def test_expand_further_on_special_is_problem(self): + # `locations` is a leaf special renderer; drilling into it is rejected. + self._assert_problem( + "findings", status=400, type_suffix="expand", data={"expand": "locations.location"}, + ) + + # --- 400: fields ----------------------------------------------------------------------- + def test_unknown_field_is_problem(self): + self._assert_problem("findings", status=400, type_suffix="fields", data={"fields": "id,nope"}) + + # --- 400: filter ----------------------------------------------------------------------- + def test_unknown_filter_param_is_problem(self): + self._assert_problem("findings", status=400, type_suffix="filter", data={"not_a_filter": "x"}) + + def test_invalid_filter_value_is_problem(self): + # `cwe` is numeric; a non-numeric value fails django-filter validation. + self._assert_problem("findings", status=400, type_suffix="filter", data={"cwe": "abc"}) + + def test_unknown_ordering_is_problem(self): + self._assert_problem("findings", status=400, type_suffix="filter", data={"o": "nope"}) + + # --- 400: pagination ------------------------------------------------------------------- + def test_bad_limit_is_problem(self): + self._assert_problem("findings", status=400, type_suffix="pagination", data={"limit": "-1"}) + + def test_non_integer_limit_is_problem(self): + self._assert_problem("findings", status=400, type_suffix="pagination", data={"limit": "abc"}) + + def test_cursor_pagination_reserved_is_problem(self): + self._assert_problem( + "findings", status=400, type_suffix="pagination", data={"pagination": "cursor"}, + ) + + # --- 401 / 404 ------------------------------------------------------------------------- + def test_anonymous_is_401_problem(self): + self._assert_problem( + "findings", status=401, type_suffix="unauthorized", client=self.anonymous_client(), + ) + + def test_unknown_or_unauthorized_detail_is_404_problem(self): + self._assert_problem("findings/99999999", status=404, type_suffix="not-found") + + +class _CyclicSchema(Schema): + + """A synthetic self-referential schema used only to exercise the expand cycle guard.""" + + django_model: ClassVar = Finding + EXPANDABLE: ClassVar[dict] = {} + + id: int + + +_CyclicSchema.EXPANDABLE = {"loop": ExpandRel(attr="x", path="x", schema=_CyclicSchema)} + + +class TestApiV3ExpandCycleGuard(SimpleTestCase): + + """ + Kernel-internal unit test (§6 preamble). + + The cycle guard is not reachable via the OS2 findings registry (it has no cyclic relation), + so it is tested directly against ``plan()``. + """ + + def test_cycle_is_rejected(self): + with self.assertRaises(ProblemDetail) as ctx: + plan(_CyclicSchema, "loop") + self.assertEqual(400, ctx.exception.status) + self.assertEqual("expand", ctx.exception.error_type) + self.assertIn("cycle", ctx.exception.detail.lower()) diff --git a/unittests/api_v3/test_apiv3_filter_contract.py b/unittests/api_v3/test_apiv3_filter_contract.py new file mode 100644 index 00000000000..3f86820aa62 --- /dev/null +++ b/unittests/api_v3/test_apiv3_filter_contract.py @@ -0,0 +1,58 @@ +r""" +Filter-contract snapshot test for API v3 (D6 / §4.9, §6 OS2 / I2). + +The per-object filter vocabulary (params + orderings + search fields) is a *tested artifact*: this +test renders every registered ``FilterSpec`` to ``unittests/api_v3/snapshots/filters.json`` and +fails on any drift, so a contract change can never land silently -- it must be an intentional +snapshot update. + +Regenerate the snapshot deliberately with:: + + DD_API_V3_UPDATE_SNAPSHOTS=1 ./run-unittest.sh --test-case \\ + unittests.api_v3.test_apiv3_filter_contract +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +# Importing the built API instance forces every resource route module to import, which registers +# its FilterSpec into the kernel registry (resource -> kernel dependency direction, I5). +import dojo.api_v3.api # noqa: F401 +from dojo.api_v3.filtering import iter_filter_specs + +from .base import ApiV3TestCase + +SNAPSHOT = Path(__file__).parent / "snapshots" / "filters.json" + + +def _render() -> dict: + return {name: spec.vocabulary() for name, spec in sorted(iter_filter_specs().items())} + + +class TestApiV3FilterContract(ApiV3TestCase): + + def test_filter_contract_matches_snapshot(self): + current = _render() + # Sanity: at least the findings vocabulary must be registered. + self.assertIn("finding", current, "no FilterSpec registered -- did the routers import?") + + serialized = json.dumps(current, indent=2, sort_keys=True) + "\n" + + if os.environ.get("DD_API_V3_UPDATE_SNAPSHOTS") or not SNAPSHOT.exists(): + SNAPSHOT.parent.mkdir(parents=True, exist_ok=True) + SNAPSHOT.write_text(serialized) + if not os.environ.get("DD_API_V3_UPDATE_SNAPSHOTS"): + # First-ever run establishes the baseline; nothing to compare against yet. + return + + saved = json.loads(SNAPSHOT.read_text()) + self.assertEqual( + saved, + current, + "contract drift -- update snapshot deliberately " + f"(set DD_API_V3_UPDATE_SNAPSHOTS=1 to regenerate {SNAPSHOT.name}).\n" + f"expected: {json.dumps(saved, sort_keys=True)}\n" + f"actual: {json.dumps(current, sort_keys=True)}", + ) diff --git a/unittests/api_v3/test_apiv3_findings.py b/unittests/api_v3/test_apiv3_findings.py index f90ef3cfcec..318f99f1d62 100644 --- a/unittests/api_v3/test_apiv3_findings.py +++ b/unittests/api_v3/test_apiv3_findings.py @@ -1,9 +1,12 @@ """Findings read-path contract tests for API v3 (§4.3-§4.9, OS1).""" from __future__ import annotations +from django.db import connection from django.test import override_settings +from django.test.utils import CaptureQueriesContext -from dojo.models import User +from dojo.location.models import Location, LocationFindingReference +from dojo.models import Finding, User from .base import ApiV3TestCase @@ -106,6 +109,35 @@ def test_ordering(self): def test_unknown_ordering_is_400(self): self.get_json("findings", data={"o": "not_orderable"}, expected=400) + def test_severity_ordering_is_by_rank_not_alphabetical(self): + rank = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Info": 4} + severities = [r["severity"] for r in self.get_json("findings", data={"o": "severity", "limit": 250})["results"]] + ranks = [rank[s] for s in severities] + # Rank-sorted ascending -> Critical first (§4.9), never alphabetical. + self.assertEqual(ranks, sorted(ranks), severities) + # Alphabetical would place "Info" before "Medium"; rank ordering must not. + if "Medium" in severities and "Info" in severities: + self.assertLess(severities.index("Medium"), severities.index("Info"), severities) + + def test_severity_ordering_descending_reverses_rank(self): + rank = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Info": 4} + severities = [r["severity"] for r in self.get_json("findings", data={"o": "-severity", "limit": 250})["results"]] + ranks = [rank[s] for s in severities] + self.assertEqual(ranks, sorted(ranks, reverse=True), severities) + + def test_q_free_text_search(self): + body = self.get_json("findings", data={"q": "DUMMY", "limit": 250}) + self.assertGreater(body["count"], 0) + for row in body["results"]: + self.assertIn("dummy", row["title"].lower()) + + def test_q_no_match_returns_empty(self): + body = self.get_json("findings", data={"q": "zzz_no_such_finding_zzz"}) + self.assertEqual(0, body["count"]) + + def test_unknown_filter_param_is_400(self): + self.get_json("findings", data={"not_a_real_filter": "x"}, expected=400) + class TestApiV3FindingsInclude(ApiV3TestCase): @@ -156,6 +188,65 @@ def test_count_switches_to_estimate_above_cap(self): self.assertFalse(body["meta"]["count_exact"]) +class TestApiV3FindingsLocationsExpand(ApiV3TestCase): + + def _attach_location(self, finding: Finding, value: str, status: str = "Active") -> None: + location = Location.objects.create(location_type="url", location_value=value) + LocationFindingReference.objects.create(location=location, finding=finding, status=status) + + def test_expand_locations_swaps_count_for_edge_rows(self): + finding = Finding.objects.first() + existing = finding.locations.count() # fixture may already attach some + self._attach_location(finding, "https://example.com/os2-a") + self._attach_location(finding, "https://example.com/os2-b", status="Mitigated") + + detail = self.get_json(f"findings/{finding.id}", data={"expand": "locations"}) + # `locations_count` is swapped for the edge rows (§4.6). + self.assertNotIn("locations_count", detail) + self.assertIn("locations", detail) + rows = detail["locations"] + self.assertEqual(existing + 2, len(rows)) + for row in rows: + self.assertEqual({"location", "status", "audit_time"}, set(row)) + self.assertEqual({"id", "name", "type"}, set(row["location"])) + by_name = {row["location"]["name"]: row for row in rows} + self.assertEqual("url", by_name["https://example.com/os2-a"]["location"]["type"]) + self.assertEqual("Active", by_name["https://example.com/os2-a"]["status"]) + self.assertEqual("Mitigated", by_name["https://example.com/os2-b"]["status"]) + + def test_slim_without_expand_keeps_locations_count(self): + row = self.get_json("findings")["results"][0] + self.assertIn("locations_count", row) + self.assertNotIn("locations", row) + + def test_expand_into_locations_is_400(self): + self.get_json("findings", data={"expand": "locations.location"}, expected=400) + + def test_expand_locations_query_count_constant(self): + test = Finding.objects.first().test + params = {"limit": 250, "expand": "locations"} + + def query_count() -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url("findings"), params) + self.assertEqual(200, response.status_code, response.content[:500]) + return len(ctx.captured_queries) + + Finding.objects.bulk_create([ + Finding(title=f"loc qcount {i}", severity="High", numerical_severity="S1", + description="x", test=test, reporter=self.admin, active=True, verified=False) + for i in range(10) + ]) + first = query_count() + Finding.objects.bulk_create([ + Finding(title=f"loc qcount b{i}", severity="High", numerical_severity="S1", + description="x", test=test, reporter=self.admin, active=True, verified=False) + for i in range(90) + ]) + second = query_count() + self.assertEqual(first, second, f"expand=locations query count grew: {first} -> {second}") + + class TestApiV3FindingsRbac(ApiV3TestCase): def test_include_counts_and_list_respect_authorized_queryset(self): diff --git a/unittests/api_v3/test_apiv3_openapi.py b/unittests/api_v3/test_apiv3_openapi.py new file mode 100644 index 00000000000..a3ac4837d2f --- /dev/null +++ b/unittests/api_v3/test_apiv3_openapi.py @@ -0,0 +1,52 @@ +""" +OpenAPI schema-generation guard for API v3 (§4.1, §6 OS2). + +A CI-facing unit test asserting ``api_v3.get_openapi_schema()`` renders and contains the expected +paths, components, per-resource tags and the alpha banner. This guards against a schema-generation +regression (a broken schema silently breaks client codegen and the interactive docs). +""" +from __future__ import annotations + +from django.conf import settings +from django.test import SimpleTestCase + +from dojo.api_v3.api import api_v3 + + +class TestApiV3OpenApi(SimpleTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.schema = api_v3.get_openapi_schema() + + def test_info_version_and_alpha_banner(self): + self.assertEqual(settings.API_V3_VERSION, self.schema["info"]["version"]) + self.assertIn("alpha", self.schema["info"]["description"].lower()) + + def test_expected_paths_present(self): + # Paths carry the mount prefix (/api/v3-alpha/...); assert on the operation suffix. + paths = set(self.schema["paths"]) + for suffix in ("/findings", "/findings/{finding_id}", "/import"): + self.assertTrue( + any(p.endswith(suffix) for p in paths), + f"missing a path ending in {suffix}; have {sorted(paths)}", + ) + + def test_expected_components_present(self): + components = set(self.schema["components"]["schemas"]) + for expected in ("Ref", "FindingSlim", "FindingDetail"): + self.assertIn(expected, components, f"missing component {expected}") + + def test_ref_component_is_closed_shape(self): + ref = self.schema["components"]["schemas"]["Ref"] + self.assertEqual({"id", "name"}, set(ref["properties"])) + + def test_per_resource_tags(self): + tags = { + tag + for path in self.schema["paths"].values() + for operation in path.values() + for tag in operation.get("tags", []) + } + self.assertLessEqual({"findings", "import"}, tags, f"tags found: {sorted(tags)}") From aaabc5e54947199532fef6b54fe45a4d6b8a3e45 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 13:11:48 +0200 Subject: [PATCH 03/37] feat(api-v3): OS3a - product_type, product, user CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - slim/detail/write schemas per plan §4.5 with canonical slims relocated out of the finding module (finding expand targets re-export them) - router factories with GET list/detail, POST, PATCH, DELETE (no PUT in alpha, additive later) - RBAC: authorized querysets + v2-parity permission checks; product/ product_type deletion mirrors v2 exactly (async_delete or Endpoint.allow_endpoint_init context) - user visibility: v2-parity view_user configuration-permission gate with guaranteed self-read (plain users see exactly themselves); UserSerializer.validate() rules ported (superuser/staff gating, password write-only, no self-delete) - filter specs registered + vocabulary snapshot extended (additive diff) 129 tests green (65 prior + 64 new). --- dojo/api_v3/api.py | 6 + dojo/finding/api_v3/schemas.py | 67 +---- dojo/product/api_v3/__init__.py | 0 dojo/product/api_v3/routes.py | 255 +++++++++++++++++++ dojo/product/api_v3/schemas.py | 125 +++++++++ dojo/product_type/api_v3/__init__.py | 0 dojo/product_type/api_v3/routes.py | 200 +++++++++++++++ dojo/product_type/api_v3/schemas.py | 70 +++++ dojo/user/api_v3/__init__.py | 0 dojo/user/api_v3/routes.py | 224 ++++++++++++++++ dojo/user/api_v3/schemas.py | 81 ++++++ unittests/api_v3/snapshots/filters.json | 73 ++++++ unittests/api_v3/test_apiv3_product_types.py | 159 ++++++++++++ unittests/api_v3/test_apiv3_products.py | 190 ++++++++++++++ unittests/api_v3/test_apiv3_users.py | 207 +++++++++++++++ 15 files changed, 1595 insertions(+), 62 deletions(-) create mode 100644 dojo/product/api_v3/__init__.py create mode 100644 dojo/product/api_v3/routes.py create mode 100644 dojo/product/api_v3/schemas.py create mode 100644 dojo/product_type/api_v3/__init__.py create mode 100644 dojo/product_type/api_v3/routes.py create mode 100644 dojo/product_type/api_v3/schemas.py create mode 100644 dojo/user/api_v3/__init__.py create mode 100644 dojo/user/api_v3/routes.py create mode 100644 dojo/user/api_v3/schemas.py create mode 100644 unittests/api_v3/test_apiv3_product_types.py create mode 100644 unittests/api_v3/test_apiv3_products.py create mode 100644 unittests/api_v3/test_apiv3_users.py diff --git a/dojo/api_v3/api.py b/dojo/api_v3/api.py index 92f8018094c..05e3b66d6a9 100644 --- a/dojo/api_v3/api.py +++ b/dojo/api_v3/api.py @@ -57,8 +57,14 @@ def build_api() -> NinjaAPI: # stays "resources import kernel", never the reverse. from dojo.api_v3.import_routes import build_import_router # noqa: PLC0415 from dojo.finding.api_v3.routes import build_findings_router # noqa: PLC0415 + from dojo.product.api_v3.routes import build_products_router # noqa: PLC0415 + from dojo.product_type.api_v3.routes import build_product_types_router # noqa: PLC0415 + from dojo.user.api_v3.routes import build_users_router # noqa: PLC0415 api.add_router("", build_findings_router()) + api.add_router("", build_product_types_router()) + api.add_router("", build_products_router()) + api.add_router("", build_users_router()) api.add_router("", build_import_router()) return api diff --git a/dojo/finding/api_v3/schemas.py b/dojo/finding/api_v3/schemas.py index 8527a8c0478..6c5875a771e 100644 --- a/dojo/finding/api_v3/schemas.py +++ b/dojo/finding/api_v3/schemas.py @@ -25,74 +25,17 @@ from dojo.api_v3.refs import Ref, to_location_ref, to_ref from dojo.models import ( Development_Environment, - Dojo_User, Engagement, Finding, - Product, - Product_Type, Test, Test_Type, ) - -class UserSlim(Schema): - django_model: ClassVar = Dojo_User - SELECT_RELATED: ClassVar[tuple] = () - PREFETCH_RELATED: ClassVar[tuple] = () - EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} - - id: int - username: str - first_name: str - last_name: str - email: str - is_active: bool - is_superuser: bool - last_login: datetime | None - - -class ProductTypeSlim(Schema): - django_model: ClassVar = Product_Type - SELECT_RELATED: ClassVar[tuple] = () - PREFETCH_RELATED: ClassVar[tuple] = () - EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} - - id: int - name: str - description: str | None - critical_product: bool | None - key_product: bool | None - created: datetime | None - updated: datetime | None - - -class ProductSlim(Schema): - django_model: ClassVar = Product - SELECT_RELATED: ClassVar[tuple] = ("prod_type",) - PREFETCH_RELATED: ClassVar[tuple] = ("tags",) - EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} - - id: int - name: str - description: str | None - product_type: Ref - lifecycle: str | None - tags: list[str] - created: datetime | None - updated: datetime | None - - @staticmethod - def resolve_product_type(obj) -> dict | None: - return to_ref(obj.prod_type) - - @staticmethod - def resolve_tags(obj) -> list[str]: - return [t.name for t in obj.tags.all()] - - -ProductSlim.EXPANDABLE = { - "product_type": ExpandRel(attr="prod_type", path="prod_type", schema=ProductTypeSlim), -} +# Canonical parent slims live in their own resource modules as of OS3a (§12): the finding expand +# targets (below) reference these single canonical classes rather than a private duplicate. +from dojo.product.api_v3.schemas import ProductSlim +from dojo.product_type.api_v3.schemas import ProductTypeSlim +from dojo.user.api_v3.schemas import UserSlim class EngagementSlim(Schema): diff --git a/dojo/product/api_v3/__init__.py b/dojo/product/api_v3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/product/api_v3/routes.py b/dojo/product/api_v3/routes.py new file mode 100644 index 00000000000..1016ca7a18f --- /dev/null +++ b/dojo/product/api_v3/routes.py @@ -0,0 +1,255 @@ +""" +Product CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3a). + +``build_products_router()`` is a router factory (I5), same signature style as +``build_findings_router``. Routes are thin (I6): authorize -> filter -> plan queryset -> serialize +-> shape. RBAC flows only through ``get_authorized_products`` for reads (I8) and the v2 +``user_has_permission`` semantics for writes, mirroring the v2 ``UserHasProductPermission`` class: + +- create (POST): ``add`` permission on the target ``prod_type`` referenced in the payload +- update (PATCH): object ``edit`` permission, plus ``add`` on a *reassigned* ``prod_type`` + (mirrors ``check_update_permission(request, obj, "add", "prod_type")``) +- delete (DELETE): object ``delete`` permission (staff-only for non-staff members, legacy model) +- read: object ``view`` via the authorized queryset (404 for unknown-or-unauthorized) + +Deletion mirrors the v2 ``ProductViewSet.destroy`` exactly: async delete when +``ASYNC_OBJECT_DELETE`` is set, else a synchronous delete inside ``Endpoint.allow_endpoint_init()`` +(§12). Relations are referenced by integer id (§4.11). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.core.exceptions import ValidationError as DjangoValidationError +from django.http import HttpResponse +from ninja import Router, Schema +from ninja.constants import NOT_SET + +from dojo.api_v3.errors import json_response, not_found_problem, validation_problem +from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.filtering import ( + FilterSpec, + apply_filters, + filter_field, + register_filter_spec, +) +from dojo.api_v3.include import apply_includes +from dojo.api_v3.pagination import paginate +from dojo.authorization.authorization import user_has_permission +from dojo.authorization.roles_permissions import Permissions +from dojo.models import Dojo_User, Endpoint, Product, Product_Type, SLA_Configuration +from dojo.product.api_v3.schemas import ( + ProductDetail, + ProductSlim, + ProductUpdate, + ProductWrite, +) +from dojo.product.queries import get_authorized_products +from dojo.utils import async_delete, get_object_or_none, get_setting + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import QuerySet + from django.http import HttpRequest + +# --- Product filter vocabulary (§4.9, minimal set) -------------------------------------------- + +PRODUCT_FILTER_SPEC = register_filter_spec("product", FilterSpec( + model=Product, + filters={ + "id__in": filter_field("id", "in", "number"), + "name__icontains": filter_field("name", "icontains", "char"), + "product_type": filter_field("prod_type", "exact", "number"), + "product_type__in": filter_field("prod_type", "in", "number"), + "created__gte": filter_field("created", "gte", "datetime"), + "created__lte": filter_field("created", "lte", "datetime"), + "updated__gte": filter_field("updated", "gte", "datetime"), + "updated__lte": filter_field("updated", "lte", "datetime"), + }, + orderings={ + "id": "id", + "name": "name", + "created": "created", + "updated": "updated", + }, + search_fields=["name", "description"], +)) + +_USER_FK_FIELDS = ("product_manager", "technical_contact", "team_manager") + +# Sentinel distinguishing "tags omitted" from "tags set to null/empty" on PATCH. +_UNSET = object() + + +class ProductListResponse(Schema): + + """OpenAPI documentation of the list envelope (I1); runtime serialization is manual.""" + + count: int + next: str | None + previous: str | None + results: list[ProductSlim] + meta: dict | None = None + + +def _base_queryset(request: HttpRequest, queryset_hook: Callable | None) -> QuerySet: + qs = get_authorized_products(Permissions.Product_View, user=request.user) + if queryset_hook is not None: + qs = queryset_hook(qs, request) + return qs + + +def _validation_from_django(exc: DjangoValidationError) -> Exception: + if hasattr(exc, "message_dict"): + return validation_problem({k: list(v) for k, v in exc.message_dict.items()}) + return validation_problem({"non_field_errors": list(exc.messages)}) + + +def _resolve_user_fk(field: str, pk: int) -> Dojo_User: + user = get_object_or_none(Dojo_User, pk=pk) + if user is None: + raise validation_problem({field: [f"user {pk} does not exist"]}) + return user + + +def _destroy(instance: Product) -> None: + """Mirror v2 ``ProductViewSet.destroy`` exactly (§12).""" + if get_setting("ASYNC_OBJECT_DELETE"): + async_delete().delete(instance) + else: + with Endpoint.allow_endpoint_init(): # TODO: remove after full move to Locations (mirrors v2) + instance.delete() + + +def _apply_optional_relations_and_scalars(instance: Product, data: dict) -> None: + """Apply the user-FK, SLA and scalar fields present in ``data`` (create + update share this).""" + for field in _USER_FK_FIELDS: + if field in data: + pk = data.pop(field) + setattr(instance, field, _resolve_user_fk(field, pk) if pk is not None else None) + if "sla_configuration" in data: + pk = data.pop("sla_configuration") + if pk is not None: + sla = get_object_or_none(SLA_Configuration, pk=pk) + if sla is None: + raise validation_problem({"sla_configuration": [f"SLA configuration {pk} does not exist"]}) + instance.sla_configuration = sla + for key, value in data.items(): + setattr(instance, key, value) + + +def build_products_router( + *, + schema: type = ProductSlim, + detail_schema: type = ProductDetail, + filter_spec: FilterSpec = PRODUCT_FILTER_SPEC, + queryset_hook: Callable | None = None, + auth=NOT_SET, +) -> Router: + """Build the products router (I5).""" + router = Router(tags=["products"], auth=auth) + + @router.get("/products", response=ProductListResponse, url_name="products_list") + def list_products(request: HttpRequest): + filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) + + expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) + page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + page_qs = plan_queryset(page_qs, select_related, prefetch) + + fields = parse_fields(request.GET.get("fields"), set(schema.model_fields)) + + def serialize_row(obj: object) -> dict: + return apply_fields(serialize(obj, schema, expand_tree), fields) + + envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) + include_meta = apply_includes(request, filtered, allowed=set()) + if include_meta: + envelope.setdefault("meta", {}).update(include_meta) + return json_response(envelope) + + @router.get("/products/{int:product_id}", response=detail_schema, url_name="products_detail") + def get_product(request: HttpRequest, product_id: int): + expand_tree, select_related, prefetch = plan(detail_schema, request.GET.get("expand")) + qs = _base_queryset(request, queryset_hook).select_related(*detail_schema.SELECT_RELATED).prefetch_related(*detail_schema.PREFETCH_RELATED) + qs = plan_queryset(qs, select_related, prefetch) + obj = qs.filter(pk=product_id).first() + if obj is None: + msg = f"Product {product_id} not found" + raise not_found_problem(msg) + fields = parse_fields(request.GET.get("fields"), set(detail_schema.model_fields)) + return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) + + @router.post("/products", response=detail_schema, url_name="products_create") + def create_product(request: HttpRequest, payload: ProductWrite): + data = payload.dict() + tags = data.pop("tags") + prod_type_id = data.pop("prod_type") + # Mirror check_post_permission(request, Product_Type, "prod_type", "add"): 404 if the + # target product type doesn't exist, 403 if the user can't add products to it. + prod_type = get_object_or_none(Product_Type, pk=prod_type_id) + if prod_type is None: + msg = f"Product_Type {prod_type_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, prod_type, Permissions.Product_Type_Add_Product): + raise PermissionDenied + instance = Product(prod_type=prod_type) + # Drop unset (None) scalars so the model field defaults apply on create. + _apply_optional_relations_and_scalars(instance, {k: v for k, v in data.items() if v is not None}) + if tags is not None: + instance.tags = tags + try: + instance.save() + except DjangoValidationError as exc: + raise _validation_from_django(exc) from exc + return json_response(serialize(instance, detail_schema, {}), status=201) + + @router.patch("/products/{int:product_id}", response=detail_schema, url_name="products_update") + def update_product(request: HttpRequest, product_id: int, payload: ProductUpdate): + instance = _base_queryset(request, queryset_hook).filter(pk=product_id).first() + if instance is None: + msg = f"Product {product_id} not found" + raise not_found_problem(msg) # 404: unknown or unauthorized-to-view + if not user_has_permission(request.user, instance, Permissions.Product_Edit): + raise PermissionDenied # 403: visible but not editable + + data = payload.dict(exclude_unset=True) + tags = data.pop("tags", _UNSET) + if "prod_type" in data: + new_pt_id = data.pop("prod_type") + # Mirror check_update_permission: only re-check when the FK actually changes. + if new_pt_id is not None and new_pt_id != instance.prod_type_id: + new_pt = get_object_or_none(Product_Type, pk=new_pt_id) + if new_pt is None: + msg = f"Product_Type {new_pt_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, new_pt, Permissions.Product_Type_Add_Product): + raise PermissionDenied + instance.prod_type = new_pt + + _apply_optional_relations_and_scalars(instance, data) + if tags is not _UNSET: + instance.tags = tags if tags is not None else [] + try: + instance.save() + except DjangoValidationError as exc: + raise _validation_from_django(exc) from exc + return json_response(serialize(instance, detail_schema, {})) + + @router.delete("/products/{int:product_id}", url_name="products_delete") + def delete_product(request: HttpRequest, product_id: int): + instance = _base_queryset(request, queryset_hook).filter(pk=product_id).first() + if instance is None: + msg = f"Product {product_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, instance, Permissions.Product_Delete): + raise PermissionDenied + _destroy(instance) + response = HttpResponse(status=204) + response["X-API-Status"] = settings.API_V3_STATUS + return response + + return router diff --git a/dojo/product/api_v3/schemas.py b/dojo/product/api_v3/schemas.py new file mode 100644 index 00000000000..e344fceca0d --- /dev/null +++ b/dojo/product/api_v3/schemas.py @@ -0,0 +1,125 @@ +""" +Product response + write schemas for API v3 (§4.5, §4.11, OS3a). + +``ProductSlim`` is the canonical parent slim (relocated here from ``dojo/finding/api_v3`` where OS1 +first defined it -- see §12; the finding module now re-exports this copy). ``ProductDetail`` adds the +documented heavier read fields (§4.5). ``product_type`` is an expandable relation (§4.6). + +Write schemas mirror the v2 ``ProductSerializer`` required/optional split: the model requires +``name``, ``description`` and ``prod_type``; everything else is optional (``sla_configuration`` +defaults to the model default when omitted). Relations are referenced by integer id (§4.11); +server-managed fields are never writable; unknown fields are rejected (``extra="forbid"``). +""" +from __future__ import annotations + +from datetime import datetime # noqa: TC003 -- runtime import: pydantic resolves the schema field types +from typing import ClassVar + +from ninja import Schema + +from dojo.api_v3.expand import ExpandRel +from dojo.api_v3.refs import Ref, to_ref +from dojo.models import Product +from dojo.product_type.api_v3.schemas import ProductTypeSlim + + +class ProductSlim(Schema): + django_model: ClassVar = Product + SELECT_RELATED: ClassVar[tuple] = ("prod_type",) + PREFETCH_RELATED: ClassVar[tuple] = ("tags",) + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str + description: str | None + product_type: Ref + lifecycle: str | None + tags: list[str] + created: datetime | None + updated: datetime | None + + @staticmethod + def resolve_product_type(obj) -> dict | None: + return to_ref(obj.prod_type) + + @staticmethod + def resolve_tags(obj) -> list[str]: + return [t.name for t in obj.tags.all()] + + +ProductSlim.EXPANDABLE = { + "product_type": ExpandRel(attr="prod_type", path="prod_type", schema=ProductTypeSlim), +} + + +class ProductDetail(ProductSlim): + + """Slim + the documented heavier read fields (§4.5). Retrieve returns detail; list returns slim.""" + + # Detail fetch pulls the extra parent FKs so the ref resolvers below issue no extra queries. + SELECT_RELATED: ClassVar[tuple] = ("prod_type", "product_manager", "technical_contact", "team_manager") + + business_criticality: str | None + platform: str | None + origin: str | None + external_audience: bool | None + internet_accessible: bool | None + product_manager: Ref | None + technical_contact: Ref | None + team_manager: Ref | None + + @staticmethod + def resolve_product_manager(obj) -> dict | None: + return to_ref(obj.product_manager) + + @staticmethod + def resolve_technical_contact(obj) -> dict | None: + return to_ref(obj.technical_contact) + + @staticmethod + def resolve_team_manager(obj) -> dict | None: + return to_ref(obj.team_manager) + + +class ProductWrite(Schema): + + """Create payload (POST). ``name``/``description``/``prod_type`` required (§6 OS3, mirrors v2).""" + + model_config = {"extra": "forbid"} + + name: str + description: str + prod_type: int + business_criticality: str | None = None + platform: str | None = None + lifecycle: str | None = None + origin: str | None = None + product_manager: int | None = None + technical_contact: int | None = None + team_manager: int | None = None + sla_configuration: int | None = None + external_audience: bool | None = None + internet_accessible: bool | None = None + tags: list[str] | None = None + + +class ProductUpdate(Schema): + + """Partial update payload (PATCH). Every field optional; only provided keys are applied.""" + + model_config = {"extra": "forbid"} + + name: str | None = None + description: str | None = None + prod_type: int | None = None + business_criticality: str | None = None + platform: str | None = None + lifecycle: str | None = None + origin: str | None = None + product_manager: int | None = None + technical_contact: int | None = None + team_manager: int | None = None + sla_configuration: int | None = None + external_audience: bool | None = None + internet_accessible: bool | None = None + tags: list[str] | None = None diff --git a/dojo/product_type/api_v3/__init__.py b/dojo/product_type/api_v3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/product_type/api_v3/routes.py b/dojo/product_type/api_v3/routes.py new file mode 100644 index 00000000000..2f9a4111be7 --- /dev/null +++ b/dojo/product_type/api_v3/routes.py @@ -0,0 +1,200 @@ +""" +Product_Type CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3a). + +``build_product_types_router()`` is a router factory (I5), same signature style as +``build_findings_router``. Routes are thin (I6): authorize -> filter -> plan queryset -> serialize +-> shape. RBAC flows only through ``get_authorized_product_types`` for reads (I8) and the v2 +``user_has_permission``/``user_has_global_permission`` semantics for writes, mirroring the v2 +``UserHasProductTypePermission`` permission class exactly: + +- create (POST): global ``add`` permission (``user_has_global_permission(user, "add")``) +- update (PATCH): object ``edit`` permission +- delete (DELETE): object ``delete`` permission (staff-only for non-staff members, per the legacy model) +- read: object ``view`` via the authorized queryset (404 for unknown-or-unauthorized) + +Deletion mirrors the v2 ``ProductTypeViewSet.destroy`` exactly: async delete when +``ASYNC_OBJECT_DELETE`` is set, else a synchronous delete inside ``Endpoint.allow_endpoint_init()`` +(required while ``V3_FEATURE_LOCATIONS`` is on -- see §12). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.core.exceptions import ValidationError as DjangoValidationError +from django.http import HttpResponse +from ninja import Router, Schema +from ninja.constants import NOT_SET + +from dojo.api_v3.errors import json_response, not_found_problem, validation_problem +from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.filtering import ( + FilterSpec, + apply_filters, + filter_field, + register_filter_spec, +) +from dojo.api_v3.include import apply_includes +from dojo.api_v3.pagination import paginate +from dojo.authorization.authorization import user_has_global_permission, user_has_permission +from dojo.authorization.roles_permissions import Permissions +from dojo.models import Endpoint, Product_Type +from dojo.product_type.api_v3.schemas import ( + ProductTypeDetail, + ProductTypeSlim, + ProductTypeUpdate, + ProductTypeWrite, +) +from dojo.product_type.queries import get_authorized_product_types +from dojo.utils import async_delete, get_setting + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import QuerySet + from django.http import HttpRequest + +# --- Product_Type filter vocabulary (§4.9, minimal set) --------------------------------------- + +PRODUCT_TYPE_FILTER_SPEC = register_filter_spec("product_type", FilterSpec( + model=Product_Type, + filters={ + "id__in": filter_field("id", "in", "number"), + "name__icontains": filter_field("name", "icontains", "char"), + "created__gte": filter_field("created", "gte", "datetime"), + "created__lte": filter_field("created", "lte", "datetime"), + "updated__gte": filter_field("updated", "gte", "datetime"), + "updated__lte": filter_field("updated", "lte", "datetime"), + }, + orderings={ + "id": "id", + "name": "name", + "created": "created", + "updated": "updated", + }, + search_fields=["name", "description"], +)) + + +class ProductTypeListResponse(Schema): + + """OpenAPI documentation of the list envelope (I1); runtime serialization is manual.""" + + count: int + next: str | None + previous: str | None + results: list[ProductTypeSlim] + meta: dict | None = None + + +def _base_queryset(request: HttpRequest, queryset_hook: Callable | None) -> QuerySet: + # Reads flow only through the authorized queryset (I8). The OS helper resolves the current user + # from crum (its signature takes no user kwarg), exactly as the v2 viewset relies on. + qs = get_authorized_product_types(Permissions.Product_Type_View) + if queryset_hook is not None: + qs = queryset_hook(qs, request) + return qs + + +def _validation_from_django(exc: DjangoValidationError) -> Exception: + """Map a model ``full_clean`` failure onto the field-keyed problem+json contract (§4.10).""" + if hasattr(exc, "message_dict"): + return validation_problem({k: list(v) for k, v in exc.message_dict.items()}) + return validation_problem({"non_field_errors": list(exc.messages)}) + + +def _destroy(instance: Product_Type) -> None: + """Mirror v2 ``ProductTypeViewSet.destroy`` exactly (§12).""" + if get_setting("ASYNC_OBJECT_DELETE"): + async_delete().delete(instance) + else: + with Endpoint.allow_endpoint_init(): # TODO: remove after full move to Locations (mirrors v2) + instance.delete() + + +def build_product_types_router( + *, + schema: type = ProductTypeSlim, + detail_schema: type = ProductTypeDetail, + filter_spec: FilterSpec = PRODUCT_TYPE_FILTER_SPEC, + queryset_hook: Callable | None = None, + auth=NOT_SET, +) -> Router: + """Build the product_types router (I5).""" + router = Router(tags=["product_types"], auth=auth) + + @router.get("/product_types", response=ProductTypeListResponse, url_name="product_types_list") + def list_product_types(request: HttpRequest): + filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) + + expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) + page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + page_qs = plan_queryset(page_qs, select_related, prefetch) + + fields = parse_fields(request.GET.get("fields"), set(schema.model_fields)) + + def serialize_row(obj: object) -> dict: + return apply_fields(serialize(obj, schema, expand_tree), fields) + + envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) + include_meta = apply_includes(request, filtered, allowed=set()) + if include_meta: + envelope.setdefault("meta", {}).update(include_meta) + return json_response(envelope) + + @router.get("/product_types/{int:product_type_id}", response=detail_schema, url_name="product_types_detail") + def get_product_type(request: HttpRequest, product_type_id: int): + expand_tree, select_related, prefetch = plan(detail_schema, request.GET.get("expand")) + qs = _base_queryset(request, queryset_hook).select_related(*detail_schema.SELECT_RELATED).prefetch_related(*detail_schema.PREFETCH_RELATED) + qs = plan_queryset(qs, select_related, prefetch) + obj = qs.filter(pk=product_type_id).first() + if obj is None: + msg = f"Product_Type {product_type_id} not found" + raise not_found_problem(msg) + fields = parse_fields(request.GET.get("fields"), set(detail_schema.model_fields)) + return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) + + @router.post("/product_types", response=detail_schema, url_name="product_types_create") + def create_product_type(request: HttpRequest, payload: ProductTypeWrite): + # Mirror UserHasProductTypePermission: POST requires global "add". + if not user_has_global_permission(request.user, "add"): + raise PermissionDenied + instance = Product_Type(**payload.dict()) + try: + instance.save() + except DjangoValidationError as exc: + raise _validation_from_django(exc) from exc + return json_response(serialize(instance, detail_schema, {}), status=201) + + @router.patch("/product_types/{int:product_type_id}", response=detail_schema, url_name="product_types_update") + def update_product_type(request: HttpRequest, product_type_id: int, payload: ProductTypeUpdate): + instance = _base_queryset(request, queryset_hook).filter(pk=product_type_id).first() + if instance is None: + msg = f"Product_Type {product_type_id} not found" + raise not_found_problem(msg) # 404: unknown or unauthorized-to-view + if not user_has_permission(request.user, instance, Permissions.Product_Type_Edit): + raise PermissionDenied # 403: visible but not editable + for key, value in payload.dict(exclude_unset=True).items(): + setattr(instance, key, value) + try: + instance.save() + except DjangoValidationError as exc: + raise _validation_from_django(exc) from exc + return json_response(serialize(instance, detail_schema, {})) + + @router.delete("/product_types/{int:product_type_id}", url_name="product_types_delete") + def delete_product_type(request: HttpRequest, product_type_id: int): + instance = _base_queryset(request, queryset_hook).filter(pk=product_type_id).first() + if instance is None: + msg = f"Product_Type {product_type_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, instance, Permissions.Product_Type_Delete): + raise PermissionDenied + _destroy(instance) + # 204 No Content: an empty body (not "null"); still carry the alpha status header (§4.1). + response = HttpResponse(status=204) + response["X-API-Status"] = settings.API_V3_STATUS + return response + + return router diff --git a/dojo/product_type/api_v3/schemas.py b/dojo/product_type/api_v3/schemas.py new file mode 100644 index 00000000000..ea7a2e0e7ee --- /dev/null +++ b/dojo/product_type/api_v3/schemas.py @@ -0,0 +1,70 @@ +""" +Product_Type response + write schemas for API v3 (§4.5, §4.11, OS3a). + +``ProductTypeSlim`` is the canonical parent slim (relocated here from ``dojo/finding/api_v3`` where +OS1 first defined it -- see §12; the finding module now re-exports this copy so there is one class, +not two, keeping expand targets and this resource in lock-step). Every schema is a named, +importable, subclassable ninja ``Schema`` (I4) and declares the ``ClassVar`` machinery the expand +planner reads (``django_model``/``SELECT_RELATED``/``PREFETCH_RELATED``/``EXPANDABLE``). + +Write schemas (``ProductTypeWrite`` create, ``ProductTypeUpdate`` PATCH) are the editable subset of +the detail fields; required-vs-optional mirrors the v2 ``ProductTypeSerializer`` (a ``ModelSerializer`` +over the model -- ``name`` required, the rest defaulted). Server-managed fields (``id``/``created``/ +``updated``) are never writable, and unknown fields are rejected (``extra="forbid"``) so write +validation is consistent with the kernel's strict query contract (§12). +""" +from __future__ import annotations + +from datetime import datetime # noqa: TC003 -- runtime import: pydantic resolves the schema field types +from typing import TYPE_CHECKING, ClassVar + +from ninja import Schema + +from dojo.models import Product_Type + +if TYPE_CHECKING: + from dojo.api_v3.expand import ExpandRel + + +class ProductTypeSlim(Schema): + django_model: ClassVar = Product_Type + SELECT_RELATED: ClassVar[tuple] = () + PREFETCH_RELATED: ClassVar[tuple] = () + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str + description: str | None + critical_product: bool | None + key_product: bool | None + created: datetime | None + updated: datetime | None + + +class ProductTypeDetail(ProductTypeSlim): + + """Detail (retrieve) shape. Product_Type has no heavier read fields today, so it equals slim.""" + + +class ProductTypeWrite(Schema): + + """Create payload (POST). ``name`` is required; the rest mirror the model defaults (§6 OS3).""" + + model_config = {"extra": "forbid"} + + name: str + description: str | None = None + critical_product: bool = False + key_product: bool = False + + +class ProductTypeUpdate(Schema): + + """Partial update payload (PATCH). Every field optional; only provided keys are applied.""" + + model_config = {"extra": "forbid"} + + name: str | None = None + description: str | None = None + critical_product: bool | None = None + key_product: bool | None = None diff --git a/dojo/user/api_v3/__init__.py b/dojo/user/api_v3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/user/api_v3/routes.py b/dojo/user/api_v3/routes.py new file mode 100644 index 00000000000..9e273fcea24 --- /dev/null +++ b/dojo/user/api_v3/routes.py @@ -0,0 +1,224 @@ +""" +User (Dojo_User) CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3a). + +``build_users_router()`` is a router factory (I5), same signature style as ``build_findings_router``. +Routes are thin (I6): authorize -> filter -> plan queryset -> serialize -> shape. + +RBAC (mirroring the v2 ``UsersViewSet``, read first): +- reads: every authenticated user gets the collaborator-scoped view via ``get_authorized_users`` + (I8). This is the OS user-visibility model (superusers/staff see all; others see users + sharing their authorized products/product-types, plus superusers). See §12 for why reads + are opened to authenticated users rather than gated behind the ``view_user`` config perm. +- writes: admin/superuser-only, mirroring ``UserHasConfigurationPermissionSuperuser`` (the Django + ``add_user``/``change_user``/``delete_user`` configuration permissions -- superusers pass + automatically). The ``UserSerializer.validate()`` rules are ported verbatim: only + superusers may add/edit superusers or staff; password is write-only and cannot be changed + via PATCH; a password is required on create when ``REQUIRE_PASSWORD_ON_USER`` is set; and + a user may not delete themselves (v2 ``destroy``). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.conf import settings +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import PermissionDenied +from django.core.exceptions import ValidationError as DjangoValidationError +from django.http import HttpResponse +from ninja import Router, Schema +from ninja.constants import NOT_SET + +from dojo.api_v3.errors import json_response, not_found_problem, validation_problem +from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.filtering import ( + FilterSpec, + apply_filters, + filter_field, + register_filter_spec, +) +from dojo.api_v3.include import apply_includes +from dojo.api_v3.pagination import paginate +from dojo.authorization.authorization import user_has_configuration_permission +from dojo.models import Dojo_User +from dojo.user.api_v3.schemas import UserDetail, UserSlim, UserUpdate, UserWrite +from dojo.user.queries import get_authorized_users + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import QuerySet + from django.http import HttpRequest + +# --- User filter vocabulary (§4.9, minimal set) ----------------------------------------------- + +USER_FILTER_SPEC = register_filter_spec("user", FilterSpec( + model=Dojo_User, + filters={ + "id__in": filter_field("id", "in", "number"), + "username__icontains": filter_field("username", "icontains", "char"), + "first_name__icontains": filter_field("first_name", "icontains", "char"), + "last_name__icontains": filter_field("last_name", "icontains", "char"), + "email__icontains": filter_field("email", "icontains", "char"), + "is_active": filter_field("is_active", "exact", "bool"), + "is_superuser": filter_field("is_superuser", "exact", "bool"), + "date_joined__gte": filter_field("date_joined", "gte", "datetime"), + "date_joined__lte": filter_field("date_joined", "lte", "datetime"), + "last_login__gte": filter_field("last_login", "gte", "datetime"), + "last_login__lte": filter_field("last_login", "lte", "datetime"), + }, + orderings={ + "id": "id", + "username": "username", + "date_joined": "date_joined", + "last_login": "last_login", + }, + search_fields=["username", "first_name", "last_name", "email"], +)) + + +class UserListResponse(Schema): + + """OpenAPI documentation of the list envelope (I1); runtime serialization is manual.""" + + count: int + next: str | None + previous: str | None + results: list[UserSlim] + meta: dict | None = None + + +def _base_queryset(request: HttpRequest, queryset_hook: Callable | None) -> QuerySet: + # Mirror the v2 UsersViewSet ``view_user`` gate (§6 OS3 "read + self"; conservative per §10.2): + # - holders of the ``view_user`` configuration permission get the RBAC-scoped queryset + # (superusers/staff -> all; a non-staff holder -> the collaborator subset, which is <= v2's + # "return all users" exposure); + # - everyone else sees only themselves, so a plain user lists exactly their own record and + # ``GET /users/{own id}`` always resolves (never a 404 on your own record). + if user_has_configuration_permission(request.user, "auth.view_user"): + qs = get_authorized_users("view", user=request.user) + else: + qs = Dojo_User.objects.filter(pk=request.user.pk) + if queryset_hook is not None: + qs = queryset_hook(qs, request) + return qs + + +def _require_config_permission(request: HttpRequest, codename: str) -> None: + if not user_has_configuration_permission(request.user, codename): + raise PermissionDenied + + +def _enforce_superuser_staff_rules(request: HttpRequest, *, current: Dojo_User | None, data: dict) -> None: + """Port ``UserSerializer.validate()`` superuser/staff gating (§12 / D7 reconciliation).""" + acting_is_superuser = bool(getattr(request.user, "is_superuser", False)) + instance_is_superuser = bool(current.is_superuser) if current is not None else False + data_is_superuser = data.get("is_superuser", instance_is_superuser) + if not acting_is_superuser and (instance_is_superuser or data_is_superuser): + raise validation_problem({"is_superuser": ["Only superusers are allowed to add or edit superusers."]}) + + instance_is_staff = bool(current.is_staff) if current is not None else False + data_is_staff = data.get("is_staff", instance_is_staff) + if not acting_is_superuser and data_is_staff != instance_is_staff: + raise validation_problem({"is_staff": ["Only superusers are allowed to add or edit staff users."]}) + + +def _validate_password_or_400(password: str) -> None: + try: + validate_password(password) + except DjangoValidationError as exc: + raise validation_problem({"password": list(exc.messages)}) from exc + + +def build_users_router( + *, + schema: type = UserSlim, + detail_schema: type = UserDetail, + filter_spec: FilterSpec = USER_FILTER_SPEC, + queryset_hook: Callable | None = None, + auth=NOT_SET, +) -> Router: + """Build the users router (I5).""" + router = Router(tags=["users"], auth=auth) + + @router.get("/users", response=UserListResponse, url_name="users_list") + def list_users(request: HttpRequest): + filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) + + _, select_related, prefetch = plan(schema, request.GET.get("expand")) + page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + page_qs = plan_queryset(page_qs, select_related, prefetch) + + fields = parse_fields(request.GET.get("fields"), set(schema.model_fields)) + + def serialize_row(obj: object) -> dict: + return apply_fields(serialize(obj, schema, {}), fields) + + envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) + include_meta = apply_includes(request, filtered, allowed=set()) + if include_meta: + envelope.setdefault("meta", {}).update(include_meta) + return json_response(envelope) + + @router.get("/users/{int:user_id}", response=detail_schema, url_name="users_detail") + def get_user(request: HttpRequest, user_id: int): + obj = _base_queryset(request, queryset_hook).filter(pk=user_id).first() + if obj is None: + msg = f"User {user_id} not found" + raise not_found_problem(msg) + fields = parse_fields(request.GET.get("fields"), set(detail_schema.model_fields)) + return json_response(apply_fields(serialize(obj, detail_schema, {}), fields)) + + @router.post("/users", response=detail_schema, url_name="users_create") + def create_user(request: HttpRequest, payload: UserWrite): + _require_config_permission(request, "auth.add_user") + data = payload.dict() + password = data.pop("password") + _enforce_superuser_staff_rules(request, current=None, data=data) + if password: + _validate_password_or_400(password) + elif settings.REQUIRE_PASSWORD_ON_USER: + raise validation_problem({"password": ["Passwords must be supplied for new users"]}) + if Dojo_User.objects.filter(username=data["username"]).exists(): + raise validation_problem({"username": ["A user with that username already exists."]}) + + instance = Dojo_User(**data) + if password: + instance.set_password(password) + else: + instance.set_unusable_password() + instance.save() + return json_response(serialize(instance, detail_schema, {}), status=201) + + @router.patch("/users/{int:user_id}", response=detail_schema, url_name="users_update") + def update_user(request: HttpRequest, user_id: int, payload: UserUpdate): + # Resolve against the authorized view queryset first (404 for unknown-or-unauthorized). + instance = _base_queryset(request, queryset_hook).filter(pk=user_id).first() + if instance is None: + msg = f"User {user_id} not found" + raise not_found_problem(msg) + _require_config_permission(request, "auth.change_user") + data = payload.dict(exclude_unset=True) + if "password" in data: + raise validation_problem({"password": ["Update of password though API is not allowed"]}) + _enforce_superuser_staff_rules(request, current=instance, data=data) + for key, value in data.items(): + setattr(instance, key, value) + instance.save() + return json_response(serialize(instance, detail_schema, {})) + + @router.delete("/users/{int:user_id}", url_name="users_delete") + def delete_user(request: HttpRequest, user_id: int): + instance = _base_queryset(request, queryset_hook).filter(pk=user_id).first() + if instance is None: + msg = f"User {user_id} not found" + raise not_found_problem(msg) + _require_config_permission(request, "auth.delete_user") + if request.user.pk == instance.pk: + # Mirror v2 UsersViewSet.destroy: users may not delete themselves. + raise validation_problem({"non_field_errors": ["Users may not delete themselves"]}) + instance.delete() + response = HttpResponse(status=204) + response["X-API-Status"] = settings.API_V3_STATUS + return response + + return router diff --git a/dojo/user/api_v3/schemas.py b/dojo/user/api_v3/schemas.py new file mode 100644 index 00000000000..a1aadca2669 --- /dev/null +++ b/dojo/user/api_v3/schemas.py @@ -0,0 +1,81 @@ +""" +User (Dojo_User) response + write schemas for API v3 (§4.5, §4.11, OS3a). + +``UserSlim`` is the canonical user slim (relocated here from ``dojo/finding/api_v3`` where OS1 first +defined it -- see §12; the finding module now re-exports this copy). Every field in §4.5's UserSlim +was verified against the model. ``UserDetail`` adds ``is_staff`` and ``date_joined`` so the +superuser/staff write rules operate over documented read fields. + +Write schemas mirror the v2 ``UserSerializer``: ``username``/``email`` required on create, +``password`` write-only. The superuser/staff/self-delete/password-on-PATCH rules are enforced in +the route (ported from ``UserSerializer.validate()``). Server-managed fields (``id``, +``date_joined``, ``last_login``) are never writable; unknown fields are rejected (``extra="forbid"``). +``configuration_permissions`` is intentionally out of the alpha write surface (see §12). +""" +from __future__ import annotations + +from datetime import datetime # noqa: TC003 -- runtime import: pydantic resolves the schema field types +from typing import TYPE_CHECKING, ClassVar + +from ninja import Schema + +from dojo.models import Dojo_User + +if TYPE_CHECKING: + from dojo.api_v3.expand import ExpandRel + + +class UserSlim(Schema): + django_model: ClassVar = Dojo_User + SELECT_RELATED: ClassVar[tuple] = () + PREFETCH_RELATED: ClassVar[tuple] = () + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + username: str + first_name: str + last_name: str + email: str + is_active: bool + is_superuser: bool + last_login: datetime | None + + +class UserDetail(UserSlim): + + """Slim + documented heavier read fields (§4.5).""" + + is_staff: bool + date_joined: datetime | None + + +class UserWrite(Schema): + + """Create payload (POST). ``username``/``email`` required, mirroring the v2 serializer (§6 OS3).""" + + model_config = {"extra": "forbid"} + + username: str + email: str + first_name: str = "" + last_name: str = "" + is_active: bool = True + is_staff: bool = False + is_superuser: bool = False + password: str | None = None + + +class UserUpdate(Schema): + + """Partial update payload (PATCH). Every field optional; ``password`` is rejected here (§12).""" + + model_config = {"extra": "forbid"} + + username: str | None = None + email: str | None = None + first_name: str | None = None + last_name: str | None = None + is_active: bool | None = None + is_staff: bool | None = None + is_superuser: bool | None = None + password: str | None = None diff --git a/unittests/api_v3/snapshots/filters.json b/unittests/api_v3/snapshots/filters.json index 028f14d271a..85a08ca113b 100644 --- a/unittests/api_v3/snapshots/filters.json +++ b/unittests/api_v3/snapshots/filters.json @@ -41,5 +41,78 @@ "description", "title" ] + }, + "product": { + "model": "Product", + "orderings": [ + "created", + "id", + "name", + "updated" + ], + "params": [ + "created__gte", + "created__lte", + "id__in", + "name__icontains", + "product_type", + "product_type__in", + "updated__gte", + "updated__lte" + ], + "search_fields": [ + "description", + "name" + ] + }, + "product_type": { + "model": "Product_Type", + "orderings": [ + "created", + "id", + "name", + "updated" + ], + "params": [ + "created__gte", + "created__lte", + "id__in", + "name__icontains", + "updated__gte", + "updated__lte" + ], + "search_fields": [ + "description", + "name" + ] + }, + "user": { + "model": "Dojo_User", + "orderings": [ + "date_joined", + "id", + "last_login", + "username" + ], + "params": [ + "date_joined__gte", + "date_joined__lte", + "email__icontains", + "first_name__icontains", + "id__in", + "is_active", + "is_superuser", + "last_login__gte", + "last_login__lte", + "last_name__icontains", + "username__icontains" + ], + "search_fields": [ + "email", + "first_name", + "last_name", + "username" + ] } } + diff --git a/unittests/api_v3/test_apiv3_product_types.py b/unittests/api_v3/test_apiv3_product_types.py new file mode 100644 index 00000000000..c0e42b11ee8 --- /dev/null +++ b/unittests/api_v3/test_apiv3_product_types.py @@ -0,0 +1,159 @@ +"""Product_Type CRUD + RBAC + contract tests for API v3 (OS3a).""" +from __future__ import annotations + +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from dojo.models import Dojo_User, Product_Type, User + +from .base import ApiV3TestCase + +_SLIM_KEYS = {"id", "name", "description", "critical_product", "key_product", "created", "updated"} + + +class TestApiV3ProductTypesRead(ApiV3TestCase): + + def test_list_envelope_and_slim_shape(self): + body = self.get_json("product_types") + self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) + self.assertGreater(body["count"], 0) + self.assertEqual(_SLIM_KEYS, set(body["results"][0])) + + def test_detail_shape(self): + pt = Product_Type.objects.first() + detail = self.get_json(f"product_types/{pt.id}") + self.assertEqual(pt.id, detail["id"]) + self.assertEqual(pt.name, detail["name"]) + + def test_detail_unknown_is_404_problem(self): + response = self.client.get(self.v3_url("product_types/99999999")) + self.assertEqual(404, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_fields_projection(self): + row = self.get_json("product_types", data={"fields": "id,name"})["results"][0] + self.assertEqual({"id", "name"}, set(row)) + + def test_unknown_field_is_400(self): + self.get_json("product_types", data={"fields": "id,nope"}, expected=400) + + +class TestApiV3ProductTypesFilters(ApiV3TestCase): + + def test_filter_name_icontains(self): + pt = Product_Type.objects.first() + body = self.get_json("product_types", data={"name__icontains": pt.name[:4]}) + self.assertGreater(body["count"], 0) + + def test_ordering_by_name(self): + Product_Type.objects.create(name="ZZZ v3 last type") + Product_Type.objects.create(name="AAA v3 first type") + names = [r["name"] for r in self.get_json("product_types", data={"o": "name", "limit": 250})["results"]] + self.assertEqual(names, sorted(names)) + + def test_unknown_filter_param_is_400(self): + self.get_json("product_types", data={"not_a_filter": "x"}, expected=400) + + def test_unknown_ordering_is_400(self): + self.get_json("product_types", data={"o": "nope"}, expected=400) + + +class TestApiV3ProductTypesPagination(ApiV3TestCase): + + def test_limit_and_next(self): + for i in range(4): + Product_Type.objects.create(name=f"v3 page pt {i}") + body = self.get_json("product_types", data={"limit": 2}) + self.assertLessEqual(len(body["results"]), 2) + self.assertIsNotNone(body["next"]) + self.assertIsNone(body["previous"]) + + +class TestApiV3ProductTypesQueryCount(ApiV3TestCase): + + def _query_count(self, params: dict) -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url("product_types"), params) + self.assertEqual(200, response.status_code, response.content[:500]) + return len(ctx.captured_queries) + + def test_query_count_is_independent_of_row_count(self): + Product_Type.objects.bulk_create([Product_Type(name=f"qcount pt {i}") for i in range(10)]) + first = self._query_count({"limit": 250}) + Product_Type.objects.bulk_create([Product_Type(name=f"qcount pt b{i}") for i in range(90)]) + second = self._query_count({"limit": 250}) + self.assertEqual(first, second, f"query count grew with rows: {first} -> {second}") + + +class TestApiV3ProductTypesWrite(ApiV3TestCase): + + def test_create_happy_path(self): + response = self.client.post( + self.v3_url("product_types"), + {"name": "v3 created type", "description": "made by v3", "critical_product": True}, + format="json", + ) + self.assertEqual(201, response.status_code, response.content[:500]) + body = response.json() + self.assertEqual("v3 created type", body["name"]) + self.assertTrue(body["critical_product"]) + self.assertTrue(Product_Type.objects.filter(name="v3 created type").exists()) + + def test_create_missing_required_name_is_400(self): + response = self.client.post(self.v3_url("product_types"), {"description": "no name"}, format="json") + self.assertEqual(400, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_create_unknown_field_is_400(self): + response = self.client.post( + self.v3_url("product_types"), {"name": "x", "bogus_field": 1}, format="json", + ) + self.assertEqual(400, response.status_code) + + def test_patch_partial_update(self): + pt = Product_Type.objects.create(name="v3 patch me", description="old") + response = self.client.patch( + self.v3_url(f"product_types/{pt.id}"), {"description": "new"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.assertEqual("new", response.json()["description"]) + pt.refresh_from_db() + self.assertEqual("new", pt.description) + self.assertEqual("v3 patch me", pt.name) # untouched + + def test_delete(self): + pt = Product_Type.objects.create(name="v3 delete me") + response = self.client.delete(self.v3_url(f"product_types/{pt.id}")) + self.assertEqual(204, response.status_code) + self.assertFalse(Product_Type.objects.filter(pk=pt.id).exists()) + + +class TestApiV3ProductTypesRbac(ApiV3TestCase): + + def setUp(self): + super().setUp() + self.limited = User.objects.create_user(username="v3_pt_limited", password="x") # noqa: S106 + # authorized_users M2M targets Dojo_User (the proxy), so the member must be a Dojo_User. + self.member = Dojo_User.objects.create_user(username="v3_pt_member", password="x") # noqa: S106 + self.pt = Product_Type.objects.first() + self.pt.authorized_users.add(self.member) + + def test_unauthorized_read_is_404(self): + client = self.token_client(user=self.limited) + # Limited user has no product-type membership -> empty list, detail 404. + self.assertEqual(0, self.get_json("product_types", client=client)["count"]) + self.get_json(f"product_types/{self.pt.id}", client=client, expected=404) + + def test_create_without_global_add_is_403(self): + client = self.token_client(user=self.limited) + response = client.post(self.v3_url("product_types"), {"name": "v3 nope"}, format="json") + self.assertEqual(403, response.status_code, response.content[:300]) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_member_can_view_but_delete_is_403(self): + client = self.token_client(user=self.member) + # Member can view (200) ... + self.get_json(f"product_types/{self.pt.id}", client=client) + # ... but delete is staff-only for non-staff members (legacy model) -> 403 (not 404). + response = client.delete(self.v3_url(f"product_types/{self.pt.id}")) + self.assertEqual(403, response.status_code, response.content[:300]) diff --git a/unittests/api_v3/test_apiv3_products.py b/unittests/api_v3/test_apiv3_products.py new file mode 100644 index 00000000000..3e5dc41374f --- /dev/null +++ b/unittests/api_v3/test_apiv3_products.py @@ -0,0 +1,190 @@ +"""Product CRUD + RBAC + contract tests for API v3 (OS3a).""" +from __future__ import annotations + +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from dojo.models import Dojo_User, Product, Product_Type, User + +from .base import ApiV3TestCase + +_SLIM_KEYS = {"id", "name", "description", "product_type", "lifecycle", "tags", "created", "updated"} + + +class TestApiV3ProductsRead(ApiV3TestCase): + + def test_list_envelope_and_slim_shape(self): + body = self.get_json("products") + self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) + self.assertGreater(body["count"], 0) + row = body["results"][0] + self.assertEqual(_SLIM_KEYS, set(row)) + self.assertEqual({"id", "name"}, set(row["product_type"])) + self.assertIsInstance(row["tags"], list) + + def test_detail_adds_heavy_fields(self): + product = Product.objects.first() + detail = self.get_json(f"products/{product.id}") + for key in ("business_criticality", "platform", "origin", "product_manager", "technical_contact", "team_manager"): + self.assertIn(key, detail) + + def test_detail_unknown_is_404_problem(self): + response = self.client.get(self.v3_url("products/99999999")) + self.assertEqual(404, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_expand_product_type_inlines_slim(self): + row = self.get_json("products", data={"expand": "product_type"})["results"][0] + # ref swapped for the product_type slim (carries description, timestamps, not just id/name). + self.assertIn("description", row["product_type"]) + self.assertIn("critical_product", row["product_type"]) + + def test_expand_unknown_relation_is_400(self): + self.get_json("products", data={"expand": "not_a_relation"}, expected=400) + + +class TestApiV3ProductsFilters(ApiV3TestCase): + + def test_filter_product_type(self): + pt_id = Product.objects.first().prod_type_id + body = self.get_json("products", data={"product_type": pt_id, "limit": 250}) + self.assertGreater(body["count"], 0) + for row in body["results"]: + self.assertEqual(pt_id, row["product_type"]["id"]) + + def test_filter_name_icontains(self): + name = Product.objects.first().name + body = self.get_json("products", data={"name__icontains": name[:5]}) + self.assertGreater(body["count"], 0) + + def test_ordering_by_name(self): + names = [r["name"] for r in self.get_json("products", data={"o": "name", "limit": 250})["results"]] + self.assertEqual(names, sorted(names)) + + def test_unknown_filter_param_is_400(self): + self.get_json("products", data={"not_a_filter": "x"}, expected=400) + + +class TestApiV3ProductsPagination(ApiV3TestCase): + + def test_limit_next_previous(self): + body = self.get_json("products", data={"limit": 2, "offset": 2}) + self.assertLessEqual(len(body["results"]), 2) + self.assertIsNotNone(body["previous"]) + + +class TestApiV3ProductsQueryCount(ApiV3TestCase): + + def _bulk(self, count: int, start: int) -> None: + pt = Product_Type.objects.first() + Product.objects.bulk_create([ + Product(name=f"qcount product {start + i}", description="x", prod_type=pt, sla_configuration_id=1) + for i in range(count) + ]) + + def _query_count(self, params: dict) -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url("products"), params) + self.assertEqual(200, response.status_code, response.content[:500]) + return len(ctx.captured_queries) + + def test_query_count_is_independent_of_row_count(self): + self._bulk(10, 0) + first = self._query_count({"limit": 250}) + first_expand = self._query_count({"limit": 250, "expand": "product_type"}) + self._bulk(90, 100) + second = self._query_count({"limit": 250}) + second_expand = self._query_count({"limit": 250, "expand": "product_type"}) + self.assertEqual(first, second, f"query count grew (no expand): {first} -> {second}") + self.assertEqual(first_expand, second_expand, f"query count grew (expand): {first_expand} -> {second_expand}") + + +class TestApiV3ProductsWrite(ApiV3TestCase): + + def test_create_happy_path(self): + pt = Product_Type.objects.first() + response = self.client.post( + self.v3_url("products"), + {"name": "v3 created product", "description": "made by v3", "prod_type": pt.id, + "lifecycle": "production", "tags": ["pci", "v3"]}, + format="json", + ) + self.assertEqual(201, response.status_code, response.content[:500]) + body = response.json() + self.assertEqual("v3 created product", body["name"]) + self.assertEqual(pt.id, body["product_type"]["id"]) + created = Product.objects.get(name="v3 created product") + self.assertEqual("production", created.lifecycle) + self.assertEqual({"pci", "v3"}, {t.name for t in created.tags.all()}) + + def test_create_missing_required_is_400(self): + response = self.client.post(self.v3_url("products"), {"name": "no prodtype"}, format="json") + self.assertEqual(400, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_create_unknown_field_is_400(self): + pt = Product_Type.objects.first() + response = self.client.post( + self.v3_url("products"), + {"name": "x", "description": "y", "prod_type": pt.id, "bogus": 1}, + format="json", + ) + self.assertEqual(400, response.status_code) + + def test_create_nonexistent_prod_type_is_404(self): + response = self.client.post( + self.v3_url("products"), + {"name": "orphan", "description": "y", "prod_type": 99999999}, + format="json", + ) + self.assertEqual(404, response.status_code) + + def test_patch_partial_update(self): + pt = Product_Type.objects.first() + product = Product.objects.create(name="v3 patch product", description="old", prod_type=pt, sla_configuration_id=1) + response = self.client.patch( + self.v3_url(f"products/{product.id}"), {"description": "new"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + product.refresh_from_db() + self.assertEqual("new", product.description) + self.assertEqual("v3 patch product", product.name) + + def test_delete(self): + pt = Product_Type.objects.first() + product = Product.objects.create(name="v3 delete product", description="d", prod_type=pt, sla_configuration_id=1) + response = self.client.delete(self.v3_url(f"products/{product.id}")) + self.assertEqual(204, response.status_code) + self.assertFalse(Product.objects.filter(pk=product.id).exists()) + + +class TestApiV3ProductsRbac(ApiV3TestCase): + + def setUp(self): + super().setUp() + self.limited = User.objects.create_user(username="v3_prod_limited", password="x") # noqa: S106 + # authorized_users M2M targets Dojo_User (the proxy), so the member must be a Dojo_User. + self.member = Dojo_User.objects.create_user(username="v3_prod_member", password="x") # noqa: S106 + self.product = Product.objects.first() + self.product.authorized_users.add(self.member) + + def test_unauthorized_read_is_404(self): + client = self.token_client(user=self.limited) + self.assertEqual(0, self.get_json("products", client=client)["count"]) + self.get_json(f"products/{self.product.id}", client=client, expected=404) + + def test_create_without_prod_type_add_is_403(self): + client = self.token_client(user=self.limited) + response = client.post( + self.v3_url("products"), + {"name": "v3 rbac nope", "description": "d", "prod_type": self.product.prod_type_id}, + format="json", + ) + self.assertEqual(403, response.status_code, response.content[:300]) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_member_can_view_but_delete_is_403(self): + client = self.token_client(user=self.member) + self.get_json(f"products/{self.product.id}", client=client) + response = client.delete(self.v3_url(f"products/{self.product.id}")) + self.assertEqual(403, response.status_code, response.content[:300]) diff --git a/unittests/api_v3/test_apiv3_users.py b/unittests/api_v3/test_apiv3_users.py new file mode 100644 index 00000000000..7924656db5a --- /dev/null +++ b/unittests/api_v3/test_apiv3_users.py @@ -0,0 +1,207 @@ +"""User (Dojo_User) CRUD + RBAC + contract tests for API v3 (OS3a).""" +from __future__ import annotations + +from django.contrib.auth.models import Permission +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from dojo.models import Dojo_User, User + +from .base import ApiV3TestCase + +_SLIM_KEYS = {"id", "username", "first_name", "last_name", "email", "is_active", "is_superuser", "last_login"} +_PASSWORD = "Zx9!qWtvB2mnLP4k" + + +class TestApiV3UsersRead(ApiV3TestCase): + + def test_list_envelope_and_slim_shape(self): + body = self.get_json("users") + self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) + self.assertGreater(body["count"], 0) + self.assertEqual(_SLIM_KEYS, set(body["results"][0])) + + def test_detail_adds_heavy_fields(self): + detail = self.get_json(f"users/{self.admin.id}") + self.assertIn("is_staff", detail) + self.assertIn("date_joined", detail) + self.assertEqual("admin", detail["username"]) + + def test_detail_unknown_is_404_problem(self): + response = self.client.get(self.v3_url("users/99999999")) + self.assertEqual(404, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_fields_projection(self): + row = self.get_json("users", data={"fields": "id,username"})["results"][0] + self.assertEqual({"id", "username"}, set(row)) + + +class TestApiV3UsersFilters(ApiV3TestCase): + + def test_filter_username_icontains(self): + body = self.get_json("users", data={"username__icontains": "admin"}) + self.assertGreater(body["count"], 0) + + def test_filter_is_superuser(self): + body = self.get_json("users", data={"is_superuser": "true", "limit": 250}) + for row in body["results"]: + self.assertTrue(row["is_superuser"]) + + def test_ordering_by_username(self): + User.objects.create_user(username="v3_aaa_user", password=_PASSWORD) + User.objects.create_user(username="v3_zzz_user", password=_PASSWORD) + names = [r["username"] for r in self.get_json("users", data={"o": "username", "limit": 250})["results"]] + self.assertEqual(names, sorted(names)) + + def test_unknown_filter_param_is_400(self): + self.get_json("users", data={"not_a_filter": "x"}, expected=400) + + +class TestApiV3UsersQueryCount(ApiV3TestCase): + + def _query_count(self, params: dict) -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url("users"), params) + self.assertEqual(200, response.status_code, response.content[:500]) + return len(ctx.captured_queries) + + def test_query_count_is_independent_of_row_count(self): + Dojo_User.objects.bulk_create([Dojo_User(username=f"qcount_user_{i}") for i in range(10)]) + first = self._query_count({"limit": 250}) + Dojo_User.objects.bulk_create([Dojo_User(username=f"qcount_user_b{i}") for i in range(90)]) + second = self._query_count({"limit": 250}) + self.assertEqual(first, second, f"query count grew with rows: {first} -> {second}") + + +class TestApiV3UsersWrite(ApiV3TestCase): + + def test_create_happy_path(self): + response = self.client.post( + self.v3_url("users"), + {"username": "v3_newuser", "email": "v3new@example.com", "first_name": "New", + "last_name": "User", "password": _PASSWORD}, + format="json", + ) + self.assertEqual(201, response.status_code, response.content[:500]) + body = response.json() + self.assertEqual("v3_newuser", body["username"]) + self.assertNotIn("password", body) # write-only, never echoed + created = Dojo_User.objects.get(username="v3_newuser") + self.assertTrue(created.check_password(_PASSWORD)) + + def test_create_missing_email_is_400(self): + response = self.client.post( + self.v3_url("users"), {"username": "v3_noemail", "password": _PASSWORD}, format="json", + ) + self.assertEqual(400, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_create_missing_password_is_400_when_required(self): + # REQUIRE_PASSWORD_ON_USER defaults True -> password is mandatory on create (mirrors v2). + response = self.client.post( + self.v3_url("users"), {"username": "v3_nopass", "email": "np@example.com"}, format="json", + ) + self.assertEqual(400, response.status_code) + + def test_create_unknown_field_is_400(self): + response = self.client.post( + self.v3_url("users"), + {"username": "v3_extra", "email": "e@example.com", "password": _PASSWORD, "bogus": 1}, + format="json", + ) + self.assertEqual(400, response.status_code) + + def test_create_superuser_as_superuser_ok(self): + response = self.client.post( + self.v3_url("users"), + {"username": "v3_super", "email": "s@example.com", "password": _PASSWORD, "is_superuser": True}, + format="json", + ) + self.assertEqual(201, response.status_code, response.content[:500]) + self.assertTrue(Dojo_User.objects.get(username="v3_super").is_superuser) + + def test_patch_partial_update(self): + user = User.objects.create_user(username="v3_patch_user", password=_PASSWORD) + response = self.client.patch( + self.v3_url(f"users/{user.id}"), {"first_name": "Patched"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + user.refresh_from_db() + self.assertEqual("Patched", user.first_name) + + def test_patch_password_is_rejected(self): + user = User.objects.create_user(username="v3_pwuser", password=_PASSWORD) + response = self.client.patch( + self.v3_url(f"users/{user.id}"), {"password": _PASSWORD}, format="json", + ) + self.assertEqual(400, response.status_code) + + def test_delete_other_user(self): + user = User.objects.create_user(username="v3_del_user", password=_PASSWORD) + response = self.client.delete(self.v3_url(f"users/{user.id}")) + self.assertEqual(204, response.status_code) + self.assertFalse(Dojo_User.objects.filter(pk=user.id).exists()) + + def test_delete_self_is_rejected(self): + response = self.client.delete(self.v3_url(f"users/{self.admin.id}")) + self.assertEqual(400, response.status_code) + self.assertTrue(Dojo_User.objects.filter(pk=self.admin.id).exists()) + + +class TestApiV3UsersRbac(ApiV3TestCase): + + def setUp(self): + super().setUp() + self.limited = User.objects.create_user(username="v3_user_limited", password=_PASSWORD) + + # --- plain user (no view_user perm): read is SELF-ONLY, self-read guaranteed --------------- + def test_plain_user_lists_only_self(self): + client = self.token_client(user=self.limited) + body = self.get_json("users", client=client) + self.assertEqual(1, body["count"]) + self.assertEqual([self.limited.id], [r["id"] for r in body["results"]]) + + def test_plain_user_can_always_read_own_record(self): + client = self.token_client(user=self.limited) + detail = self.get_json(f"users/{self.limited.id}", client=client) + self.assertEqual(self.limited.id, detail["id"]) + + def test_plain_user_cannot_read_other_record(self): + client = self.token_client(user=self.limited) + self.get_json(f"users/{self.admin.id}", client=client, expected=404) + + # --- view_user holder: RBAC-scoped visibility (<= v2's "all users" exposure) --------------- + def test_view_user_holder_gets_scoped_visibility(self): + self.limited.user_permissions.add( + Permission.objects.get(codename="view_user", content_type__app_label="auth", content_type__model="user"), + ) + client = self.token_client(user=self.limited) + # The scoped queryset (get_authorized_users) always surfaces superusers, so admin is visible + # to a view_user holder where a plain user (above) gets 404. + self.get_json(f"users/{self.admin.id}", client=client) + + # --- superuser: sees all ------------------------------------------------------------------- + def test_superuser_sees_all(self): + other = User.objects.create_user(username="v3_user_other", password=_PASSWORD) + ids = [r["id"] for r in self.get_json("users", data={"limit": 250})["results"]] + self.assertIn(other.id, ids) + self.assertIn(self.admin.id, ids) + + # --- writes stay admin/superuser-only (unchanged) ----------------------------------------- + def test_non_superuser_cannot_create(self): + client = self.token_client(user=self.limited) + response = client.post( + self.v3_url("users"), + {"username": "v3_denied", "email": "d@example.com", "password": _PASSWORD}, + format="json", + ) + self.assertEqual(403, response.status_code, response.content[:300]) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_non_superuser_cannot_delete_other(self): + other = User.objects.create_user(username="v3_other", password=_PASSWORD) + client = self.token_client(user=self.limited) + response = client.delete(self.v3_url(f"users/{other.id}")) + # Self-only queryset -> `other` is invisible -> 404 (never reaches the delete perm check). + self.assertEqual(404, response.status_code, response.content[:300]) From d8586fede8ef620f7ebd1472a60afc649aa936f4 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 13:48:33 +0200 Subject: [PATCH 04/37] feat(api-v3): OS3b - engagement/test CRUD + finding writes via service layer - dojo/finding/services.py (D7 flagship): create/update/delete_finding extracted by reconciling FindingSerializer.update()/validate() AND the UI edit_finding flows; 17-row divergence table in the phase report, serializer semantics canonical, UI-only behaviors deferred to CONV2 - POST/PATCH/DELETE /findings: thin routes (authorize -> service -> serialize), 404-then-403 permission ladder, JIRA mocked in tests (push failure -> problem+json 400) - engagement + test resources: CRUD, canonical slims relocated out of the finding module, deletion mirrors v2 exactly (no Endpoint.allow_endpoint_init wrapper, unlike products - per v2) - filter snapshot extended additively 197 tests green (129 prior + 68 new). --- dojo/api_v3/api.py | 4 + dojo/engagement/api_v3/__init__.py | 0 dojo/engagement/api_v3/routes.py | 272 +++++++++++++++++ dojo/engagement/api_v3/schemas.py | 156 ++++++++++ dojo/finding/api_v3/routes.py | 100 ++++++- dojo/finding/api_v3/schemas.py | 267 ++++++++--------- dojo/finding/services.py | 275 +++++++++++++++++ dojo/test/api_v3/__init__.py | 0 dojo/test/api_v3/routes.py | 279 ++++++++++++++++++ dojo/test/api_v3/schemas.py | 175 +++++++++++ unittests/api_v3/snapshots/filters.json | 67 ++++- unittests/api_v3/test_apiv3_engagements.py | 234 +++++++++++++++ unittests/api_v3/test_apiv3_finding_writes.py | 230 +++++++++++++++ unittests/api_v3/test_apiv3_tests.py | 239 +++++++++++++++ 14 files changed, 2142 insertions(+), 156 deletions(-) create mode 100644 dojo/engagement/api_v3/__init__.py create mode 100644 dojo/engagement/api_v3/routes.py create mode 100644 dojo/engagement/api_v3/schemas.py create mode 100644 dojo/finding/services.py create mode 100644 dojo/test/api_v3/__init__.py create mode 100644 dojo/test/api_v3/routes.py create mode 100644 dojo/test/api_v3/schemas.py create mode 100644 unittests/api_v3/test_apiv3_engagements.py create mode 100644 unittests/api_v3/test_apiv3_finding_writes.py create mode 100644 unittests/api_v3/test_apiv3_tests.py diff --git a/dojo/api_v3/api.py b/dojo/api_v3/api.py index 05e3b66d6a9..48a86476c40 100644 --- a/dojo/api_v3/api.py +++ b/dojo/api_v3/api.py @@ -56,14 +56,18 @@ def build_api() -> NinjaAPI: # module top) so the kernel submodules never depend on route modules -- dependency direction # stays "resources import kernel", never the reverse. from dojo.api_v3.import_routes import build_import_router # noqa: PLC0415 + from dojo.engagement.api_v3.routes import build_engagements_router # noqa: PLC0415 from dojo.finding.api_v3.routes import build_findings_router # noqa: PLC0415 from dojo.product.api_v3.routes import build_products_router # noqa: PLC0415 from dojo.product_type.api_v3.routes import build_product_types_router # noqa: PLC0415 + from dojo.test.api_v3.routes import build_tests_router # noqa: PLC0415 from dojo.user.api_v3.routes import build_users_router # noqa: PLC0415 api.add_router("", build_findings_router()) api.add_router("", build_product_types_router()) api.add_router("", build_products_router()) + api.add_router("", build_engagements_router()) + api.add_router("", build_tests_router()) api.add_router("", build_users_router()) api.add_router("", build_import_router()) return api diff --git a/dojo/engagement/api_v3/__init__.py b/dojo/engagement/api_v3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/engagement/api_v3/routes.py b/dojo/engagement/api_v3/routes.py new file mode 100644 index 00000000000..601dc2e5ce3 --- /dev/null +++ b/dojo/engagement/api_v3/routes.py @@ -0,0 +1,272 @@ +""" +Engagement CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3b). + +``build_engagements_router()`` is a router factory (I5), same shape as ``build_products_router``. +Routes are thin (I6): authorize -> filter -> plan queryset -> serialize -> shape. RBAC flows only +through ``get_authorized_engagements`` for reads (I8) and the v2 ``user_has_permission`` semantics +for writes, mirroring the v2 ``UserHasEngagementPermission`` permission class exactly: + +- create (POST): ``add`` permission on the target ``product`` referenced in the payload + (404 if the product doesn't exist, 403 if unauthorized -- mirrors + ``check_post_permission(request, Product, "product", "add")``) +- update (PATCH): object ``edit`` permission, plus ``add`` on a *reassigned* ``product`` + (mirrors ``check_update_permission(request, obj, "add", "product")``) +- delete (DELETE): object ``delete`` permission (staff-only for non-staff members, legacy model) +- read: object ``view`` via the authorized queryset (404 for unknown-or-unauthorized) + +Deletion mirrors the v2 ``EngagementViewSet.destroy`` exactly: async delete when +``ASYNC_OBJECT_DELETE`` is set, else a plain synchronous ``instance.delete()`` -- note there is **no** +``Endpoint.allow_endpoint_init()`` wrapper here (unlike product/product_type destroy), mirroring v2 +(§12). Relations are referenced by integer id (§4.11). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.core.exceptions import ValidationError as DjangoValidationError +from django.http import HttpResponse +from ninja import Router, Schema +from ninja.constants import NOT_SET + +from dojo.api_v3.errors import json_response, not_found_problem, validation_problem +from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.filtering import ( + FilterSpec, + apply_filters, + filter_field, + register_filter_spec, +) +from dojo.api_v3.include import apply_includes +from dojo.api_v3.pagination import paginate +from dojo.authorization.authorization import user_has_permission +from dojo.authorization.roles_permissions import Permissions +from dojo.engagement.api_v3.schemas import ( + EngagementDetail, + EngagementSlim, + EngagementUpdate, + EngagementWrite, +) +from dojo.engagement.queries import get_authorized_engagements +from dojo.models import Dojo_User, Engagement, Product +from dojo.utils import async_delete, get_object_or_none, get_setting + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import QuerySet + from django.http import HttpRequest + +# --- Engagement filter vocabulary (§4.9, minimal set) ----------------------------------------- + +ENGAGEMENT_FILTER_SPEC = register_filter_spec("engagement", FilterSpec( + model=Engagement, + filters={ + "id__in": filter_field("id", "in", "number"), + "name__icontains": filter_field("name", "icontains", "char"), + "product": filter_field("product", "exact", "number"), + "product__in": filter_field("product", "in", "number"), + "product_type": filter_field("product__prod_type", "exact", "number"), + "lead": filter_field("lead", "exact", "number"), + "status": filter_field("status", "exact", "char"), + "engagement_type": filter_field("engagement_type", "exact", "char"), + "target_start__gte": filter_field("target_start", "gte", "date"), + "target_start__lte": filter_field("target_start", "lte", "date"), + "target_end__gte": filter_field("target_end", "gte", "date"), + "target_end__lte": filter_field("target_end", "lte", "date"), + "created__gte": filter_field("created", "gte", "datetime"), + "created__lte": filter_field("created", "lte", "datetime"), + "updated__gte": filter_field("updated", "gte", "datetime"), + "updated__lte": filter_field("updated", "lte", "datetime"), + }, + orderings={ + "id": "id", + "name": "name", + "target_start": "target_start", + "created": "created", + "updated": "updated", + }, + search_fields=["name", "description"], +)) + +# Sentinel distinguishing "tags omitted" from "tags set to null/empty" on PATCH. +_UNSET = object() + + +class EngagementListResponse(Schema): + + """OpenAPI documentation of the list envelope (I1); runtime serialization is manual.""" + + count: int + next: str | None + previous: str | None + results: list[EngagementSlim] + meta: dict | None = None + + +def _base_queryset(request: HttpRequest, queryset_hook: Callable | None) -> QuerySet: + # Reads flow only through the authorized queryset (I8). The OS helper resolves the current user + # from crum (its signature takes no user kwarg), exactly as the v2 viewset relies on. + qs = get_authorized_engagements(Permissions.Engagement_View) + if queryset_hook is not None: + qs = queryset_hook(qs, request) + return qs + + +def _validation_from_django(exc: DjangoValidationError) -> Exception: + if hasattr(exc, "message_dict"): + return validation_problem({k: list(v) for k, v in exc.message_dict.items()}) + return validation_problem({"non_field_errors": list(exc.messages)}) + + +def _resolve_lead(pk: int | None) -> Dojo_User | None: + if pk is None: + return None + lead = get_object_or_none(Dojo_User, pk=pk) + if lead is None: + raise validation_problem({"lead": [f"user {pk} does not exist"]}) + return lead + + +def _validate_dates(target_start, target_end) -> None: + # Mirror EngagementSerializer.validate (POST): target start must not exceed target end. + if target_start is not None and target_end is not None and target_start > target_end: + raise validation_problem( + {"target_start": ["Your target start date exceeds your target end date"]}, + ) + + +def _destroy(instance: Engagement) -> None: + """Mirror v2 ``EngagementViewSet.destroy`` exactly (no Endpoint context wrapper -- §12).""" + if get_setting("ASYNC_OBJECT_DELETE"): + async_delete().delete(instance) + else: + instance.delete() + + +def _apply_scalars(instance: Engagement, data: dict) -> None: + if "lead" in data: + instance.lead = _resolve_lead(data.pop("lead")) + for key, value in data.items(): + setattr(instance, key, value) + + +def build_engagements_router( + *, + schema: type = EngagementSlim, + detail_schema: type = EngagementDetail, + filter_spec: FilterSpec = ENGAGEMENT_FILTER_SPEC, + queryset_hook: Callable | None = None, + auth=NOT_SET, +) -> Router: + """Build the engagements router (I5).""" + router = Router(tags=["engagements"], auth=auth) + + @router.get("/engagements", response=EngagementListResponse, url_name="engagements_list") + def list_engagements(request: HttpRequest): + filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) + + expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) + page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + page_qs = plan_queryset(page_qs, select_related, prefetch) + + fields = parse_fields(request.GET.get("fields"), set(schema.model_fields)) + + def serialize_row(obj: object) -> dict: + return apply_fields(serialize(obj, schema, expand_tree), fields) + + envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) + include_meta = apply_includes(request, filtered, allowed=set()) + if include_meta: + envelope.setdefault("meta", {}).update(include_meta) + return json_response(envelope) + + @router.get("/engagements/{int:engagement_id}", response=detail_schema, url_name="engagements_detail") + def get_engagement(request: HttpRequest, engagement_id: int): + expand_tree, select_related, prefetch = plan(detail_schema, request.GET.get("expand")) + qs = _base_queryset(request, queryset_hook).select_related(*detail_schema.SELECT_RELATED).prefetch_related(*detail_schema.PREFETCH_RELATED) + qs = plan_queryset(qs, select_related, prefetch) + obj = qs.filter(pk=engagement_id).first() + if obj is None: + msg = f"Engagement {engagement_id} not found" + raise not_found_problem(msg) + fields = parse_fields(request.GET.get("fields"), set(detail_schema.model_fields)) + return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) + + @router.post("/engagements", response=detail_schema, url_name="engagements_create") + def create_engagement(request: HttpRequest, payload: EngagementWrite): + data = payload.dict() + tags = data.pop("tags") + product_id = data.pop("product") + # Mirror check_post_permission(request, Product, "product", "add"): 404 if the target + # product doesn't exist, 403 if the user can't add engagements to it. + product = get_object_or_none(Product, pk=product_id) + if product is None: + msg = f"Product {product_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, product, Permissions.Engagement_Add): + raise PermissionDenied + _validate_dates(data.get("target_start"), data.get("target_end")) + + instance = Engagement(product=product) + _apply_scalars(instance, {k: v for k, v in data.items() if v is not None}) + try: + instance.save() + except DjangoValidationError as exc: + raise _validation_from_django(exc) from exc + if tags is not None: + instance.tags = tags + instance.save() + return json_response(serialize(instance, detail_schema, {}), status=201) + + @router.patch("/engagements/{int:engagement_id}", response=detail_schema, url_name="engagements_update") + def update_engagement(request: HttpRequest, engagement_id: int, payload: EngagementUpdate): + instance = _base_queryset(request, queryset_hook).filter(pk=engagement_id).first() + if instance is None: + msg = f"Engagement {engagement_id} not found" + raise not_found_problem(msg) # 404: unknown or unauthorized-to-view + if not user_has_permission(request.user, instance, Permissions.Engagement_Edit): + raise PermissionDenied # 403: visible but not editable + + data = payload.dict(exclude_unset=True) + tags = data.pop("tags", _UNSET) + if "product" in data: + new_product_id = data.pop("product") + # Mirror check_update_permission: only re-check `add` when the FK actually changes. + if new_product_id is not None and new_product_id != instance.product_id: + new_product = get_object_or_none(Product, pk=new_product_id) + if new_product is None: + msg = f"Product {new_product_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, new_product, Permissions.Engagement_Add): + raise PermissionDenied + instance.product = new_product + + target_start = data.get("target_start", instance.target_start) + target_end = data.get("target_end", instance.target_end) + _validate_dates(target_start, target_end) + + _apply_scalars(instance, data) + if tags is not _UNSET: + instance.tags = tags if tags is not None else [] + try: + instance.save() + except DjangoValidationError as exc: + raise _validation_from_django(exc) from exc + return json_response(serialize(instance, detail_schema, {})) + + @router.delete("/engagements/{int:engagement_id}", url_name="engagements_delete") + def delete_engagement(request: HttpRequest, engagement_id: int): + instance = _base_queryset(request, queryset_hook).filter(pk=engagement_id).first() + if instance is None: + msg = f"Engagement {engagement_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, instance, Permissions.Engagement_Delete): + raise PermissionDenied + _destroy(instance) + response = HttpResponse(status=204) + response["X-API-Status"] = settings.API_V3_STATUS + return response + + return router diff --git a/dojo/engagement/api_v3/schemas.py b/dojo/engagement/api_v3/schemas.py new file mode 100644 index 00000000000..5d1c92c3b21 --- /dev/null +++ b/dojo/engagement/api_v3/schemas.py @@ -0,0 +1,156 @@ +""" +Engagement response + write schemas for API v3 (§4.5, §4.11, OS3b). + +``EngagementSlim`` is the canonical Engagement slim -- relocated here from ``dojo/finding/api_v3`` +(where OS1 first defined it so finding ``?expand=`` had a target); the finding module now re-exports +this copy so there is exactly one class per model (verified is-identity in the tests, mirroring the +OS3a relocation pattern -- see §12). ``EngagementDetail`` adds the documented heavier read fields +(§4.5). ``product``/``product_type``/``lead`` are expandable relations (§4.6). + +Write schemas mirror the v2 ``EngagementSerializer`` (a ``ModelSerializer`` excluding +``inherited_tags``): the model requires ``target_start``, ``target_end`` and ``product``; everything +else is optional. Relations are referenced by integer id (§4.11); ``editable=False`` / +server-managed fields (``active``, ``notes``, ``files``, ``progress``, ``risk_acceptance``, +``done_testing``, ``id``, ``created``, ``updated``) are never writable; unknown fields are rejected +(``extra="forbid"``). +""" +from __future__ import annotations + +from datetime import date, datetime # noqa: TC003 -- runtime import: pydantic resolves the schema field types +from typing import ClassVar + +from ninja import Schema + +from dojo.api_v3.expand import ExpandRel +from dojo.api_v3.refs import Ref, to_ref +from dojo.models import Engagement +from dojo.product.api_v3.schemas import ProductSlim +from dojo.product_type.api_v3.schemas import ProductTypeSlim +from dojo.user.api_v3.schemas import UserSlim + + +class EngagementSlim(Schema): + django_model: ClassVar = Engagement + SELECT_RELATED: ClassVar[tuple] = ("product__prod_type", "lead") + PREFETCH_RELATED: ClassVar[tuple] = ("tags",) + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str | None + product: Ref + product_type: Ref + lead: Ref | None + status: str | None + engagement_type: str | None + target_start: date | None + target_end: date | None + active: bool | None + tags: list[str] + created: datetime | None + updated: datetime | None + + @staticmethod + def resolve_product(obj) -> dict | None: + return to_ref(obj.product) + + @staticmethod + def resolve_product_type(obj) -> dict | None: + return to_ref(obj.product.prod_type) + + @staticmethod + def resolve_lead(obj) -> dict | None: + return to_ref(obj.lead) + + @staticmethod + def resolve_tags(obj) -> list[str]: + return [t.name for t in obj.tags.all()] + + +EngagementSlim.EXPANDABLE = { + "product": ExpandRel(attr="product", path="product", schema=ProductSlim), + "product_type": ExpandRel(attr="product.prod_type", path="product__prod_type", schema=ProductTypeSlim), + "lead": ExpandRel(attr="lead", path="lead", schema=UserSlim), +} + + +class EngagementDetail(EngagementSlim): + + """Slim + the documented heavier read fields (§4.5). Retrieve returns detail; list returns slim.""" + + description: str | None + version: str | None + first_contacted: date | None + reason: str | None + tracker: str | None + test_strategy: str | None + threat_model: bool | None + api_test: bool | None + pen_test: bool | None + check_list: bool | None + build_id: str | None + commit_hash: str | None + branch_tag: str | None + source_code_management_uri: str | None + deduplication_on_engagement: bool | None + + +class EngagementWrite(Schema): + + """Create payload (POST). ``product``/``target_start``/``target_end`` required (mirrors v2).""" + + model_config = {"extra": "forbid"} + + product: int + target_start: date + target_end: date + name: str | None = None + description: str | None = None + version: str | None = None + first_contacted: date | None = None + lead: int | None = None + reason: str | None = None + tracker: str | None = None + test_strategy: str | None = None + threat_model: bool | None = None + api_test: bool | None = None + pen_test: bool | None = None + check_list: bool | None = None + status: str | None = None + engagement_type: str | None = None + build_id: str | None = None + commit_hash: str | None = None + branch_tag: str | None = None + source_code_management_uri: str | None = None + deduplication_on_engagement: bool | None = None + tags: list[str] | None = None + + +class EngagementUpdate(Schema): + + """Partial update payload (PATCH). Every field optional; only provided keys are applied.""" + + model_config = {"extra": "forbid"} + + product: int | None = None + target_start: date | None = None + target_end: date | None = None + name: str | None = None + description: str | None = None + version: str | None = None + first_contacted: date | None = None + lead: int | None = None + reason: str | None = None + tracker: str | None = None + test_strategy: str | None = None + threat_model: bool | None = None + api_test: bool | None = None + pen_test: bool | None = None + check_list: bool | None = None + status: str | None = None + engagement_type: str | None = None + build_id: str | None = None + commit_hash: str | None = None + branch_tag: str | None = None + source_code_management_uri: str | None = None + deduplication_on_engagement: bool | None = None + tags: list[str] | None = None diff --git a/dojo/finding/api_v3/routes.py b/dojo/finding/api_v3/routes.py index 225e8deab7a..a7458ef2cc5 100644 --- a/dojo/finding/api_v3/routes.py +++ b/dojo/finding/api_v3/routes.py @@ -1,16 +1,30 @@ """ -Findings read routes for API v3 (§4.5, §4.6, §4.8, OS1). +Findings read + write routes for API v3 (§4.5, §4.6, §4.8, OS1 read / OS3b write). ``build_findings_router()`` is a router *factory* (I5): the OS mount calls it with defaults; a downstream distribution can call it with a subclassed schema / extra filters / a queryset hook and -mount the result under its own prefix -- no fork. Routes are thin (I6): authorize -> filter -> -plan queryset -> serialize -> shape; all RBAC flows through ``get_authorized_findings`` (I8). +mount the result under its own prefix -- no fork. Routes are thin (I6): authorize -> parse -> +service -> serialize; all RBAC flows through ``get_authorized_findings`` (reads) and the v2 +``UserHasFindingPermission`` semantics (writes, I8), and **all** write side-effects/orchestration +live in ``dojo/finding/services.py`` (D7), never in the route: + +- create (POST): ``add`` permission on the target ``test`` referenced in the payload (404 if it + doesn't exist, 403 if unauthorized -- mirrors + ``check_post_permission(request, Test, "test", "add")``). +- update (PATCH): object ``edit`` permission (404 for unknown-or-unauthorized, 403 if visible but + not editable). Mirrors ``perform_update``: ``push_to_jira`` is OR-ed with the + JIRA project's ``push_all_issues`` when JIRA is enabled. +- delete (DELETE): object ``delete`` permission; delegates to the service, whose ``Finding.delete()`` + runs the same dedup/grading hooks as the v2 ``FindingViewSet.destroy`` (§12). """ from __future__ import annotations from typing import TYPE_CHECKING +from django.conf import settings +from django.core.exceptions import PermissionDenied from django.db.models import Count +from django.http import HttpResponse from ninja import Router, Schema from ninja.constants import NOT_SET @@ -25,10 +39,14 @@ ) from dojo.api_v3.include import apply_includes from dojo.api_v3.pagination import paginate +from dojo.authorization.authorization import user_has_permission from dojo.authorization.roles_permissions import Permissions -from dojo.finding.api_v3.schemas import FindingDetail, FindingSlim +from dojo.finding.api_v3.schemas import FindingDetail, FindingSlim, FindingUpdate, FindingWrite from dojo.finding.queries import get_authorized_findings -from dojo.models import Finding +from dojo.finding.services import create_finding, delete_finding, update_finding +from dojo.jira import services as jira_services +from dojo.models import Finding, Test +from dojo.utils import get_object_or_none, get_system_setting if TYPE_CHECKING: from collections.abc import Callable @@ -105,6 +123,20 @@ def _base_queryset(request: HttpRequest, queryset_hook: Callable | None) -> Quer return qs +def _detail_object(request: HttpRequest, queryset_hook: Callable | None, detail_schema: type, finding_id: int): + """ + Fetch a finding through the authorized queryset with the detail schema's relations + + ``locations_count`` annotation loaded, so the write responses serialize identically to GET. + """ + qs = ( + _base_queryset(request, queryset_hook) + .select_related(*detail_schema.SELECT_RELATED) + .prefetch_related(*detail_schema.PREFETCH_RELATED) + .annotate(locations_count=Count("locations", distinct=True)) + ) + return qs.filter(pk=finding_id).first() + + def build_findings_router( *, schema: type = FindingSlim, @@ -163,4 +195,62 @@ def get_finding(request: HttpRequest, finding_id: int): data = apply_fields(serialize(obj, detail_schema, expand_tree), fields) return json_response(data) + @router.post("/findings", response=detail_schema, url_name="findings_create") + def create_finding_route(request: HttpRequest, payload: FindingWrite): + data = payload.dict() + test_id = data.pop("test") + push_to_jira = data.pop("push_to_jira") + vulnerability_ids = data.pop("vulnerability_ids") + # Mirror UserHasFindingPermission -> check_post_permission(request, Test, "test", "add"): + # 404 if the test doesn't exist, 403 if the user can't add findings to it. + test = get_object_or_none(Test, pk=test_id) + if test is None: + msg = f"Test {test_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, test, Permissions.Finding_Add): + raise PermissionDenied + finding = create_finding( + test=test, data=data, user=request.user, + push_to_jira=push_to_jira, vulnerability_ids=vulnerability_ids, + ) + obj = _detail_object(request, queryset_hook, detail_schema, finding.pk) or finding + return json_response(serialize(obj, detail_schema, {}), status=201) + + @router.patch("/findings/{int:finding_id}", response=detail_schema, url_name="findings_update") + def update_finding_route(request: HttpRequest, finding_id: int, payload: FindingUpdate): + finding = _base_queryset(request, queryset_hook).filter(pk=finding_id).first() + if finding is None: + msg = f"Finding {finding_id} not found" + raise not_found_problem(msg) # 404: unknown or unauthorized-to-view + if not user_has_permission(request.user, finding, Permissions.Finding_Edit): + raise PermissionDenied # 403: visible but not editable + + changes = payload.dict(exclude_unset=True) + push_to_jira = changes.pop("push_to_jira", False) + vulnerability_ids = changes.pop("vulnerability_ids", None) + # Mirror FindingViewSet.perform_update: OR push_to_jira with the project's push_all_issues. + jira_project = jira_services.get_project(finding) + if get_system_setting("enable_jira") and jira_project: + push_to_jira = push_to_jira or jira_project.push_all_issues + + update_finding( + finding, changes=changes, user=request.user, + push_to_jira=push_to_jira, vulnerability_ids=vulnerability_ids, + ) + obj = _detail_object(request, queryset_hook, detail_schema, finding_id) or finding + return json_response(serialize(obj, detail_schema, {})) + + @router.delete("/findings/{int:finding_id}", url_name="findings_delete") + def delete_finding_route(request: HttpRequest, finding_id: int): + finding = _base_queryset(request, queryset_hook).filter(pk=finding_id).first() + if finding is None: + msg = f"Finding {finding_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, finding, Permissions.Finding_Delete): + raise PermissionDenied + delete_finding(finding, user=request.user) + response = HttpResponse(status=204) + response["X-API-Status"] = settings.API_V3_STATUS + return response + return router diff --git a/dojo/finding/api_v3/schemas.py b/dojo/finding/api_v3/schemas.py index 6c5875a771e..e5df7273353 100644 --- a/dojo/finding/api_v3/schemas.py +++ b/dojo/finding/api_v3/schemas.py @@ -1,170 +1,60 @@ """ -Finding response schemas for API v3 (§4.5). +Finding response + write schemas for API v3 (§4.5, §4.11). -``FindingSlim`` (list) and ``FindingDetail`` (retrieve) plus the parent slim schemas needed to -serve ``?expand=`` targets (§4.6). Every schema is a named, importable, subclassable ninja Schema -(I4) and declares (as ``ClassVar`` so pydantic does not treat them as fields): +``FindingSlim`` (list) and ``FindingDetail`` (retrieve). Every schema is a named, importable, +subclassable ninja Schema (I4) and declares (as ``ClassVar`` so pydantic does not treat them as +fields): - ``django_model`` -- the mapped model (used by the expand cycle guard), - ``SELECT_RELATED`` / ``PREFETCH_RELATED`` -- the relation paths its resolvers read, so the expand planner can keep the query count constant, - ``EXPANDABLE`` -- its expandable relations (§4.6). -Slim = identity + primary status fields + parent refs + timestamps, with no per-row computed -fields (``locations_count`` is a queryset annotation, which is allowed). The parent slims live -here for OS1 (findings-only); OS3 relocates the canonical copies to their resource modules. +The parent slims (``EngagementSlim``/``TestSlim``/``TestTypeSlim``/``EnvironmentSlim``/``ProductSlim`` +/``ProductTypeSlim``/``UserSlim``) live in their own resource modules; this module **re-exports** +them (OS3a relocated product/product_type/user; OS3b relocated engagement/test) so there is exactly +one canonical class per model shared by the resource endpoints and the finding ``?expand=`` targets +(I4). See §12. + +Write schemas (``FindingWrite`` create, ``FindingUpdate`` PATCH) are the editable subset of the +detail fields; required-vs-optional mirrors the v2 ``FindingCreateSerializer`` / ``FindingSerializer``. +Relations are referenced by integer id (§4.11); ``test`` is writable only on create +(``editable=False`` on the model, mirrors v2). Server-managed fields are never writable and unknown +fields are rejected (``extra="forbid"``). All side-effect / status-invariant validation lives in the +service (``dojo/finding/services.py``, D7); the schemas do field typing + strictness only. """ from __future__ import annotations -from datetime import date, datetime # noqa: TC003 -- runtime import: pydantic resolves the schema field types +import datetime # noqa: TC003 -- runtime import: pydantic resolves the schema field types from typing import ClassVar from ninja import Schema from dojo.api_v3.expand import ExpandRel from dojo.api_v3.refs import Ref, to_location_ref, to_ref -from dojo.models import ( - Development_Environment, - Engagement, - Finding, - Test, - Test_Type, -) - -# Canonical parent slims live in their own resource modules as of OS3a (§12): the finding expand -# targets (below) reference these single canonical classes rather than a private duplicate. + +# Canonical parent slims live in their own resource modules; re-exported here so the finding +# expand targets (below) and the resource endpoints serialize through the same schema (I4, §12). +from dojo.engagement.api_v3.schemas import EngagementSlim +from dojo.models import Finding from dojo.product.api_v3.schemas import ProductSlim from dojo.product_type.api_v3.schemas import ProductTypeSlim +from dojo.test.api_v3.schemas import EnvironmentSlim, TestSlim, TestTypeSlim from dojo.user.api_v3.schemas import UserSlim - -class EngagementSlim(Schema): - django_model: ClassVar = Engagement - SELECT_RELATED: ClassVar[tuple] = ("product__prod_type", "lead") - PREFETCH_RELATED: ClassVar[tuple] = ("tags",) - EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} - - id: int - name: str | None - product: Ref - product_type: Ref - lead: Ref | None - status: str | None - engagement_type: str | None - target_start: date | None - target_end: date | None - active: bool | None - tags: list[str] - created: datetime | None - updated: datetime | None - - @staticmethod - def resolve_product(obj) -> dict | None: - return to_ref(obj.product) - - @staticmethod - def resolve_product_type(obj) -> dict | None: - return to_ref(obj.product.prod_type) - - @staticmethod - def resolve_lead(obj) -> dict | None: - return to_ref(obj.lead) - - @staticmethod - def resolve_tags(obj) -> list[str]: - return [t.name for t in obj.tags.all()] - - -EngagementSlim.EXPANDABLE = { - "product": ExpandRel(attr="product", path="product", schema=ProductSlim), - "product_type": ExpandRel(attr="product.prod_type", path="product__prod_type", schema=ProductTypeSlim), - "lead": ExpandRel(attr="lead", path="lead", schema=UserSlim), -} - - -class TestTypeSlim(Schema): - django_model: ClassVar = Test_Type - SELECT_RELATED: ClassVar[tuple] = () - PREFETCH_RELATED: ClassVar[tuple] = () - EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} - - id: int - name: str - active: bool | None - - -class EnvironmentSlim(Schema): - django_model: ClassVar = Development_Environment - SELECT_RELATED: ClassVar[tuple] = () - PREFETCH_RELATED: ClassVar[tuple] = () - EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} - - id: int - name: str - - -class TestSlim(Schema): - django_model: ClassVar = Test - SELECT_RELATED: ClassVar[tuple] = ("test_type", "engagement__product__prod_type", "environment", "lead") - PREFETCH_RELATED: ClassVar[tuple] = ("tags",) - EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} - - id: int - name: str | None - test_type: Ref - engagement: Ref - product: Ref - product_type: Ref - environment: Ref | None - lead: Ref | None - target_start: datetime | None - target_end: datetime | None - percent_complete: int | None - tags: list[str] - created: datetime | None - updated: datetime | None - - @staticmethod - def resolve_name(obj) -> str | None: - return obj.title - - @staticmethod - def resolve_test_type(obj) -> dict | None: - return to_ref(obj.test_type) - - @staticmethod - def resolve_engagement(obj) -> dict | None: - return to_ref(obj.engagement) - - @staticmethod - def resolve_product(obj) -> dict | None: - return to_ref(obj.engagement.product) - - @staticmethod - def resolve_product_type(obj) -> dict | None: - return to_ref(obj.engagement.product.prod_type) - - @staticmethod - def resolve_environment(obj) -> dict | None: - return to_ref(obj.environment) - - @staticmethod - def resolve_lead(obj) -> dict | None: - return to_ref(obj.lead) - - @staticmethod - def resolve_tags(obj) -> list[str]: - return [t.name for t in obj.tags.all()] - - -TestSlim.EXPANDABLE = { - "test_type": ExpandRel(attr="test_type", path="test_type", schema=TestTypeSlim), - "engagement": ExpandRel(attr="engagement", path="engagement", schema=EngagementSlim), - "product": ExpandRel(attr="engagement.product", path="engagement__product", schema=ProductSlim), - "product_type": ExpandRel(attr="engagement.product.prod_type", path="engagement__product__prod_type", schema=ProductTypeSlim), - "lead": ExpandRel(attr="lead", path="lead", schema=UserSlim), - "environment": ExpandRel(attr="environment", path="environment", schema=EnvironmentSlim), -} +__all__ = [ + "EngagementSlim", + "EnvironmentSlim", + "FindingDetail", + "FindingSlim", + "FindingUpdate", + "FindingWrite", + "ProductSlim", + "ProductTypeSlim", + "TestSlim", + "TestTypeSlim", + "UserSlim", +] class FindingSlim(Schema): @@ -184,7 +74,7 @@ class FindingSlim(Schema): risk_accepted: bool out_of_scope: bool is_mitigated: bool - date: date | None + date: datetime.date | None cwe: int | None test: Ref engagement: Ref @@ -193,8 +83,8 @@ class FindingSlim(Schema): reporter: Ref | None locations_count: int tags: list[str] - created: datetime | None - updated: datetime | None + created: datetime.datetime | None + updated: datetime.datetime | None @staticmethod def resolve_test(obj) -> dict | None: @@ -275,9 +165,86 @@ class FindingDetail(FindingSlim): references: str | None file_path: str | None line: int | None - mitigated: datetime | None + mitigated: datetime.datetime | None mitigated_by: Ref | None @staticmethod def resolve_mitigated_by(obj) -> dict | None: return to_ref(obj.mitigated_by) + + +# --- Write schemas (§4.11, §6 OS3 write-schema rule) ------------------------------------------ + +class FindingWrite(Schema): + + """ + Create payload (POST /findings). ``test``/``title``/``severity``/``description``/``active``/ + ``verified`` required (mirrors ``FindingCreateSerializer``: ``active``/``verified`` carry + ``extra_kwargs required=True``). ``vulnerability_ids`` is a flat ``list[str]`` (§4.11); the + service persists them and mirrors the first into the ``cve`` field. ``found_by`` references + ``Test_Type`` ids; ``reporter``/``mitigated_by`` reference user ids. + """ + + model_config = {"extra": "forbid"} + + test: int + title: str + severity: str + description: str + active: bool + verified: bool + date: datetime.date | None = None + cwe: int | None = None + false_p: bool | None = None + duplicate: bool | None = None + out_of_scope: bool | None = None + risk_accepted: bool | None = None + is_mitigated: bool | None = None + mitigation: str | None = None + impact: str | None = None + steps_to_reproduce: str | None = None + severity_justification: str | None = None + references: str | None = None + file_path: str | None = None + line: int | None = None + mitigated: datetime.datetime | None = None + mitigated_by: int | None = None + reporter: int | None = None + found_by: list[int] | None = None + vulnerability_ids: list[str] | None = None + tags: list[str] | None = None + push_to_jira: bool = False + + +class FindingUpdate(Schema): + + """Partial update payload (PATCH). ``test`` is not writable (editable=False, mirrors v2).""" + + model_config = {"extra": "forbid"} + + title: str | None = None + severity: str | None = None + description: str | None = None + active: bool | None = None + verified: bool | None = None + date: datetime.date | None = None + cwe: int | None = None + false_p: bool | None = None + duplicate: bool | None = None + out_of_scope: bool | None = None + risk_accepted: bool | None = None + is_mitigated: bool | None = None + mitigation: str | None = None + impact: str | None = None + steps_to_reproduce: str | None = None + severity_justification: str | None = None + references: str | None = None + file_path: str | None = None + line: int | None = None + mitigated: datetime.datetime | None = None + mitigated_by: int | None = None + reporter: int | None = None + found_by: list[int] | None = None + vulnerability_ids: list[str] | None = None + tags: list[str] | None = None + push_to_jira: bool = False diff --git a/dojo/finding/services.py b/dojo/finding/services.py new file mode 100644 index 00000000000..8ba6530fe8e --- /dev/null +++ b/dojo/finding/services.py @@ -0,0 +1,275 @@ +""" +Finding write service for API v3 (D7 -- the flagship service extraction). + +This module is the single home for finding create/update/delete orchestration and side-effects +(JIRA push + keep-in-sync, risk-acceptance processing, vulnerability-id / CWE persistence, found_by +handling, mitigated-field edit rules, status invariants, deletion dedup/grading hooks). It is +written to serve *all three* consumers (v2 serializer, UI views, v3 routes), but in the alpha PR only +v3 calls it -- so the additive property (D1) is preserved. The v2 ``FindingSerializer`` / +``FindingCreateSerializer`` and the UI ``EditFinding`` view remain the live implementations and are +**not modified** here. + +Extraction rule (D7): the functions below reconcile *both* reference implementations -- +``dojo/finding/api/serializer.py`` (``FindingSerializer.update``/``validate`` and +``FindingCreateSerializer.create``/``validate``) and the UI flow in ``dojo/finding/ui/views.py`` +(``EditFinding.process_finding_form`` / ``process_jira_form`` / ``process_forms``). Where they +diverge, the canonical behavior chosen here is recorded in API_V3_PLAN.md §12 (see the OS3b +divergence entry). The serializer path is the primary reference (the plan names it), so where the UI +adds API-absent side-effects (last_reviewed stamping, mitigated-status propagation to location edges, +false-positive history, burp req/resp, finding-group handling, github, jira link/unlink) those are +consciously **deferred** to the convergence track rather than baked into the API here. + +Invariant I6: keyword-only args, an explicit ``user``, no HTTP context. Validation failures raise +``rest_framework.exceptions.ValidationError`` -- exactly the exception the reference serializer +raises -- which the v3 kernel error boundary maps to a 400 ``application/problem+json`` (§12 OS1). +""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import ValidationError + +from dojo.celery_dispatch import dojo_dispatch_task +from dojo.finding.helper import ( + can_edit_mitigated_data, + save_cwes, + save_vulnerability_ids, +) +from dojo.jira import services as jira_services +from dojo.models import SEVERITIES, Dojo_User, Finding, Test_Type +from dojo.notifications.helper import async_create_notification +from dojo.utils import get_object_or_none + +if TYPE_CHECKING: + from dojo.models import Test + +logger = logging.getLogger(__name__) + +# Distinguishes "key absent" from "key present with value None" in a PATCH change set. +_UNSET = object() + +# Scalar Finding fields consumed via **kwargs on create; relations/side-effect keys are handled +# explicitly (popped) before this set is applied. +_SPECIAL_KEYS = frozenset({"reporter", "mitigated_by", "found_by", "tags", "vulnerability_ids", "push_to_jira", "test"}) + + +# --- shared validation (ported from FindingSerializer.validate / FindingCreateSerializer.validate) -- + +def _validate_severity(severity: str) -> None: + if severity is not None and severity not in SEVERITIES: + msg = f"Severity must be one of the following: {SEVERITIES}" + raise ValidationError({"severity": [msg]}) + + +def _validate_mitigated_editable(data: dict, user) -> None: + """Mirror the serializer's mitigated-field editability gate (settings.EDITABLE_MITIGATED_DATA).""" + attempting = any( + (field in data) and (data.get(field) is not None) + for field in ("mitigated", "mitigated_by") + ) + if attempting and not can_edit_mitigated_data(user): + errors = {} + if ("mitigated" in data) and (data.get("mitigated") is not None): + errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"] + if ("mitigated_by" in data) and (data.get("mitigated_by") is not None): + errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"] + if errors: + raise ValidationError(errors) + + +def _validate_status_invariants(*, is_active, is_verified, is_duplicate, is_false_p, + is_risk_accepted, product, was_risk_accepted) -> None: + """Port the active/verified/duplicate/false_p/risk_accepted invariants (both serializers).""" + if (is_active or is_verified) and is_duplicate: + msg = "Duplicate findings cannot be verified or active" + raise ValidationError(msg) + if is_false_p and is_verified: + msg = "False positive findings cannot be verified." + raise ValidationError(msg) + if is_risk_accepted and not was_risk_accepted and not product.enable_simple_risk_acceptance: + msg = "Simple risk acceptance is disabled for this product, use the UI to accept this finding." + raise ValidationError(msg) + if is_active and is_risk_accepted: + msg = "Active findings cannot be risk accepted." + raise ValidationError(msg) + + +def _process_risk_acceptance(finding: Finding, changes: dict, user) -> None: + """ + Port ``FindingSerializer.process_risk_acceptance`` (runs in validate(), i.e. before field + updates). No-op unless ``risk_accepted`` is an explicit bool in the change set. + """ + import dojo.risk_acceptance.helper as ra_helper # noqa: PLC0415 -- lazy import, avoids circular dependency + + is_risk_accepted = changes.get("risk_accepted") + if not isinstance(is_risk_accepted, bool): + return + if (is_risk_accepted and not finding.risk_accepted + and finding.test.engagement.product.enable_simple_risk_acceptance + and not changes.get("active")): + ra_helper.simple_risk_accept(user, finding) + elif not is_risk_accepted and finding.risk_accepted: + ra_helper.risk_unaccept(user, finding) + + +def _resolve_user(pk: int) -> Dojo_User: + user = get_object_or_none(Dojo_User, pk=pk) + if user is None: + raise ValidationError({"reporter": [f"user {pk} does not exist"]}) + return user + + +def _resolve_test_types(ids: list[int]) -> list[Test_Type]: + resolved = [] + for pk in ids: + tt = get_object_or_none(Test_Type, pk=pk) + if tt is None: + raise ValidationError({"found_by": [f"Test_Type {pk} does not exist"]}) + resolved.append(tt) + return resolved + + +# --- public service API (I6) ------------------------------------------------------------------ + +def create_finding(*, test: Test, data: dict, user, push_to_jira: bool = False, + vulnerability_ids: list[str] | None = None) -> Finding: + """ + Create a finding under ``test``. Ports ``FindingCreateSerializer.create``/``validate``: + reporter defaulting, status invariants, vulnerability-id + CWE persistence, found_by, JIRA push, + and the ``finding_added`` notification. + """ + data = {k: v for k, v in data.items() if k not in _SPECIAL_KEYS or k in {"reporter", "mitigated_by", "found_by", "tags"}} + + _validate_severity(data.get("severity")) + _validate_mitigated_editable(data, user) + _validate_status_invariants( + is_active=data.get("active"), + is_verified=data.get("verified"), + is_duplicate=data.get("duplicate"), + is_false_p=data.get("false_p"), + is_risk_accepted=data.get("risk_accepted"), + product=test.engagement.product, + was_risk_accepted=False, + ) + + reporter_id = data.pop("reporter", None) + reporter = _resolve_user(reporter_id) if reporter_id is not None else user + mitigated_by_id = data.pop("mitigated_by", None) + mitigated_by = _resolve_user(mitigated_by_id) if mitigated_by_id is not None else None + found_by_ids = data.pop("found_by", None) + tags = data.pop("tags", None) + + scalars = {k: v for k, v in data.items() if v is not None} + # Mirror the serializer: the first vuln id is written into `cve` *before* the initial save so it + # is persisted (save_vulnerability_ids below only sets it in memory). + if vulnerability_ids: + scalars["cve"] = vulnerability_ids[0] + new_finding = Finding(test=test, reporter=reporter, **scalars) + if mitigated_by is not None: + new_finding.mitigated_by = mitigated_by + # Persist vuln ids so model save computes the hash including them (mirror the serializer). + new_finding.unsaved_vulnerability_ids = vulnerability_ids or [] + new_finding.save() + + if found_by_ids: + new_finding.found_by.set(_resolve_test_types(found_by_ids)) + if vulnerability_ids: + save_vulnerability_ids(new_finding, vulnerability_ids) + # Create always persists the primary Finding.cwe as a Finding_CWE row (mirror serializer). + save_cwes(new_finding) + if tags is not None: + new_finding.tags = tags + new_finding.save() + + if push_to_jira: + jira_services.push(new_finding) + + dojo_dispatch_task( + async_create_notification, + event="finding_added", + title=_("Addition of %s") % new_finding.title, + finding_id=new_finding.id, + description=_('Finding "%s" was added by %s') % (new_finding.title, new_finding.reporter), + url=reverse("view_finding", args=(new_finding.id,)), + icon="exclamation-triangle", + ) + return new_finding + + +def update_finding(finding: Finding, *, changes: dict, user, push_to_jira: bool = False, + vulnerability_ids: list[str] | None = None) -> Finding: + """ + Update ``finding`` from a partial ``changes`` dict. Ports ``FindingSerializer.update``/ + ``validate``: mitigated-edit rules, status invariants (PATCH defaults from the instance), + risk-acceptance processing, vulnerability-id + CWE persistence, found_by set/clear, reporter, + and the synchronous JIRA push (force_sync, raising on failure) + keep-in-sync. + """ + changes = dict(changes) + + if "severity" in changes: + _validate_severity(changes["severity"]) + _validate_mitigated_editable(changes, user) + _validate_status_invariants( + is_active=changes.get("active", finding.active), + is_verified=changes.get("verified", finding.verified), + is_duplicate=changes.get("duplicate", finding.duplicate), + is_false_p=changes.get("false_p", finding.false_p), + is_risk_accepted=changes.get("risk_accepted", finding.risk_accepted), + product=finding.test.engagement.product, + was_risk_accepted=finding.risk_accepted, + ) + # Runs before the field updates (mirrors validate() -> process_risk_acceptance ordering). + _process_risk_acceptance(finding, changes, user) + + reporter_id = changes.pop("reporter", None) + if reporter_id is not None: + finding.reporter = _resolve_user(reporter_id) + mitigated_by = changes.pop("mitigated_by", _UNSET) + if mitigated_by is not _UNSET: + finding.mitigated_by = _resolve_user(mitigated_by) if mitigated_by is not None else None + found_by_ids = changes.pop("found_by", _UNSET) + tags = changes.pop("tags", _UNSET) + cwe_provided = "cwe" in changes + + # Persist vuln ids first so model save computes the hash including them (mirror serializer). + if vulnerability_ids: + save_vulnerability_ids(finding, vulnerability_ids) + + if found_by_ids is not _UNSET: + if found_by_ids: + finding.found_by.set(_resolve_test_types(found_by_ids)) + else: + finding.found_by.clear() + + for key, value in changes.items(): + setattr(finding, key, value) + finding.save() + + # v3 exposes a scalar `cwe` (not the v2 nested `cwes` list), so resync the Finding_CWE rows + # whenever `cwe` is updated -- keeps Finding.cwe and its Finding_CWE rows consistent (§12). + if cwe_provided: + save_cwes(finding) + + if tags is not _UNSET: + finding.tags = tags if tags is not None else [] + finding.save() + + if push_to_jira or jira_services.is_keep_in_sync(finding): + success, message = jira_services.push(finding, force_sync=True) + if not success: + raise ValidationError(message) + return finding + + +def delete_finding(finding: Finding, *, user) -> None: + """ + Delete ``finding``. Mirrors the v2 ``FindingViewSet.destroy`` calculation/dedup hooks: the + model's ``Finding.delete()`` runs ``finding_helper.finding_delete`` (dedup reassignment) and + ``perform_product_grading`` (§12). ``user`` is part of the service contract (I6); the model + resolves the acting user via crum. + """ + logger.debug("api_v3 delete_finding id=%s by user=%s", finding.pk, getattr(user, "username", user)) + finding.delete() diff --git a/dojo/test/api_v3/__init__.py b/dojo/test/api_v3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/test/api_v3/routes.py b/dojo/test/api_v3/routes.py new file mode 100644 index 00000000000..3d8ce4ea702 --- /dev/null +++ b/dojo/test/api_v3/routes.py @@ -0,0 +1,279 @@ +""" +Test CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3b). + +``build_tests_router()`` is a router factory (I5), same shape as ``build_products_router``. Routes +are thin (I6): authorize -> filter -> plan queryset -> serialize -> shape. RBAC flows only through +``get_authorized_tests`` for reads (I8) and the v2 ``user_has_permission`` semantics for writes, +mirroring the v2 ``UserHasTestPermission`` permission class exactly: + +- create (POST): ``add`` permission on the target ``engagement`` referenced in the payload + (404 if it doesn't exist, 403 if unauthorized -- mirrors + ``check_post_permission(request, Engagement, "engagement", "add")``), plus + ``view`` on ``api_scan_configuration`` when present (mirrors the ``required=False`` + sibling check). +- update (PATCH): object ``edit`` permission, plus ``view`` on a *reassigned* + ``api_scan_configuration`` (mirrors + ``check_update_permission(request, obj, "view", "api_scan_configuration")``). + ``engagement`` is not writable on update (``editable=False``, mirrors v2). +- delete (DELETE): object ``delete`` permission (staff-only for non-staff members, legacy model) +- read: object ``view`` via the authorized queryset (404 for unknown-or-unauthorized) + +Deletion mirrors the v2 ``TestsViewSet.destroy`` exactly: async delete when ``ASYNC_OBJECT_DELETE`` +is set, else a plain synchronous ``instance.delete()`` (no ``Endpoint`` context wrapper -- §12). +Relations are referenced by integer id (§4.11). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.core.exceptions import ValidationError as DjangoValidationError +from django.http import HttpResponse +from ninja import Router, Schema +from ninja.constants import NOT_SET + +from dojo.api_v3.errors import json_response, not_found_problem, validation_problem +from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.filtering import ( + FilterSpec, + apply_filters, + filter_field, + register_filter_spec, +) +from dojo.api_v3.include import apply_includes +from dojo.api_v3.pagination import paginate +from dojo.authorization.authorization import user_has_permission +from dojo.authorization.roles_permissions import Permissions +from dojo.models import ( + Development_Environment, + Dojo_User, + Engagement, + Product_API_Scan_Configuration, + Test, + Test_Type, +) +from dojo.test.api_v3.schemas import ( + TestDetail, + TestSlim, + TestUpdate, + TestWrite, +) +from dojo.test.queries import get_authorized_tests +from dojo.utils import async_delete, get_object_or_none, get_setting + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import QuerySet + from django.http import HttpRequest + +# --- Test filter vocabulary (§4.9, minimal set) ----------------------------------------------- + +TEST_FILTER_SPEC = register_filter_spec("test", FilterSpec( + model=Test, + filters={ + "id__in": filter_field("id", "in", "number"), + "title__icontains": filter_field("title", "icontains", "char"), + "engagement": filter_field("engagement", "exact", "number"), + "engagement__in": filter_field("engagement", "in", "number"), + "product": filter_field("engagement__product", "exact", "number"), + "product__in": filter_field("engagement__product", "in", "number"), + "product_type": filter_field("engagement__product__prod_type", "exact", "number"), + "test_type": filter_field("test_type", "exact", "number"), + "environment": filter_field("environment", "exact", "number"), + "lead": filter_field("lead", "exact", "number"), + "target_start__gte": filter_field("target_start", "gte", "datetime"), + "target_start__lte": filter_field("target_start", "lte", "datetime"), + "target_end__gte": filter_field("target_end", "gte", "datetime"), + "target_end__lte": filter_field("target_end", "lte", "datetime"), + "created__gte": filter_field("created", "gte", "datetime"), + "created__lte": filter_field("created", "lte", "datetime"), + "updated__gte": filter_field("updated", "gte", "datetime"), + "updated__lte": filter_field("updated", "lte", "datetime"), + }, + orderings={ + "id": "id", + "title": "title", + "target_start": "target_start", + "created": "created", + "updated": "updated", + }, + search_fields=["title", "description"], +)) + +# Sentinel distinguishing "tags omitted" from "tags set to null/empty" on PATCH. +_UNSET = object() + +# Optional relation fields resolved by integer id (with existence validation -> 400). +_SIMPLE_FK = { + "test_type": Test_Type, + "environment": Development_Environment, + "lead": Dojo_User, +} + + +class TestListResponse(Schema): + + """OpenAPI documentation of the list envelope (I1); runtime serialization is manual.""" + + count: int + next: str | None + previous: str | None + results: list[TestSlim] + meta: dict | None = None + + +def _base_queryset(request: HttpRequest, queryset_hook: Callable | None) -> QuerySet: + qs = get_authorized_tests(Permissions.Test_View) + if queryset_hook is not None: + qs = queryset_hook(qs, request) + return qs + + +def _validation_from_django(exc: DjangoValidationError) -> Exception: + if hasattr(exc, "message_dict"): + return validation_problem({k: list(v) for k, v in exc.message_dict.items()}) + return validation_problem({"non_field_errors": list(exc.messages)}) + + +def _resolve_simple_fk(field: str, pk: int): + """Resolve a body-referenced FK by id (400 if it doesn't exist -- mirrors DRF PK validation).""" + obj = get_object_or_none(_SIMPLE_FK[field], pk=pk) + if obj is None: + raise validation_problem({field: [f"{_SIMPLE_FK[field].__name__} {pk} does not exist"]}) + return obj + + +def _authorize_api_scan_configuration(request: HttpRequest, pk: int) -> Product_API_Scan_Configuration: + """Mirror check_post_permission(..., 'view'): 404 if it doesn't exist, 403 if no view perm.""" + config = get_object_or_none(Product_API_Scan_Configuration, pk=pk) + if config is None: + msg = f"Product_API_Scan_Configuration {pk} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, config, Permissions.Product_API_Scan_Configuration_View): + raise PermissionDenied + return config + + +def _destroy(instance: Test) -> None: + """Mirror v2 ``TestsViewSet.destroy`` exactly (no Endpoint context wrapper -- §12).""" + if get_setting("ASYNC_OBJECT_DELETE"): + async_delete().delete(instance) + else: + instance.delete() + + +def _apply_relations_and_scalars(request: HttpRequest, instance: Test, data: dict) -> None: + for field in _SIMPLE_FK: + if field in data: + pk = data.pop(field) + setattr(instance, field, _resolve_simple_fk(field, pk) if pk is not None else None) + if "api_scan_configuration" in data: + pk = data.pop("api_scan_configuration") + instance.api_scan_configuration = _authorize_api_scan_configuration(request, pk) if pk is not None else None + for key, value in data.items(): + setattr(instance, key, value) + + +def build_tests_router( + *, + schema: type = TestSlim, + detail_schema: type = TestDetail, + filter_spec: FilterSpec = TEST_FILTER_SPEC, + queryset_hook: Callable | None = None, + auth=NOT_SET, +) -> Router: + """Build the tests router (I5).""" + router = Router(tags=["tests"], auth=auth) + + @router.get("/tests", response=TestListResponse, url_name="tests_list") + def list_tests(request: HttpRequest): + filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) + + expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) + page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + page_qs = plan_queryset(page_qs, select_related, prefetch) + + fields = parse_fields(request.GET.get("fields"), set(schema.model_fields)) + + def serialize_row(obj: object) -> dict: + return apply_fields(serialize(obj, schema, expand_tree), fields) + + envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) + include_meta = apply_includes(request, filtered, allowed=set()) + if include_meta: + envelope.setdefault("meta", {}).update(include_meta) + return json_response(envelope) + + @router.get("/tests/{int:test_id}", response=detail_schema, url_name="tests_detail") + def get_test(request: HttpRequest, test_id: int): + expand_tree, select_related, prefetch = plan(detail_schema, request.GET.get("expand")) + qs = _base_queryset(request, queryset_hook).select_related(*detail_schema.SELECT_RELATED).prefetch_related(*detail_schema.PREFETCH_RELATED) + qs = plan_queryset(qs, select_related, prefetch) + obj = qs.filter(pk=test_id).first() + if obj is None: + msg = f"Test {test_id} not found" + raise not_found_problem(msg) + fields = parse_fields(request.GET.get("fields"), set(detail_schema.model_fields)) + return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) + + @router.post("/tests", response=detail_schema, url_name="tests_create") + def create_test(request: HttpRequest, payload: TestWrite): + data = payload.dict() + tags = data.pop("tags") + engagement_id = data.pop("engagement") + # Mirror check_post_permission(request, Engagement, "engagement", "add"): 404 if the target + # engagement doesn't exist, 403 if the user can't add tests to it. + engagement = get_object_or_none(Engagement, pk=engagement_id) + if engagement is None: + msg = f"Engagement {engagement_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, engagement, Permissions.Test_Add): + raise PermissionDenied + + instance = Test(engagement=engagement) + _apply_relations_and_scalars(request, instance, {k: v for k, v in data.items() if v is not None}) + try: + instance.save() + except DjangoValidationError as exc: + raise _validation_from_django(exc) from exc + if tags is not None: + instance.tags = tags + instance.save() + return json_response(serialize(instance, detail_schema, {}), status=201) + + @router.patch("/tests/{int:test_id}", response=detail_schema, url_name="tests_update") + def update_test(request: HttpRequest, test_id: int, payload: TestUpdate): + instance = _base_queryset(request, queryset_hook).filter(pk=test_id).first() + if instance is None: + msg = f"Test {test_id} not found" + raise not_found_problem(msg) # 404: unknown or unauthorized-to-view + if not user_has_permission(request.user, instance, Permissions.Test_Edit): + raise PermissionDenied # 403: visible but not editable + + data = payload.dict(exclude_unset=True) + tags = data.pop("tags", _UNSET) + _apply_relations_and_scalars(request, instance, data) + if tags is not _UNSET: + instance.tags = tags if tags is not None else [] + try: + instance.save() + except DjangoValidationError as exc: + raise _validation_from_django(exc) from exc + return json_response(serialize(instance, detail_schema, {})) + + @router.delete("/tests/{int:test_id}", url_name="tests_delete") + def delete_test(request: HttpRequest, test_id: int): + instance = _base_queryset(request, queryset_hook).filter(pk=test_id).first() + if instance is None: + msg = f"Test {test_id} not found" + raise not_found_problem(msg) + if not user_has_permission(request.user, instance, Permissions.Test_Delete): + raise PermissionDenied + _destroy(instance) + response = HttpResponse(status=204) + response["X-API-Status"] = settings.API_V3_STATUS + return response + + return router diff --git a/dojo/test/api_v3/schemas.py b/dojo/test/api_v3/schemas.py new file mode 100644 index 00000000000..9a3fcb8e07e --- /dev/null +++ b/dojo/test/api_v3/schemas.py @@ -0,0 +1,175 @@ +""" +Test response + write schemas for API v3 (§4.5, §4.11, OS3b). + +``TestSlim`` (plus ``TestTypeSlim`` / ``EnvironmentSlim``) is the canonical Test slim -- relocated +here from ``dojo/finding/api_v3`` (where OS1 first defined it for finding ``?expand=``); the finding +module now re-exports these copies so there is exactly one class per model (verified is-identity in +the tests -- see §12). ``TestDetail`` adds the documented heavier read fields (§4.5). +``test_type``/``engagement``/``product``/``product_type``/``environment``/``lead`` are expandable +relations (§4.6). + +Write schemas mirror the v2 ``TestCreateSerializer`` (create) and ``TestSerializer`` (update): the +model requires ``engagement``, ``test_type``, ``target_start`` and ``target_end``. ``engagement`` is +``editable=False`` on the model, so it is writable **only on create** (mirrors v2 -- the update +serializer treats it as read-only). Relations are referenced by integer id (§4.11); server-managed / +``editable=False`` fields (``id``, ``created``, ``updated``, ``notes``, ``files``) are never +writable; unknown fields are rejected (``extra="forbid"``). +""" +from __future__ import annotations + +from datetime import datetime # noqa: TC003 -- runtime import: pydantic resolves the schema field types +from typing import ClassVar + +from ninja import Schema + +from dojo.api_v3.expand import ExpandRel +from dojo.api_v3.refs import Ref, to_ref +from dojo.engagement.api_v3.schemas import EngagementSlim +from dojo.models import Development_Environment, Test, Test_Type +from dojo.product.api_v3.schemas import ProductSlim +from dojo.product_type.api_v3.schemas import ProductTypeSlim +from dojo.user.api_v3.schemas import UserSlim + + +class TestTypeSlim(Schema): + django_model: ClassVar = Test_Type + SELECT_RELATED: ClassVar[tuple] = () + PREFETCH_RELATED: ClassVar[tuple] = () + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str + active: bool | None + + +class EnvironmentSlim(Schema): + django_model: ClassVar = Development_Environment + SELECT_RELATED: ClassVar[tuple] = () + PREFETCH_RELATED: ClassVar[tuple] = () + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str + + +class TestSlim(Schema): + django_model: ClassVar = Test + SELECT_RELATED: ClassVar[tuple] = ("test_type", "engagement__product__prod_type", "environment", "lead") + PREFETCH_RELATED: ClassVar[tuple] = ("tags",) + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str | None + test_type: Ref + engagement: Ref + product: Ref + product_type: Ref + environment: Ref | None + lead: Ref | None + target_start: datetime | None + target_end: datetime | None + percent_complete: int | None + tags: list[str] + created: datetime | None + updated: datetime | None + + @staticmethod + def resolve_name(obj) -> str | None: + return obj.title + + @staticmethod + def resolve_test_type(obj) -> dict | None: + return to_ref(obj.test_type) + + @staticmethod + def resolve_engagement(obj) -> dict | None: + return to_ref(obj.engagement) + + @staticmethod + def resolve_product(obj) -> dict | None: + return to_ref(obj.engagement.product) + + @staticmethod + def resolve_product_type(obj) -> dict | None: + return to_ref(obj.engagement.product.prod_type) + + @staticmethod + def resolve_environment(obj) -> dict | None: + return to_ref(obj.environment) + + @staticmethod + def resolve_lead(obj) -> dict | None: + return to_ref(obj.lead) + + @staticmethod + def resolve_tags(obj) -> list[str]: + return [t.name for t in obj.tags.all()] + + +TestSlim.EXPANDABLE = { + "test_type": ExpandRel(attr="test_type", path="test_type", schema=TestTypeSlim), + "engagement": ExpandRel(attr="engagement", path="engagement", schema=EngagementSlim), + "product": ExpandRel(attr="engagement.product", path="engagement__product", schema=ProductSlim), + "product_type": ExpandRel(attr="engagement.product.prod_type", path="engagement__product__prod_type", schema=ProductTypeSlim), + "lead": ExpandRel(attr="lead", path="lead", schema=UserSlim), + "environment": ExpandRel(attr="environment", path="environment", schema=EnvironmentSlim), +} + + +class TestDetail(TestSlim): + + """Slim + the documented heavier read fields (§4.5). Retrieve returns detail; list returns slim.""" + + description: str | None + scan_type: str | None + version: str | None + build_id: str | None + commit_hash: str | None + branch_tag: str | None + + +class TestWrite(Schema): + + """Create payload (POST). ``engagement``/``test_type``/``target_start``/``target_end`` required.""" + + model_config = {"extra": "forbid"} + + engagement: int + test_type: int + target_start: datetime + target_end: datetime + title: str | None = None + description: str | None = None + scan_type: str | None = None + lead: int | None = None + percent_complete: int | None = None + environment: int | None = None + version: str | None = None + build_id: str | None = None + commit_hash: str | None = None + branch_tag: str | None = None + api_scan_configuration: int | None = None + tags: list[str] | None = None + + +class TestUpdate(Schema): + + """Partial update payload (PATCH). ``engagement`` is not writable (editable=False, mirrors v2).""" + + model_config = {"extra": "forbid"} + + test_type: int | None = None + target_start: datetime | None = None + target_end: datetime | None = None + title: str | None = None + description: str | None = None + scan_type: str | None = None + lead: int | None = None + percent_complete: int | None = None + environment: int | None = None + version: str | None = None + build_id: str | None = None + commit_hash: str | None = None + branch_tag: str | None = None + api_scan_configuration: int | None = None + tags: list[str] | None = None diff --git a/unittests/api_v3/snapshots/filters.json b/unittests/api_v3/snapshots/filters.json index 85a08ca113b..73272820162 100644 --- a/unittests/api_v3/snapshots/filters.json +++ b/unittests/api_v3/snapshots/filters.json @@ -1,4 +1,36 @@ { + "engagement": { + "model": "Engagement", + "orderings": [ + "created", + "id", + "name", + "target_start", + "updated" + ], + "params": [ + "created__gte", + "created__lte", + "engagement_type", + "id__in", + "lead", + "name__icontains", + "product", + "product__in", + "product_type", + "status", + "target_end__gte", + "target_end__lte", + "target_start__gte", + "target_start__lte", + "updated__gte", + "updated__lte" + ], + "search_fields": [ + "description", + "name" + ] + }, "finding": { "model": "Finding", "orderings": [ @@ -86,6 +118,40 @@ "name" ] }, + "test": { + "model": "Test", + "orderings": [ + "created", + "id", + "target_start", + "title", + "updated" + ], + "params": [ + "created__gte", + "created__lte", + "engagement", + "engagement__in", + "environment", + "id__in", + "lead", + "product", + "product__in", + "product_type", + "target_end__gte", + "target_end__lte", + "target_start__gte", + "target_start__lte", + "test_type", + "title__icontains", + "updated__gte", + "updated__lte" + ], + "search_fields": [ + "description", + "title" + ] + }, "user": { "model": "Dojo_User", "orderings": [ @@ -115,4 +181,3 @@ ] } } - diff --git a/unittests/api_v3/test_apiv3_engagements.py b/unittests/api_v3/test_apiv3_engagements.py new file mode 100644 index 00000000000..056f2ec1b4d --- /dev/null +++ b/unittests/api_v3/test_apiv3_engagements.py @@ -0,0 +1,234 @@ +"""Engagement CRUD + RBAC + contract tests for API v3 (OS3b).""" +from __future__ import annotations + +import datetime + +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from dojo.engagement.api_v3.schemas import EngagementSlim +from dojo.finding.api_v3 import schemas as finding_schemas +from dojo.models import Dojo_User, Engagement, Product, User + +from .base import ApiV3TestCase + +_SLIM_KEYS = { + "id", "name", "product", "product_type", "lead", "status", "engagement_type", + "target_start", "target_end", "active", "tags", "created", "updated", +} + + +class TestApiV3EngagementsRelocation(ApiV3TestCase): + + def test_engagement_slim_is_canonical_single_class(self): + # OS3b relocated EngagementSlim out of the finding module; the finding module now re-exports + # the one canonical class (is-identity, mirroring the OS3a relocation pattern -- §12). + self.assertIs(EngagementSlim, finding_schemas.EngagementSlim) + + +class TestApiV3EngagementsRead(ApiV3TestCase): + + def test_list_envelope_and_slim_shape(self): + body = self.get_json("engagements") + self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) + self.assertGreater(body["count"], 0) + row = body["results"][0] + self.assertEqual(_SLIM_KEYS, set(row)) + self.assertEqual({"id", "name"}, set(row["product"])) + self.assertEqual({"id", "name"}, set(row["product_type"])) + self.assertIsInstance(row["tags"], list) + + def test_detail_adds_heavy_fields(self): + engagement = Engagement.objects.first() + detail = self.get_json(f"engagements/{engagement.id}") + for key in ("description", "version", "first_contacted", "threat_model", "deduplication_on_engagement"): + self.assertIn(key, detail) + + def test_detail_unknown_is_404_problem(self): + response = self.client.get(self.v3_url("engagements/99999999")) + self.assertEqual(404, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_expand_product_inlines_slim(self): + row = self.get_json("engagements", data={"expand": "product"})["results"][0] + self.assertIn("description", row["product"]) + self.assertIn("lifecycle", row["product"]) + + def test_expand_lead_and_product_type(self): + eng = Engagement.objects.exclude(lead__isnull=True).first() + if eng is None: + eng = Engagement.objects.first() + eng.lead = self.admin + eng.save() + row = self.get_json("engagements", data={"expand": "lead,product_type", "id__in": eng.id})["results"][0] + self.assertIn("username", row["lead"]) + self.assertIn("critical_product", row["product_type"]) + + def test_expand_unknown_relation_is_400(self): + self.get_json("engagements", data={"expand": "not_a_relation"}, expected=400) + + +class TestApiV3EngagementsFilters(ApiV3TestCase): + + def test_filter_product(self): + product_id = Engagement.objects.first().product_id + body = self.get_json("engagements", data={"product": product_id, "limit": 250}) + self.assertGreater(body["count"], 0) + for row in body["results"]: + self.assertEqual(product_id, row["product"]["id"]) + + def test_filter_status(self): + eng = Engagement.objects.first() + body = self.get_json("engagements", data={"status": eng.status, "limit": 250}) + for row in body["results"]: + self.assertEqual(eng.status, row["status"]) + + def test_ordering_by_id(self): + ids = [r["id"] for r in self.get_json("engagements", data={"o": "id", "limit": 250})["results"]] + self.assertEqual(ids, sorted(ids)) + + def test_unknown_filter_param_is_400(self): + self.get_json("engagements", data={"not_a_filter": "x"}, expected=400) + + +class TestApiV3EngagementsPagination(ApiV3TestCase): + + def test_limit_next_previous(self): + body = self.get_json("engagements", data={"limit": 2, "offset": 2}) + self.assertLessEqual(len(body["results"]), 2) + self.assertIsNotNone(body["previous"]) + + +class TestApiV3EngagementsQueryCount(ApiV3TestCase): + + def _bulk(self, count: int, start: int) -> None: + product = Product.objects.first() + day = datetime.date(2024, 1, 1) + Engagement.objects.bulk_create([ + Engagement(name=f"qcount engagement {start + i}", product=product, target_start=day, target_end=day) + for i in range(count) + ]) + + def _query_count(self, params: dict) -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url("engagements"), params) + self.assertEqual(200, response.status_code, response.content[:500]) + return len(ctx.captured_queries) + + def test_query_count_is_independent_of_row_count(self): + self._bulk(10, 0) + first = self._query_count({"limit": 250}) + first_expand = self._query_count({"limit": 250, "expand": "product.product_type"}) + self._bulk(90, 100) + second = self._query_count({"limit": 250}) + second_expand = self._query_count({"limit": 250, "expand": "product.product_type"}) + self.assertEqual(first, second, f"query count grew (no expand): {first} -> {second}") + self.assertEqual(first_expand, second_expand, f"query count grew (expand): {first_expand} -> {second_expand}") + + +class TestApiV3EngagementsWrite(ApiV3TestCase): + + def test_create_happy_path(self): + product = Product.objects.first() + response = self.client.post( + self.v3_url("engagements"), + {"name": "v3 created engagement", "product": product.id, + "target_start": "2024-01-01", "target_end": "2024-02-01", + "status": "In Progress", "tags": ["v3"]}, + format="json", + ) + self.assertEqual(201, response.status_code, response.content[:500]) + body = response.json() + self.assertEqual("v3 created engagement", body["name"]) + self.assertEqual(product.id, body["product"]["id"]) + created = Engagement.objects.get(name="v3 created engagement") + self.assertEqual({"v3"}, {t.name for t in created.tags.all()}) + + def test_create_target_start_after_end_is_400(self): + product = Product.objects.first() + response = self.client.post( + self.v3_url("engagements"), + {"name": "bad dates", "product": product.id, + "target_start": "2024-02-01", "target_end": "2024-01-01"}, + format="json", + ) + self.assertEqual(400, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_create_missing_required_is_400(self): + response = self.client.post(self.v3_url("engagements"), {"name": "no product"}, format="json") + self.assertEqual(400, response.status_code) + + def test_create_unknown_field_is_400(self): + product = Product.objects.first() + response = self.client.post( + self.v3_url("engagements"), + {"product": product.id, "target_start": "2024-01-01", "target_end": "2024-02-01", "bogus": 1}, + format="json", + ) + self.assertEqual(400, response.status_code) + + def test_create_nonexistent_product_is_404(self): + response = self.client.post( + self.v3_url("engagements"), + {"product": 99999999, "target_start": "2024-01-01", "target_end": "2024-02-01"}, + format="json", + ) + self.assertEqual(404, response.status_code) + + def test_patch_partial_update(self): + product = Product.objects.first() + eng = Engagement.objects.create( + name="v3 patch engagement", product=product, + target_start=datetime.date(2024, 1, 1), target_end=datetime.date(2024, 2, 1), + ) + response = self.client.patch( + self.v3_url(f"engagements/{eng.id}"), {"name": "renamed"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + eng.refresh_from_db() + self.assertEqual("renamed", eng.name) + + def test_delete(self): + product = Product.objects.first() + eng = Engagement.objects.create( + name="v3 delete engagement", product=product, + target_start=datetime.date(2024, 1, 1), target_end=datetime.date(2024, 2, 1), + ) + response = self.client.delete(self.v3_url(f"engagements/{eng.id}")) + self.assertEqual(204, response.status_code) + self.assertFalse(Engagement.objects.filter(pk=eng.id).exists()) + + +class TestApiV3EngagementsRbac(ApiV3TestCase): + + def setUp(self): + super().setUp() + self.limited = User.objects.create_user(username="v3_eng_limited", password="x") # noqa: S106 + self.member = Dojo_User.objects.create_user(username="v3_eng_member", password="x") # noqa: S106 + self.engagement = Engagement.objects.first() + self.product = self.engagement.product + self.product.authorized_users.add(self.member) + + def test_unauthorized_read_is_404(self): + client = self.token_client(user=self.limited) + self.assertEqual(0, self.get_json("engagements", client=client)["count"]) + self.get_json(f"engagements/{self.engagement.id}", client=client, expected=404) + + def test_create_without_product_add_is_403(self): + client = self.token_client(user=self.limited) + response = client.post( + self.v3_url("engagements"), + {"product": self.product.id, "target_start": "2024-01-01", "target_end": "2024-02-01"}, + format="json", + ) + self.assertEqual(403, response.status_code, response.content[:300]) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_member_can_view_but_delete_is_403(self): + # OS legacy RBAC: membership grants view+edit+add, but delete is staff-only -- so the + # "authorized-to-see-but-not-modify" 403 is demonstrated on delete (§12). + client = self.token_client(user=self.member) + self.get_json(f"engagements/{self.engagement.id}", client=client) + response = client.delete(self.v3_url(f"engagements/{self.engagement.id}")) + self.assertEqual(403, response.status_code, response.content[:300]) diff --git a/unittests/api_v3/test_apiv3_finding_writes.py b/unittests/api_v3/test_apiv3_finding_writes.py new file mode 100644 index 00000000000..9c191ffc7f7 --- /dev/null +++ b/unittests/api_v3/test_apiv3_finding_writes.py @@ -0,0 +1,230 @@ +""" +Finding write-path tests for API v3 (OS3b, PART 2/3). + +Exercises the ``dojo/finding/services.py`` extraction (D7) via the v3 routes: create/update/delete +happy paths, side-effect assertions with JIRA **mocked** (the service imports ``jira_services`` as +``dojo.finding.services.jira_services`` -- patched here), vulnerability-id persistence incl. the +``cve`` mirror field, CWE handling, the mitigated-edit rules in both ``EDITABLE_MITIGATED_DATA`` +states, risk-acceptance invariants, and RBAC (404 unauthorized, 403 not-modifiable). +""" +from __future__ import annotations + +from unittest.mock import patch + +from django.test import override_settings + +from dojo.models import Dojo_User, Finding, Finding_CWE, Test, User, Vulnerability_Id + +from .base import ApiV3TestCase + +_PUSH = "dojo.finding.services.jira_services.push" +_KEEP_IN_SYNC = "dojo.finding.services.jira_services.is_keep_in_sync" + + +def _finding_payload(test_id: int, **overrides) -> dict: + payload = { + "test": test_id, + "title": "v3 write finding", + "severity": "High", + "description": "created via api v3", + "active": True, + "verified": False, + } + payload.update(overrides) + return payload + + +class TestApiV3FindingCreate(ApiV3TestCase): + + def setUp(self): + super().setUp() + self.test = Test.objects.first() + + def test_create_happy_path(self): + response = self.client.post(self.v3_url("findings"), _finding_payload(self.test.id), format="json") + self.assertEqual(201, response.status_code, response.content[:500]) + body = response.json() + self.assertEqual("V3 Write Finding", body["title"]) # title-cased on save + self.assertEqual(self.test.id, body["test"]["id"]) + created = Finding.objects.get(pk=body["id"]) + self.assertEqual(self.admin, created.reporter) # reporter defaults to the request user + + def test_create_persists_vulnerability_ids_and_cve(self): + payload = _finding_payload(self.test.id, vulnerability_ids=["CVE-2020-1234", "CVE-2020-5678"]) + response = self.client.post(self.v3_url("findings"), payload, format="json") + self.assertEqual(201, response.status_code, response.content[:500]) + created = Finding.objects.get(pk=response.json()["id"]) + vids = set(Vulnerability_Id.objects.filter(finding=created).values_list("vulnerability_id", flat=True)) + self.assertEqual({"CVE-2020-1234", "CVE-2020-5678"}, vids) + self.assertEqual("CVE-2020-1234", created.cve) # first vuln id mirrored into cve + + def test_create_persists_cwe_row(self): + response = self.client.post(self.v3_url("findings"), _finding_payload(self.test.id, cwe=79), format="json") + self.assertEqual(201, response.status_code, response.content[:500]) + created = Finding.objects.get(pk=response.json()["id"]) + self.assertEqual(79, created.cwe) + self.assertTrue(Finding_CWE.objects.filter(finding=created, cwe="CWE-79").exists()) + + def test_create_duplicate_active_invariant_is_400(self): + payload = _finding_payload(self.test.id, active=True, duplicate=True) + response = self.client.post(self.v3_url("findings"), payload, format="json") + self.assertEqual(400, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_create_false_positive_verified_invariant_is_400(self): + payload = _finding_payload(self.test.id, verified=True, false_p=True) + response = self.client.post(self.v3_url("findings"), payload, format="json") + self.assertEqual(400, response.status_code) + + def test_create_bad_severity_is_400(self): + payload = _finding_payload(self.test.id, severity="Catastrophic") + response = self.client.post(self.v3_url("findings"), payload, format="json") + self.assertEqual(400, response.status_code) + + def test_create_push_to_jira_calls_service(self): + with patch(_PUSH) as push: + payload = _finding_payload(self.test.id, push_to_jira=True) + response = self.client.post(self.v3_url("findings"), payload, format="json") + self.assertEqual(201, response.status_code, response.content[:500]) + push.assert_called_once() + + +class TestApiV3FindingUpdate(ApiV3TestCase): + + def setUp(self): + super().setUp() + self.finding = Finding.objects.filter(risk_accepted=False).first() + + def test_update_scalar(self): + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), {"severity": "Low"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.finding.refresh_from_db() + self.assertEqual("Low", self.finding.severity) + + def test_update_vulnerability_ids(self): + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), + {"vulnerability_ids": ["CVE-2019-9999"]}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.finding.refresh_from_db() + self.assertEqual("CVE-2019-9999", self.finding.cve) + self.assertTrue(Vulnerability_Id.objects.filter(finding=self.finding, vulnerability_id="CVE-2019-9999").exists()) + + def test_update_cwe_resyncs_finding_cwe(self): + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), {"cwe": 89}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.finding.refresh_from_db() + self.assertEqual(89, self.finding.cwe) + self.assertTrue(Finding_CWE.objects.filter(finding=self.finding, cwe="CWE-89").exists()) + + def test_update_duplicate_active_invariant_is_400(self): + self.finding.active = True + self.finding.save() + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), {"duplicate": True}, format="json", + ) + self.assertEqual(400, response.status_code) + + def test_update_risk_accept_disabled_is_400(self): + product = self.finding.test.engagement.product + product.enable_simple_risk_acceptance = False + product.save() + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), + {"risk_accepted": True, "active": False}, format="json", + ) + self.assertEqual(400, response.status_code) + + def test_update_active_and_risk_accepted_is_400(self): + product = self.finding.test.engagement.product + product.enable_simple_risk_acceptance = True + product.save() + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), + {"risk_accepted": True, "active": True}, format="json", + ) + self.assertEqual(400, response.status_code) + + @override_settings(EDITABLE_MITIGATED_DATA=False) + def test_update_mitigated_blocked_when_disabled(self): + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), + {"mitigated": "2024-01-01T00:00:00Z"}, format="json", + ) + self.assertEqual(400, response.status_code) + + @override_settings(EDITABLE_MITIGATED_DATA=True) + def test_update_mitigated_allowed_for_superuser_when_enabled(self): + # admin is a superuser; can_edit_mitigated_data requires EDITABLE_MITIGATED_DATA + superuser. + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), + {"mitigated": "2024-01-01T00:00:00Z"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + + def test_update_keep_in_sync_pushes_synchronously(self): + with patch(_KEEP_IN_SYNC, return_value=True), patch(_PUSH, return_value=(True, "ok")) as push: + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), {"severity": "Medium"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + push.assert_called_once() + self.assertTrue(push.call_args.kwargs.get("force_sync")) + + def test_update_jira_push_failure_is_400(self): + with patch(_KEEP_IN_SYNC, return_value=False), patch(_PUSH, return_value=(False, "jira exploded")): + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), + {"severity": "Medium", "push_to_jira": True}, format="json", + ) + self.assertEqual(400, response.status_code) + + +class TestApiV3FindingDelete(ApiV3TestCase): + + def test_delete(self): + finding = Finding.objects.first() + response = self.client.delete(self.v3_url(f"findings/{finding.id}")) + self.assertEqual(204, response.status_code) + self.assertFalse(Finding.objects.filter(pk=finding.id).exists()) + + +class TestApiV3FindingWriteRbac(ApiV3TestCase): + + def setUp(self): + super().setUp() + self.limited = User.objects.create_user(username="v3_fw_limited", password="x") # noqa: S106 + self.member = Dojo_User.objects.create_user(username="v3_fw_member", password="x") # noqa: S106 + self.finding = Finding.objects.first() + self.product = self.finding.test.engagement.product + self.product.authorized_users.add(self.member) + + def test_unauthorized_finding_update_is_404(self): + client = self.token_client(user=self.limited) + response = client.patch(self.v3_url(f"findings/{self.finding.id}"), {"severity": "Low"}, format="json") + self.assertEqual(404, response.status_code) + + def test_unauthorized_finding_delete_is_404(self): + client = self.token_client(user=self.limited) + response = client.delete(self.v3_url(f"findings/{self.finding.id}")) + self.assertEqual(404, response.status_code) + + def test_create_on_unauthorized_test_is_403(self): + client = self.token_client(user=self.limited) + response = client.post( + self.v3_url("findings"), _finding_payload(self.finding.test_id), format="json", + ) + self.assertEqual(403, response.status_code, response.content[:300]) + + def test_member_can_view_but_delete_is_403(self): + # OS legacy RBAC: membership grants view+edit; delete is staff-only, so the not-modifiable + # 403 is demonstrated on delete (Finding_Edit is not staff-gated in OS legacy -- §12). + client = self.token_client(user=self.member) + self.get_json(f"findings/{self.finding.id}", client=client) + response = client.delete(self.v3_url(f"findings/{self.finding.id}")) + self.assertEqual(403, response.status_code, response.content[:300]) diff --git a/unittests/api_v3/test_apiv3_tests.py b/unittests/api_v3/test_apiv3_tests.py new file mode 100644 index 00000000000..6f7f73d9c5e --- /dev/null +++ b/unittests/api_v3/test_apiv3_tests.py @@ -0,0 +1,239 @@ +"""Test (scan run) CRUD + RBAC + contract tests for API v3 (OS3b).""" +from __future__ import annotations + +import datetime + +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from dojo.finding.api_v3 import schemas as finding_schemas +from dojo.models import Dojo_User, Engagement, Test, Test_Type, User +from dojo.test.api_v3.schemas import EnvironmentSlim, TestSlim, TestTypeSlim + +from .base import ApiV3TestCase + +_SLIM_KEYS = { + "id", "name", "test_type", "engagement", "product", "product_type", "environment", + "lead", "target_start", "target_end", "percent_complete", "tags", "created", "updated", +} + + +class TestApiV3TestsRelocation(ApiV3TestCase): + + def test_test_slims_are_canonical_single_classes(self): + # OS3b relocated TestSlim/TestTypeSlim/EnvironmentSlim out of the finding module; the finding + # module now re-exports the one canonical class each (is-identity -- §12 relocation pattern). + self.assertIs(TestSlim, finding_schemas.TestSlim) + self.assertIs(TestTypeSlim, finding_schemas.TestTypeSlim) + self.assertIs(EnvironmentSlim, finding_schemas.EnvironmentSlim) + + +class TestApiV3TestsRead(ApiV3TestCase): + + def test_list_envelope_and_slim_shape(self): + body = self.get_json("tests") + self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) + self.assertGreater(body["count"], 0) + row = body["results"][0] + self.assertEqual(_SLIM_KEYS, set(row)) + self.assertEqual({"id", "name"}, set(row["test_type"])) + self.assertEqual({"id", "name"}, set(row["engagement"])) + self.assertIsInstance(row["tags"], list) + + def test_detail_adds_heavy_fields(self): + test = Test.objects.first() + detail = self.get_json(f"tests/{test.id}") + for key in ("description", "scan_type", "version", "build_id", "commit_hash", "branch_tag"): + self.assertIn(key, detail) + + def test_detail_unknown_is_404_problem(self): + response = self.client.get(self.v3_url("tests/99999999")) + self.assertEqual(404, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_expand_engagement_and_test_type(self): + row = self.get_json("tests", data={"expand": "engagement,test_type"})["results"][0] + self.assertIn("status", row["engagement"]) + self.assertIn("active", row["test_type"]) + + def test_expand_unknown_relation_is_400(self): + self.get_json("tests", data={"expand": "not_a_relation"}, expected=400) + + +class TestApiV3TestsFilters(ApiV3TestCase): + + def test_filter_engagement(self): + engagement_id = Test.objects.first().engagement_id + body = self.get_json("tests", data={"engagement": engagement_id, "limit": 250}) + self.assertGreater(body["count"], 0) + for row in body["results"]: + self.assertEqual(engagement_id, row["engagement"]["id"]) + + def test_filter_test_type(self): + tt_id = Test.objects.first().test_type_id + body = self.get_json("tests", data={"test_type": tt_id, "limit": 250}) + for row in body["results"]: + self.assertEqual(tt_id, row["test_type"]["id"]) + + def test_ordering_by_id(self): + ids = [r["id"] for r in self.get_json("tests", data={"o": "id", "limit": 250})["results"]] + self.assertEqual(ids, sorted(ids)) + + def test_unknown_filter_param_is_400(self): + self.get_json("tests", data={"not_a_filter": "x"}, expected=400) + + +class TestApiV3TestsPagination(ApiV3TestCase): + + def test_limit_next_previous(self): + body = self.get_json("tests", data={"limit": 2, "offset": 2}) + self.assertLessEqual(len(body["results"]), 2) + self.assertIsNotNone(body["previous"]) + + +class TestApiV3TestsQueryCount(ApiV3TestCase): + + def _bulk(self, count: int, start: int) -> None: + engagement = Engagement.objects.first() + test_type = Test_Type.objects.first() + now = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) + Test.objects.bulk_create([ + Test(engagement=engagement, test_type=test_type, target_start=now, target_end=now) + for _ in range(count) + ]) + + def _query_count(self, params: dict) -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url("tests"), params) + self.assertEqual(200, response.status_code, response.content[:500]) + return len(ctx.captured_queries) + + def test_query_count_is_independent_of_row_count(self): + self._bulk(10, 0) + first = self._query_count({"limit": 250}) + first_expand = self._query_count({"limit": 250, "expand": "engagement.product,test_type"}) + self._bulk(90, 100) + second = self._query_count({"limit": 250}) + second_expand = self._query_count({"limit": 250, "expand": "engagement.product,test_type"}) + self.assertEqual(first, second, f"query count grew (no expand): {first} -> {second}") + self.assertEqual(first_expand, second_expand, f"query count grew (expand): {first_expand} -> {second_expand}") + + +class TestApiV3TestsWrite(ApiV3TestCase): + + def test_create_happy_path(self): + engagement = Engagement.objects.first() + test_type = Test_Type.objects.first() + response = self.client.post( + self.v3_url("tests"), + {"engagement": engagement.id, "test_type": test_type.id, + "target_start": "2024-01-01T00:00:00Z", "target_end": "2024-01-02T00:00:00Z", + "title": "v3 created test", "tags": ["v3"]}, + format="json", + ) + self.assertEqual(201, response.status_code, response.content[:500]) + body = response.json() + self.assertEqual("v3 created test", body["name"]) + self.assertEqual(engagement.id, body["engagement"]["id"]) + created = Test.objects.get(title="v3 created test") + self.assertEqual({"v3"}, {t.name for t in created.tags.all()}) + + def test_create_bad_test_type_is_400(self): + engagement = Engagement.objects.first() + response = self.client.post( + self.v3_url("tests"), + {"engagement": engagement.id, "test_type": 99999999, + "target_start": "2024-01-01T00:00:00Z", "target_end": "2024-01-02T00:00:00Z"}, + format="json", + ) + self.assertEqual(400, response.status_code) + + def test_create_missing_required_is_400(self): + response = self.client.post(self.v3_url("tests"), {"title": "no engagement"}, format="json") + self.assertEqual(400, response.status_code) + + def test_create_unknown_field_is_400(self): + engagement = Engagement.objects.first() + test_type = Test_Type.objects.first() + response = self.client.post( + self.v3_url("tests"), + {"engagement": engagement.id, "test_type": test_type.id, + "target_start": "2024-01-01T00:00:00Z", "target_end": "2024-01-02T00:00:00Z", "bogus": 1}, + format="json", + ) + self.assertEqual(400, response.status_code) + + def test_create_nonexistent_engagement_is_404(self): + test_type = Test_Type.objects.first() + response = self.client.post( + self.v3_url("tests"), + {"engagement": 99999999, "test_type": test_type.id, + "target_start": "2024-01-01T00:00:00Z", "target_end": "2024-01-02T00:00:00Z"}, + format="json", + ) + self.assertEqual(404, response.status_code) + + def test_patch_partial_update(self): + test = Test.objects.first() + response = self.client.patch( + self.v3_url(f"tests/{test.id}"), {"title": "renamed test"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + test.refresh_from_db() + self.assertEqual("renamed test", test.title) + + def test_patch_engagement_is_rejected_as_unknown_field(self): + # engagement is editable=False on the model -> not part of the update schema (mirrors v2). + test = Test.objects.first() + other = Engagement.objects.exclude(pk=test.engagement_id).first() + response = self.client.patch( + self.v3_url(f"tests/{test.id}"), {"engagement": other.id}, format="json", + ) + self.assertEqual(400, response.status_code) + + def test_delete(self): + engagement = Engagement.objects.first() + test_type = Test_Type.objects.first() + test = Test.objects.create( + engagement=engagement, test_type=test_type, + target_start=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), + target_end=datetime.datetime(2024, 1, 2, tzinfo=datetime.UTC), + ) + response = self.client.delete(self.v3_url(f"tests/{test.id}")) + self.assertEqual(204, response.status_code) + self.assertFalse(Test.objects.filter(pk=test.id).exists()) + + +class TestApiV3TestsRbac(ApiV3TestCase): + + def setUp(self): + super().setUp() + self.limited = User.objects.create_user(username="v3_test_limited", password="x") # noqa: S106 + self.member = Dojo_User.objects.create_user(username="v3_test_member", password="x") # noqa: S106 + self.test = Test.objects.first() + self.product = self.test.engagement.product + self.product.authorized_users.add(self.member) + + def test_unauthorized_read_is_404(self): + client = self.token_client(user=self.limited) + self.assertEqual(0, self.get_json("tests", client=client)["count"]) + self.get_json(f"tests/{self.test.id}", client=client, expected=404) + + def test_create_without_engagement_add_is_403(self): + client = self.token_client(user=self.limited) + test_type = Test_Type.objects.first() + response = client.post( + self.v3_url("tests"), + {"engagement": self.test.engagement_id, "test_type": test_type.id, + "target_start": "2024-01-01T00:00:00Z", "target_end": "2024-01-02T00:00:00Z"}, + format="json", + ) + self.assertEqual(403, response.status_code, response.content[:300]) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_member_can_view_but_delete_is_403(self): + # OS legacy RBAC: membership grants view+edit+add; delete is staff-only (§12). + client = self.token_client(user=self.member) + self.get_json(f"tests/{self.test.id}", client=client) + response = client.delete(self.v3_url(f"tests/{self.test.id}")) + self.assertEqual(403, response.status_code, response.content[:300]) From acf25accf465ff161ab98b6dcfd7fc3a7dd27e66 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 13:48:33 +0200 Subject: [PATCH 05/37] test(api-v3): whole-surface N+1 query sweep + report harness - query_report.py: captures per-request SQL, normalizes literals, flags the N+1 signature (same query shape >= 4x in one request) - test_apiv3_query_report.py: sweeps every mounted v3 GET route with fanned-out rows so per-row queries cannot hide; OpenAPI completeness check forces every new GET endpoint to be query-profiled; writes /tmp/apiv3_query_report.md artifact - current surface: zero N+1 flags 198 tests green. --- unittests/api_v3/query_report.py | 109 +++++++++++++++++ unittests/api_v3/test_apiv3_query_report.py | 122 ++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 unittests/api_v3/query_report.py create mode 100644 unittests/api_v3/test_apiv3_query_report.py diff --git a/unittests/api_v3/query_report.py b/unittests/api_v3/query_report.py new file mode 100644 index 00000000000..98ae7e41a73 --- /dev/null +++ b/unittests/api_v3/query_report.py @@ -0,0 +1,109 @@ +""" +Query-capture harness for API v3 (§7 of the plan / OS6 verification). + +Captures every SQL query a v3 request executes and detects the N+1 signature: the same +*normalized* query shape executed many times within one request. Complements the per-list +``assertNumQueries`` tests (which pin totals) by identifying *which* query repeats when a +regression appears, and by sweeping the whole mounted surface in one place +(``test_apiv3_query_report.py``) so new resources are covered automatically. + +Also usable interactively from a shell (``manage.py shell`` + test client) to profile an +endpoint; ``format_report`` renders the capture as markdown. +""" +from __future__ import annotations + +import re +from collections import Counter +from dataclasses import dataclass, field + +from django.db import connection +from django.test.utils import CaptureQueriesContext + +# Normalization: collapse literals so structurally-identical queries group together. +_NORMALIZERS = ( + (re.compile(r"'(?:[^']|'')*'"), "'?'"), # string literals + (re.compile(r"\b\d+(?:\.\d+)?\b"), "N"), # numeric literals + (re.compile(r"IN \([^)]*\)", re.IGNORECASE), "IN (...)"), # IN lists (prefetch batches) + (re.compile(r"\s+"), " "), # whitespace +) + +# Shapes that legitimately repeat and are never an N+1 signal. +_IGNORED_SHAPES = ( + re.compile(r"^(SAVEPOINT|RELEASE SAVEPOINT|ROLLBACK TO SAVEPOINT)", re.IGNORECASE), +) + + +def normalize_sql(sql: str) -> str: + for pattern, replacement in _NORMALIZERS: + sql = pattern.sub(replacement, sql) + return sql.strip() + + +@dataclass +class EndpointCapture: + + """One request's query profile.""" + + label: str + path: str + status_code: int + query_count: int + result_rows: int | None + shapes: Counter = field(default_factory=Counter) + + def repeated_shapes(self, threshold: int) -> list[tuple[str, int]]: + """Normalized shapes executed >= threshold times, excluding known-benign ones.""" + flagged = [] + for shape, count in self.shapes.most_common(): + if count < threshold: + break + if any(p.search(shape) for p in _IGNORED_SHAPES): + continue + flagged.append((shape, count)) + return flagged + + +def capture_request(client, label: str, path: str) -> EndpointCapture: + """Execute a GET through the in-process client and capture its query profile.""" + with CaptureQueriesContext(connection) as ctx: + response = client.get(path) + shapes = Counter(normalize_sql(q["sql"]) for q in ctx.captured_queries) + rows = None + if response.status_code == 200: + try: + body = response.json() + rows = len(body["results"]) if isinstance(body, dict) and "results" in body else None + except ValueError: + rows = None + return EndpointCapture( + label=label, + path=path, + status_code=response.status_code, + query_count=len(ctx.captured_queries), + result_rows=rows, + shapes=shapes, + ) + + +def format_report(captures: list[EndpointCapture], threshold: int) -> str: + """Render captures as a markdown report (written to /tmp by the sweep test).""" + lines = [ + "# API v3 query report", + "", + f"N+1 flag threshold: same normalized query shape >= {threshold}x in one request.", + "", + "| endpoint | status | queries | rows | flagged shapes |", + "|---|---|---:|---:|---|", + ] + for cap in captures: + flagged = cap.repeated_shapes(threshold) + flag_text = "; ".join(f"{count}x `{shape[:80]}...`" for shape, count in flagged) if flagged else "-" + lines.append( + f"| {cap.label} | {cap.status_code} | {cap.query_count} " + f"| {cap.result_rows if cap.result_rows is not None else '-'} | {flag_text} |", + ) + lines.append("") + for cap in captures: + for shape, count in cap.repeated_shapes(threshold): + lines += [f"## {cap.label}: {count}x", "", "```sql", shape, "```", ""] + return "\n".join(lines) diff --git a/unittests/api_v3/test_apiv3_query_report.py b/unittests/api_v3/test_apiv3_query_report.py new file mode 100644 index 00000000000..c6b495905c2 --- /dev/null +++ b/unittests/api_v3/test_apiv3_query_report.py @@ -0,0 +1,122 @@ +""" +Whole-surface query sweep for API v3 (§7 / OS6 verification). + +Two guarantees on every mounted v3 GET endpoint: + +1. **No N+1 signature** — no normalized query shape repeats >= ``_THRESHOLD`` times within one + request (the per-list ``assertNumQueries`` tests pin totals; this test identifies the shape + when something regresses, across the whole surface at once). +2. **Completeness** — every GET path in the OpenAPI schema must have a representative request + below. Adding a resource without extending the sweep fails the test, so OS4/OS5+ endpoints + are covered by construction. + +The full capture is always written to ``/tmp/apiv3_query_report.md`` for review. +""" +from __future__ import annotations + +from pathlib import Path + +from django.conf import settings + +from dojo.api_v3.api import api_v3 +from dojo.models import Engagement, Finding, Product, Product_Type, Test + +from .base import ApiV3TestCase +from .query_report import capture_request, format_report + +_THRESHOLD = 4 +_REPORT_PATH = Path("/tmp/apiv3_query_report.md") +# POST-only paths (no GET operation) are exempt from the completeness check. +_ROW_FANOUT = 15 # enough rows that any per-row query trips the threshold unmistakably + + +class TestApiV3QueryReport(ApiV3TestCase): + + """Sweep every mounted v3 GET route; flag N+1 shapes; enforce sweep completeness.""" + + def setUp(self): + super().setUp() # base gives self.client = admin token client: RBAC never hides rows here + self._fan_out_rows() + + def _fan_out_rows(self): + """Clone fixture rows so per-row queries repeat >= threshold and cannot hide.""" + finding = Finding.objects.first() + for i in range(_ROW_FANOUT): + clone = Finding.objects.get(pk=finding.pk) + clone.pk = None + clone.title = f"query-report fanout {i}" + clone.save() + prod_type = Product_Type.objects.first() + for i in range(_ROW_FANOUT): + Product_Type.objects.create(name=f"query-report pt {i}") + for i in range(_ROW_FANOUT): + Product.objects.create( + name=f"query-report product {i}", + description="query report fanout", + prod_type=prod_type, + ) + for model in (Engagement, Test): + template = model.objects.first() + if template is None: + continue + for _ in range(_ROW_FANOUT): + clone = model.objects.get(pk=template.pk) + clone.pk = None + clone.save() + + def _representative_requests(self) -> dict[str, str]: + """OpenAPI GET path -> concrete request URL. Extend when a phase adds endpoints.""" + finding_id = Finding.objects.first().pk + product_id = Product.objects.first().pk + product_type_id = Product_Type.objects.first().pk + user_id = self.admin.pk + limit = f"limit={_ROW_FANOUT + 5}" + return { + "/findings": self.v3_url(f"findings?{limit}&expand=test.engagement,locations&include=counts"), + "/findings/{finding_id}": self.v3_url(f"findings/{finding_id}?expand=test.engagement"), # noqa: RUF027 -- OpenAPI path template key, not an f-string + "/products": self.v3_url(f"products?{limit}&expand=product_type"), + "/products/{product_id}": self.v3_url(f"products/{product_id}"), # noqa: RUF027 -- OpenAPI path template key, not an f-string + "/product_types": self.v3_url(f"product_types?{limit}"), + "/product_types/{product_type_id}": self.v3_url(f"product_types/{product_type_id}"), # noqa: RUF027 -- OpenAPI path template key, not an f-string + "/users": self.v3_url(f"users?{limit}"), + "/users/{user_id}": self.v3_url(f"users/{user_id}"), # noqa: RUF027 -- OpenAPI path template key, not an f-string + "/engagements": self.v3_url(f"engagements?{limit}"), + "/engagements/{engagement_id}": self.v3_url(f"engagements/{Engagement.objects.first().pk}"), + "/tests": self.v3_url(f"tests?{limit}"), + "/tests/{test_id}": self.v3_url(f"tests/{Test.objects.first().pk}"), + } + + def _openapi_get_paths(self) -> set[str]: + schema = api_v3.get_openapi_schema() + # Schema paths carry the mount prefix (/api/v3-alpha/...); compare mount-relative. + return { + "/" + path.split(settings.API_V3_URL_PREFIX, 1)[-1].lstrip("/") + for path, ops in schema["paths"].items() + if "get" in ops + } + + def test_no_n_plus_one_across_surface(self): + requests = self._representative_requests() + + missing = self._openapi_get_paths() - set(requests) + self.assertFalse( + missing, + f"GET endpoint(s) {sorted(missing)} have no representative request in the query " + f"sweep — add one to _representative_requests() (this is deliberate: every new " + f"endpoint must be query-profiled).", + ) + + captures = [capture_request(self.client, path, url) for path, url in requests.items()] + _REPORT_PATH.write_text(format_report(captures, _THRESHOLD), encoding="utf-8") + + failures = [] + for cap in captures: + self.assertEqual(cap.status_code, 200, f"{cap.label} -> {cap.status_code}") + for shape, count in cap.repeated_shapes(_THRESHOLD): + failures.append(f"{cap.label}: {count}x {shape[:160]}") + self.assertFalse( + failures, + "N+1 signature detected (same query shape repeated within one request):\n" + + "\n".join(failures) + + f"\nFull report: {_REPORT_PATH}", + ) From c3f73bcbdae11be615d165e9466acd39cd282183 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 14:15:22 +0200 Subject: [PATCH 06/37] feat(api-v3): OS4 - locations resource + edge sub-resources - GET /locations, /locations/{id} (read-only; superuser gate mirrors v2 LocationViewSet, rows via get_authorized_locations as the future seam) - GET /findings/{id}/locations and /products/{id}/locations: edge rows with status (+audit_time/auditor for findings; product edge has no audit columns), parent-inherited authorization, constant query count - auditor ref added to expand=locations edge rows (closes OS2 deferral) - ?fields= allowlist now includes expandable keys, so expand=locations&fields=id,title,locations works (OS2 open question) - flag-off test: V3_FEATURE_LOCATIONS=False unmounts all of /api/v3-alpha/ (in-process URLconf reload) - query sweep extended with the 4 new endpoints, zero N+1 flags 229 tests green (198 prior + 31 new). --- dojo/api_v3/api.py | 8 + dojo/api_v3/expand.py | 12 + dojo/engagement/api_v3/routes.py | 6 +- dojo/finding/api_v3/routes.py | 6 +- dojo/finding/api_v3/schemas.py | 11 +- dojo/location/api_v3/__init__.py | 0 dojo/location/api_v3/routes.py | 186 +++++++++++ dojo/location/api_v3/schemas.py | 207 +++++++++++++ dojo/product/api_v3/routes.py | 6 +- dojo/product_type/api_v3/routes.py | 6 +- dojo/test/api_v3/routes.py | 6 +- dojo/user/api_v3/routes.py | 6 +- unittests/api_v3/snapshots/filters.json | 15 + unittests/api_v3/test_apiv3_findings.py | 3 +- unittests/api_v3/test_apiv3_locations.py | 324 ++++++++++++++++++++ unittests/api_v3/test_apiv3_query_report.py | 12 + 16 files changed, 791 insertions(+), 23 deletions(-) create mode 100644 dojo/location/api_v3/__init__.py create mode 100644 dojo/location/api_v3/routes.py create mode 100644 dojo/location/api_v3/schemas.py create mode 100644 unittests/api_v3/test_apiv3_locations.py diff --git a/dojo/api_v3/api.py b/dojo/api_v3/api.py index 48a86476c40..1c39d2b9973 100644 --- a/dojo/api_v3/api.py +++ b/dojo/api_v3/api.py @@ -58,6 +58,11 @@ def build_api() -> NinjaAPI: from dojo.api_v3.import_routes import build_import_router # noqa: PLC0415 from dojo.engagement.api_v3.routes import build_engagements_router # noqa: PLC0415 from dojo.finding.api_v3.routes import build_findings_router # noqa: PLC0415 + from dojo.location.api_v3.routes import ( # noqa: PLC0415 + build_finding_locations_router, + build_locations_router, + build_product_locations_router, + ) from dojo.product.api_v3.routes import build_products_router # noqa: PLC0415 from dojo.product_type.api_v3.routes import build_product_types_router # noqa: PLC0415 from dojo.test.api_v3.routes import build_tests_router # noqa: PLC0415 @@ -69,6 +74,9 @@ def build_api() -> NinjaAPI: api.add_router("", build_engagements_router()) api.add_router("", build_tests_router()) api.add_router("", build_users_router()) + api.add_router("", build_locations_router()) + api.add_router("", build_finding_locations_router()) + api.add_router("", build_product_locations_router()) api.add_router("", build_import_router()) return api diff --git a/dojo/api_v3/expand.py b/dojo/api_v3/expand.py index f2a815e9b91..cb100be606b 100644 --- a/dojo/api_v3/expand.py +++ b/dojo/api_v3/expand.py @@ -176,6 +176,18 @@ def serialize(obj: object, schema: type, expand_tree: dict) -> dict: # --- ?fields= projection (§4.7) ---------------------------------------------------------------- +def allowed_field_names(schema: type) -> set[str]: + """ + The ``?fields=`` allowlist for a schema: its declared fields **plus** its registered expandable + keys (§4.7 + the OS4 fields/expand interplay decision, §12). This lets ``?expand=locations& + fields=id,title,locations`` name an expand key in ``fields=`` while a genuinely unknown name + still 400s. Expand keys are not model fields (e.g. ``locations`` replaces ``locations_count``), + so they must be added explicitly; without expand the key simply renders nothing (``apply_fields`` + only keeps keys actually present in the serialized dict). + """ + return set(schema.model_fields) | set(getattr(schema, "EXPANDABLE", {})) + + def parse_fields(raw: str | None, allowed: set[str]) -> set[str] | None: """Return the requested field allowlist (``id`` always included) or None. Unknown -> 400.""" if not raw: diff --git a/dojo/engagement/api_v3/routes.py b/dojo/engagement/api_v3/routes.py index 601dc2e5ce3..1576f5c50d2 100644 --- a/dojo/engagement/api_v3/routes.py +++ b/dojo/engagement/api_v3/routes.py @@ -31,7 +31,7 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem, validation_problem -from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -171,7 +171,7 @@ def list_engagements(request: HttpRequest): page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - fields = parse_fields(request.GET.get("fields"), set(schema.model_fields)) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) def serialize_row(obj: object) -> dict: return apply_fields(serialize(obj, schema, expand_tree), fields) @@ -191,7 +191,7 @@ def get_engagement(request: HttpRequest, engagement_id: int): if obj is None: msg = f"Engagement {engagement_id} not found" raise not_found_problem(msg) - fields = parse_fields(request.GET.get("fields"), set(detail_schema.model_fields)) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) @router.post("/engagements", response=detail_schema, url_name="engagements_create") diff --git a/dojo/finding/api_v3/routes.py b/dojo/finding/api_v3/routes.py index a7458ef2cc5..a1c2be0c60c 100644 --- a/dojo/finding/api_v3/routes.py +++ b/dojo/finding/api_v3/routes.py @@ -29,7 +29,7 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem -from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -160,7 +160,7 @@ def list_findings(request: HttpRequest): ) page_qs = plan_queryset(page_qs, select_related, prefetch) - allowed_fields = set(schema.model_fields) + allowed_fields = allowed_field_names(schema) fields = parse_fields(request.GET.get("fields"), allowed_fields) def serialize_row(obj: object) -> dict: @@ -190,7 +190,7 @@ def get_finding(request: HttpRequest, finding_id: int): msg = f"Finding {finding_id} not found" raise not_found_problem(msg) - allowed_fields = set(detail_schema.model_fields) + allowed_fields = allowed_field_names(detail_schema) fields = parse_fields(request.GET.get("fields"), allowed_fields) data = apply_fields(serialize(obj, detail_schema, expand_tree), fields) return json_response(data) diff --git a/dojo/finding/api_v3/schemas.py b/dojo/finding/api_v3/schemas.py index e5df7273353..d93a6513601 100644 --- a/dojo/finding/api_v3/schemas.py +++ b/dojo/finding/api_v3/schemas.py @@ -118,15 +118,18 @@ def resolve_tags(obj) -> list[str]: def _finding_location_edges(finding) -> list[dict]: """ ``expand=locations`` special renderer (§4.6): swap the cheap ``locations_count`` for the edge - rows ``[{location: {id, name, type}, status, audit_time}]``. ``finding.locations`` is the - ``LocationFindingReference`` reverse manager (edge carries ``status``/``audit_time``); the - ``locations__location`` prefetch declared on the ExpandRel keeps the query count constant. + rows ``[{location: {id, name, type}, status, audit_time, auditor: {id, name}|null}]``. + ``finding.locations`` is the ``LocationFindingReference`` reverse manager (edge carries + ``status``/``audit_time``/``auditor``); the ``locations__location`` and ``locations__auditor`` + prefetches declared on the ExpandRel keep the query count constant. The ``auditor`` ref was + deferred from OS2 and added in OS4 to match the ``/findings/{id}/locations`` sub-resource (§12). """ return [ { "location": to_location_ref(ref.location), "status": ref.status, "audit_time": ref.audit_time, + "auditor": to_ref(ref.auditor), } for ref in finding.locations.all() ] @@ -147,7 +150,7 @@ def _finding_location_edges(finding) -> list[dict]: path="locations", to_many=True, special=_finding_location_edges, - prefetch_paths=("locations__location",), + prefetch_paths=("locations__location", "locations__auditor"), replaces="locations_count", ), } diff --git a/dojo/location/api_v3/__init__.py b/dojo/location/api_v3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/location/api_v3/routes.py b/dojo/location/api_v3/routes.py new file mode 100644 index 00000000000..0984acc22a9 --- /dev/null +++ b/dojo/location/api_v3/routes.py @@ -0,0 +1,186 @@ +""" +Location read routes + finding/product location sub-resources for API v3 (§4.14, OS4). + +Three router factories (I5), all **read-only** (location lifecycle is import-driven, §4.14): + +- ``build_locations_router()`` -> ``GET /locations`` + ``GET /locations/{id}`` +- ``build_finding_locations_router()`` -> ``GET /findings/{id}/locations`` (edge rows) +- ``build_product_locations_router()`` -> ``GET /products/{id}/locations`` (edge rows) + +Routes are thin (I6): authorize -> filter/plan -> serialize -> shape. RBAC: + +- ``/locations`` mirrors the v2 ``LocationViewSet`` access model **exactly** -- that viewset stacks + ``permission_classes = (IsSuperUser, DjangoModelPermissions)``, i.e. **superuser-only** (verified, + see §12). v3 gates the whole resource behind ``request.user.is_superuser`` (403 otherwise) and + draws rows from ``get_authorized_locations`` (I8, forward-compatible: a downstream distribution can + scope the queryset without a route change). +- the sub-resources use **parent-inherited authorization**: the parent finding/product is resolved + through ``get_authorized_findings``/``get_authorized_products`` (I8); an unknown *or unauthorized* + parent is a 404 (never leak existence, §4.10). The edge rows are then drawn from the parent's own + reverse manager, so a caller who can see the parent sees its edges. + +These live in the location module (not the finding/product route factories) so all location code +stays together and the finding/product factories are untouched (§12). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.core.exceptions import PermissionDenied +from ninja import Router +from ninja.constants import NOT_SET + +from dojo.api_v3.errors import json_response, not_found_problem +from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.filtering import FilterSpec, apply_filters, filter_field, register_filter_spec +from dojo.api_v3.pagination import paginate +from dojo.authorization.roles_permissions import Permissions +from dojo.finding.queries import get_authorized_findings +from dojo.location.api_v3.schemas import ( + FindingLocationListResponse, + LocationDetail, + LocationListResponse, + LocationSlim, + ProductLocationListResponse, + finding_location_edge, + product_location_edge, +) +from dojo.location.models import Location, LocationFindingReference, LocationProductReference +from dojo.location.queries import get_authorized_locations +from dojo.product.queries import get_authorized_products + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import QuerySet + from django.http import HttpRequest + +# --- Location filter vocabulary (§4.14) ------------------------------------------------------- + +LOCATION_FILTER_SPEC = register_filter_spec("location", FilterSpec( + model=Location, + filters={ + "type": filter_field("location_type", "exact", "char"), + "name__icontains": filter_field("location_value", "icontains", "char"), + # A location relates to a product via LocationProductReference (mirrors the v2 LocationFilter + # `product` filter, field_name="products__product"). Distinct: the join can duplicate rows. + "product": filter_field("products__product", "exact", "number", distinct=True), + }, + orderings={ + "id": "id", + "name": "location_value", + }, + search_fields=["location_value"], +)) + + +def build_locations_router( + *, + schema: type = LocationSlim, + detail_schema: type = LocationDetail, + filter_spec: FilterSpec = LOCATION_FILTER_SPEC, + queryset_hook: Callable | None = None, + auth=NOT_SET, +) -> Router: + """Build the read-only locations router (I5).""" + router = Router(tags=["locations"], auth=auth) + + def _base_queryset(request: HttpRequest) -> QuerySet: + # Mirror v2 LocationViewSet: superuser-only (IsSuperUser). Everyone else -> 403 (§12). + if not request.user.is_superuser: + raise PermissionDenied + qs = get_authorized_locations("view", user=request.user) + if queryset_hook is not None: + qs = queryset_hook(qs, request) + return qs + + @router.get("/locations", response=LocationListResponse, url_name="locations_list") + def list_locations(request: HttpRequest): + filtered = apply_filters(request, _base_queryset(request), filter_spec) + + expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) + page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + page_qs = plan_queryset(page_qs, select_related, prefetch) + + fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) + + def serialize_row(obj: object) -> dict: + return apply_fields(serialize(obj, schema, expand_tree), fields) + + envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) + return json_response(envelope) + + @router.get("/locations/{int:location_id}", response=detail_schema, url_name="locations_detail") + def get_location(request: HttpRequest, location_id: int): + expand_tree, select_related, prefetch = plan(detail_schema, request.GET.get("expand")) + qs = _base_queryset(request).select_related(*detail_schema.SELECT_RELATED).prefetch_related(*detail_schema.PREFETCH_RELATED) + qs = plan_queryset(qs, select_related, prefetch) + obj = qs.filter(pk=location_id).first() + if obj is None: + msg = f"Location {location_id} not found" + raise not_found_problem(msg) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) + return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) + + return router + + +def build_finding_locations_router(*, queryset_hook: Callable | None = None, auth=NOT_SET) -> Router: + """ + Build the ``GET /findings/{id}/locations`` sub-resource router (I5). Edge rows carry the location + ref + edge ``status``/``audit_time``/``auditor`` (§4.14). ``select_related("location", "auditor")`` + keeps the query count constant regardless of the number of edges. + """ + router = Router(tags=["findings"], auth=auth) + + @router.get( + "/findings/{int:finding_id}/locations", + response=FindingLocationListResponse, + url_name="finding_locations_list", + ) + def list_finding_locations(request: HttpRequest, finding_id: int): + findings = get_authorized_findings(Permissions.Finding_View, user=request.user) + if queryset_hook is not None: + findings = queryset_hook(findings, request) + finding = findings.filter(pk=finding_id).first() + if finding is None: + # 404 for unknown *or unauthorized* parent -- parent-inherited authorization (§4.10). + msg = f"Finding {finding_id} not found" + raise not_found_problem(msg) + + edges = LocationFindingReference.objects.filter(finding=finding).order_by("id") + page_qs = edges.select_related("location", "auditor") + envelope = paginate(request, count_qs=edges, page_qs=page_qs, serialize=finding_location_edge) + return json_response(envelope) + + return router + + +def build_product_locations_router(*, queryset_hook: Callable | None = None, auth=NOT_SET) -> Router: + """ + Build the ``GET /products/{id}/locations`` sub-resource router (I5). Edge rows carry the location + ref + edge ``status`` only -- ``LocationProductReference`` has no audit columns (§12). + ``select_related("location")`` keeps the query count constant. + """ + router = Router(tags=["products"], auth=auth) + + @router.get( + "/products/{int:product_id}/locations", + response=ProductLocationListResponse, + url_name="product_locations_list", + ) + def list_product_locations(request: HttpRequest, product_id: int): + products = get_authorized_products(Permissions.Product_View, user=request.user) + if queryset_hook is not None: + products = queryset_hook(products, request) + product = products.filter(pk=product_id).first() + if product is None: + msg = f"Product {product_id} not found" + raise not_found_problem(msg) + + edges = LocationProductReference.objects.filter(product=product).order_by("id") + page_qs = edges.select_related("location") + envelope = paginate(request, count_qs=edges, page_qs=page_qs, serialize=product_location_edge) + return json_response(envelope) + + return router diff --git a/dojo/location/api_v3/schemas.py b/dojo/location/api_v3/schemas.py new file mode 100644 index 00000000000..26642c3d761 --- /dev/null +++ b/dojo/location/api_v3/schemas.py @@ -0,0 +1,207 @@ +""" +Location response schemas + the finding/product location edge schemas for API v3 (§4.5, §4.14, OS4). + +``LocationSlim`` (list) and ``LocationDetail`` (retrieve). Every schema is a named, importable, +subclassable ninja Schema (I4) and declares (as ``ClassVar`` so pydantic does not treat them as +fields) ``django_model`` / ``SELECT_RELATED`` / ``PREFETCH_RELATED`` / ``EXPANDABLE`` -- the same +contract the kernel expand planner reads from every resource schema. + +Locations are **read-only** in alpha (lifecycle is import-driven, §4.14). Only the ``URL`` location +subtype is persistable today (D5), so ``LocationDetail`` adds the URL-subtype fields +(``protocol/host/port/path/query/fragment``) pulled from the ``url`` reverse one-to-one; for a +non-URL location (none exist in alpha) those fields render ``null``. + +``FindingLocationEdge`` / ``ProductLocationEdge`` document the edge-row shape of the +``/findings/{id}/locations`` and ``/products/{id}/locations`` sub-resources for OpenAPI (I1/I4); +their runtime serialization is manual dicts (like the list envelopes) so ``LocationRef`` is emitted +with its ``type`` field. +""" +from __future__ import annotations + +import datetime # noqa: TC003 -- runtime import: pydantic resolves the schema field types +from typing import TYPE_CHECKING, ClassVar + +from django.core.exceptions import ObjectDoesNotExist +from ninja import Schema + +# ``LocationRef``/``Ref`` are pydantic field types (runtime-resolved); ``to_location_ref``/``to_ref`` +# back the edge-row serializers below (runtime) -- co-located with the edge schemas, mirroring the +# finding module's ``_finding_location_edges``. +from dojo.api_v3.refs import LocationRef, Ref, to_location_ref, to_ref +from dojo.location.models import Location + +if TYPE_CHECKING: + # Only referenced in a ClassVar annotation (never a pydantic field), so a typing-only import. + from dojo.api_v3.expand import ExpandRel + +__all__ = [ + "FindingLocationEdge", + "FindingLocationListResponse", + "LocationDetail", + "LocationListResponse", + "LocationSlim", + "ProductLocationEdge", + "ProductLocationListResponse", + "finding_location_edge", + "product_location_edge", +] + + +def _url_of(location: Location): + """ + The location's ``URL`` subtype (reverse one-to-one), or ``None`` for a non-URL location. The + reverse one-to-one accessor raises ``RelatedObjectDoesNotExist`` (an ``ObjectDoesNotExist`` + subtype) when absent; catch it so non-URL locations render null URL fields. Loaded via + ``select_related("url")`` on the detail fetch so this issues no extra query. + """ + try: + return location.url + except ObjectDoesNotExist: + return None + + +class LocationSlim(Schema): + django_model: ClassVar = Location + SELECT_RELATED: ClassVar[tuple] = () + PREFETCH_RELATED: ClassVar[tuple] = ("tags",) + EXPANDABLE: ClassVar[dict[str, ExpandRel]] = {} + + id: int + name: str + type: str + tags: list[str] + + @staticmethod + def resolve_name(obj) -> str: + return obj.location_value + + @staticmethod + def resolve_type(obj) -> str: + return obj.location_type + + @staticmethod + def resolve_tags(obj) -> list[str]: + return [t.name for t in obj.tags.all()] + + +class LocationDetail(LocationSlim): + + """Slim + the URL-subtype detail fields (§4.5, §4.14). Retrieve/expand only; list is slim.""" + + # The URL subtype is a reverse one-to-one; load it in the detail fetch so the resolvers below + # issue no per-object query. + SELECT_RELATED: ClassVar[tuple] = ("url",) + + protocol: str | None + host: str | None + port: int | None + path: str | None + query: str | None + fragment: str | None + + @staticmethod + def resolve_protocol(obj) -> str | None: + url = _url_of(obj) + return url.protocol if url is not None else None + + @staticmethod + def resolve_host(obj) -> str | None: + url = _url_of(obj) + return url.host if url is not None else None + + @staticmethod + def resolve_port(obj) -> int | None: + url = _url_of(obj) + return url.port if url is not None else None + + @staticmethod + def resolve_path(obj) -> str | None: + url = _url_of(obj) + return url.path if url is not None else None + + @staticmethod + def resolve_query(obj) -> str | None: + url = _url_of(obj) + return url.query if url is not None else None + + @staticmethod + def resolve_fragment(obj) -> str | None: + url = _url_of(obj) + return url.fragment if url is not None else None + + +# --- Sub-resource edge schemas (OpenAPI documentation of the manual dict shapes) -------------- + +class FindingLocationEdge(Schema): + + """One row of ``GET /findings/{id}/locations`` (§4.14): location ref + edge status/audit.""" + + location: LocationRef + status: str + audit_time: datetime.datetime | None + auditor: Ref | None + + +class ProductLocationEdge(Schema): + + """ + One row of ``GET /products/{id}/locations`` (§4.14): location ref + edge status. + ``LocationProductReference`` has no ``audit_time``/``auditor`` columns, so the product edge + carries only ``status`` (§12). + """ + + location: LocationRef + status: str + + +class LocationListResponse(Schema): + + """OpenAPI documentation of the ``/locations`` list envelope (I1); serialization is manual.""" + + count: int + next: str | None + previous: str | None + results: list[LocationSlim] + meta: dict | None = None + + +class FindingLocationListResponse(Schema): + + """OpenAPI documentation of the ``/findings/{id}/locations`` envelope (I1).""" + + count: int + next: str | None + previous: str | None + results: list[FindingLocationEdge] + meta: dict | None = None + + +class ProductLocationListResponse(Schema): + + """OpenAPI documentation of the ``/products/{id}/locations`` envelope (I1).""" + + count: int + next: str | None + previous: str | None + results: list[ProductLocationEdge] + meta: dict | None = None + + +# --- Edge-row serializers (manual dict shape; co-located with the edge schemas) --------------- + +def finding_location_edge(ref) -> dict: + """Serialize a ``LocationFindingReference`` edge row (§4.14): location ref + status/audit/auditor.""" + return { + "location": to_location_ref(ref.location), + "status": ref.status, + "audit_time": ref.audit_time, + "auditor": to_ref(ref.auditor), + } + + +def product_location_edge(ref) -> dict: + """Serialize a ``LocationProductReference`` edge row (§4.14): location ref + status (no audit).""" + return { + "location": to_location_ref(ref.location), + "status": ref.status, + } diff --git a/dojo/product/api_v3/routes.py b/dojo/product/api_v3/routes.py index 1016ca7a18f..068c046a748 100644 --- a/dojo/product/api_v3/routes.py +++ b/dojo/product/api_v3/routes.py @@ -28,7 +28,7 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem, validation_problem -from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -160,7 +160,7 @@ def list_products(request: HttpRequest): page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - fields = parse_fields(request.GET.get("fields"), set(schema.model_fields)) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) def serialize_row(obj: object) -> dict: return apply_fields(serialize(obj, schema, expand_tree), fields) @@ -180,7 +180,7 @@ def get_product(request: HttpRequest, product_id: int): if obj is None: msg = f"Product {product_id} not found" raise not_found_problem(msg) - fields = parse_fields(request.GET.get("fields"), set(detail_schema.model_fields)) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) @router.post("/products", response=detail_schema, url_name="products_create") diff --git a/dojo/product_type/api_v3/routes.py b/dojo/product_type/api_v3/routes.py index 2f9a4111be7..6aee797340e 100644 --- a/dojo/product_type/api_v3/routes.py +++ b/dojo/product_type/api_v3/routes.py @@ -28,7 +28,7 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem, validation_problem -from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -132,7 +132,7 @@ def list_product_types(request: HttpRequest): page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - fields = parse_fields(request.GET.get("fields"), set(schema.model_fields)) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) def serialize_row(obj: object) -> dict: return apply_fields(serialize(obj, schema, expand_tree), fields) @@ -152,7 +152,7 @@ def get_product_type(request: HttpRequest, product_type_id: int): if obj is None: msg = f"Product_Type {product_type_id} not found" raise not_found_problem(msg) - fields = parse_fields(request.GET.get("fields"), set(detail_schema.model_fields)) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) @router.post("/product_types", response=detail_schema, url_name="product_types_create") diff --git a/dojo/test/api_v3/routes.py b/dojo/test/api_v3/routes.py index 3d8ce4ea702..5fcaf205a96 100644 --- a/dojo/test/api_v3/routes.py +++ b/dojo/test/api_v3/routes.py @@ -34,7 +34,7 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem, validation_problem -from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -195,7 +195,7 @@ def list_tests(request: HttpRequest): page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - fields = parse_fields(request.GET.get("fields"), set(schema.model_fields)) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) def serialize_row(obj: object) -> dict: return apply_fields(serialize(obj, schema, expand_tree), fields) @@ -215,7 +215,7 @@ def get_test(request: HttpRequest, test_id: int): if obj is None: msg = f"Test {test_id} not found" raise not_found_problem(msg) - fields = parse_fields(request.GET.get("fields"), set(detail_schema.model_fields)) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) @router.post("/tests", response=detail_schema, url_name="tests_create") diff --git a/dojo/user/api_v3/routes.py b/dojo/user/api_v3/routes.py index 9e273fcea24..b590dc8ae1d 100644 --- a/dojo/user/api_v3/routes.py +++ b/dojo/user/api_v3/routes.py @@ -29,7 +29,7 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem, validation_problem -from dojo.api_v3.expand import apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -148,7 +148,7 @@ def list_users(request: HttpRequest): page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - fields = parse_fields(request.GET.get("fields"), set(schema.model_fields)) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) def serialize_row(obj: object) -> dict: return apply_fields(serialize(obj, schema, {}), fields) @@ -165,7 +165,7 @@ def get_user(request: HttpRequest, user_id: int): if obj is None: msg = f"User {user_id} not found" raise not_found_problem(msg) - fields = parse_fields(request.GET.get("fields"), set(detail_schema.model_fields)) + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) return json_response(apply_fields(serialize(obj, detail_schema, {}), fields)) @router.post("/users", response=detail_schema, url_name="users_create") diff --git a/unittests/api_v3/snapshots/filters.json b/unittests/api_v3/snapshots/filters.json index 73272820162..ac8cfbe6f78 100644 --- a/unittests/api_v3/snapshots/filters.json +++ b/unittests/api_v3/snapshots/filters.json @@ -74,6 +74,21 @@ "title" ] }, + "location": { + "model": "Location", + "orderings": [ + "id", + "name" + ], + "params": [ + "name__icontains", + "product", + "type" + ], + "search_fields": [ + "location_value" + ] + }, "product": { "model": "Product", "orderings": [ diff --git a/unittests/api_v3/test_apiv3_findings.py b/unittests/api_v3/test_apiv3_findings.py index 318f99f1d62..974f8313f87 100644 --- a/unittests/api_v3/test_apiv3_findings.py +++ b/unittests/api_v3/test_apiv3_findings.py @@ -207,7 +207,8 @@ def test_expand_locations_swaps_count_for_edge_rows(self): rows = detail["locations"] self.assertEqual(existing + 2, len(rows)) for row in rows: - self.assertEqual({"location", "status", "audit_time"}, set(row)) + # OS4 added `auditor` to the expand=locations edge rows (§12). + self.assertEqual({"location", "status", "audit_time", "auditor"}, set(row)) self.assertEqual({"id", "name", "type"}, set(row["location"])) by_name = {row["location"]["name"]: row for row in rows} self.assertEqual("url", by_name["https://example.com/os2-a"]["location"]["type"]) diff --git a/unittests/api_v3/test_apiv3_locations.py b/unittests/api_v3/test_apiv3_locations.py new file mode 100644 index 00000000000..a3f3132b8cf --- /dev/null +++ b/unittests/api_v3/test_apiv3_locations.py @@ -0,0 +1,324 @@ +""" +Locations resource + finding/product location sub-resources for API v3 (§4.14, OS4). + +Covers: the read-only ``/locations`` resource (slim/detail shapes incl. URL-subtype fields, filters, +orderings, pagination, the v2 superuser-only RBAC mirror, constant query count); the +``/findings/{id}/locations`` and ``/products/{id}/locations`` edge sub-resources (edge shapes, +auditor ref, parent-inherited authorization 404, pagination, constant query count); the +``?fields=`` / ``?expand=`` interplay; and flag-off behaviour (whole /api/v3-alpha/ tree absent). +""" +from __future__ import annotations + +import importlib + +from django.db import connection +from django.test import override_settings +from django.test.utils import CaptureQueriesContext +from django.urls import Resolver404, clear_url_caches, resolve +from django.utils import timezone + +import dojo.urls +from dojo.location.models import Location, LocationFindingReference, LocationProductReference +from dojo.models import Finding, Product, User + +from .base import ApiV3TestCase + +_SLIM_KEYS = {"id", "name", "type", "tags"} +_DETAIL_KEYS = _SLIM_KEYS | {"protocol", "host", "port", "path", "query", "fragment"} +_FINDING_EDGE_KEYS = {"location", "status", "audit_time", "auditor"} +_PRODUCT_EDGE_KEYS = {"location", "status"} +_LOCATION_REF_KEYS = {"id", "name", "type"} + + +class TestApiV3LocationsRead(ApiV3TestCase): + + """The read-only /locations resource (admin is a superuser, so RBAC never hides rows here).""" + + def test_list_envelope_and_slim_shape(self): + body = self.get_json("locations") + self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) + self.assertGreater(body["count"], 0) + row = body["results"][0] + self.assertEqual(_SLIM_KEYS, set(row)) + self.assertIsInstance(row["tags"], list) + self.assertEqual("url", row["type"]) + + def test_detail_adds_url_subtype_fields(self): + # Fixture Location 1 is http://127.0.0.1/endpoint/420/edit/ (URL subtype pk 1). + detail = self.get_json("locations/1") + self.assertEqual(_DETAIL_KEYS, set(detail)) + self.assertEqual("url", detail["type"]) + self.assertEqual("http://127.0.0.1/endpoint/420/edit/", detail["name"]) + self.assertEqual("http", detail["protocol"]) + self.assertEqual("127.0.0.1", detail["host"]) + self.assertEqual(80, detail["port"]) + self.assertEqual("endpoint/420/edit/", detail["path"]) + self.assertEqual("", detail["query"]) + self.assertEqual("", detail["fragment"]) + + def test_detail_unknown_is_404_problem(self): + response = self.client.get(self.v3_url("locations/99999999")) + self.assertEqual(404, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_filter_type(self): + body = self.get_json("locations", data={"type": "url", "limit": 250}) + self.assertGreater(body["count"], 0) + for row in body["results"]: + self.assertEqual("url", row["type"]) + # No non-url locations exist in alpha; a bogus type returns nothing. + self.assertEqual(0, self.get_json("locations", data={"type": "code"})["count"]) + + def test_filter_name_icontains(self): + body = self.get_json("locations", data={"name__icontains": "bar.foo", "limit": 250}) + self.assertGreater(body["count"], 0) + for row in body["results"]: + self.assertIn("bar.foo", row["name"]) + + def test_filter_product(self): + # Fixture: LocationProductReference links product 1 -> location 6. + body = self.get_json("locations", data={"product": 1, "limit": 250}) + ids = {row["id"] for row in body["results"]} + self.assertIn(6, ids) + + def test_ordering_by_name(self): + names = [r["name"] for r in self.get_json("locations", data={"o": "name", "limit": 250})["results"]] + self.assertEqual(names, sorted(names)) + + def test_ordering_by_id_desc(self): + ids = [r["id"] for r in self.get_json("locations", data={"o": "-id", "limit": 250})["results"]] + self.assertEqual(ids, sorted(ids, reverse=True)) + + def test_pagination_envelope(self): + body = self.get_json("locations", data={"limit": 2, "offset": 2}) + self.assertLessEqual(len(body["results"]), 2) + self.assertIsNotNone(body["previous"]) + + def test_unknown_filter_param_is_400(self): + self.get_json("locations", data={"not_a_filter": "x"}, expected=400) + + def test_unknown_field_is_400(self): + self.get_json("locations", data={"fields": "id,not_a_field"}, expected=400) + + +class TestApiV3LocationsRbac(ApiV3TestCase): + + """/locations mirrors v2 LocationViewSet exactly: superuser-only (IsSuperUser) -> 403 otherwise.""" + + def setUp(self): + super().setUp() + self.regular = User.objects.create_user(username="v3_loc_regular", password="x") # noqa: S106 + + def test_non_superuser_list_is_403(self): + client = self.token_client(user=self.regular) + response = client.get(self.v3_url("locations")) + self.assertEqual(403, response.status_code, response.content[:300]) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_non_superuser_detail_is_403(self): + client = self.token_client(user=self.regular) + response = client.get(self.v3_url("locations/1")) + self.assertEqual(403, response.status_code, response.content[:300]) + + def test_superuser_can_read(self): + self.assertGreater(self.get_json("locations")["count"], 0) + self.get_json("locations/1") + + +class TestApiV3LocationsQueryCount(ApiV3TestCase): + + def _query_count(self, params: dict) -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url("locations"), params) + self.assertEqual(200, response.status_code, response.content[:500]) + return len(ctx.captured_queries) + + def test_query_count_is_independent_of_row_count(self): + Location.objects.bulk_create([ + Location(location_type="url", location_value=f"https://qcount.example/{i}") for i in range(10) + ]) + first = self._query_count({"limit": 250}) + Location.objects.bulk_create([ + Location(location_type="url", location_value=f"https://qcount.example/b{i}") for i in range(90) + ]) + second = self._query_count({"limit": 250}) + self.assertEqual(first, second, f"query count grew with row count: {first} -> {second}") + + +class TestApiV3FindingLocationsSubResource(ApiV3TestCase): + + """GET /findings/{id}/locations edge rows: location ref + status/audit_time/auditor (§4.14).""" + + def _attach(self, finding, value, status="Active", auditor=None, audit_time=None): + location = Location.objects.create(location_type="url", location_value=value) + return LocationFindingReference.objects.create( + location=location, finding=finding, status=status, auditor=auditor, audit_time=audit_time, + ) + + def test_edge_shape_and_location_ref(self): + # Fixture finding 227 already has three location edges (5/6/7). + body = self.get_json("findings/227/locations") + self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) + self.assertEqual(3, body["count"]) + for row in body["results"]: + self.assertEqual(_FINDING_EDGE_KEYS, set(row)) + self.assertEqual(_LOCATION_REF_KEYS, set(row["location"])) + self.assertEqual("url", row["location"]["type"]) + self.assertIsNone(row["auditor"]) # fixture edges carry no auditor + + def test_status_values_pass_through(self): + finding = Finding.objects.get(pk=228) # fixture: one FalsePositive edge on location 5 + rows = self.get_json(f"findings/{finding.id}/locations")["results"] + self.assertEqual(["FalsePositive"], [r["status"] for r in rows]) + + def test_auditor_ref_rendered(self): + finding = Finding.objects.get(pk=2) + when = timezone.now() + self._attach(finding, "https://audited.example/a", auditor=self.admin, audit_time=when) + rows = self.get_json(f"findings/{finding.id}/locations")["results"] + audited = [r for r in rows if r["auditor"] is not None] + self.assertEqual(1, len(audited)) + self.assertEqual({"id", "name"}, set(audited[0]["auditor"])) + self.assertEqual(self.admin.id, audited[0]["auditor"]["id"]) + self.assertEqual(self.admin.username, audited[0]["auditor"]["name"]) + self.assertIsNotNone(audited[0]["audit_time"]) + + def test_unknown_parent_is_404(self): + response = self.client.get(self.v3_url("findings/99999999/locations")) + self.assertEqual(404, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_unauthorized_parent_is_404(self): + # A user with no product access cannot see finding 227 -> parent-inherited 404 (not 403). + limited = User.objects.create_user(username="v3_loc_findlimited", password="x") # noqa: S106 + client = self.token_client(user=limited) + response = client.get(self.v3_url("findings/227/locations")) + self.assertEqual(404, response.status_code, response.content[:300]) + + def test_pagination_envelope(self): + finding = Finding.objects.get(pk=2) + for i in range(5): + self._attach(finding, f"https://page.example/{i}") + body = self.get_json(f"findings/{finding.id}/locations", data={"limit": 2, "offset": 2}) + self.assertLessEqual(len(body["results"]), 2) + self.assertIsNotNone(body["previous"]) + + def test_query_count_is_independent_of_edge_count(self): + finding = Finding.objects.get(pk=2) + + def query_count() -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url(f"findings/{finding.id}/locations"), {"limit": 250}) + self.assertEqual(200, response.status_code, response.content[:500]) + return len(ctx.captured_queries) + + for i in range(5): + self._attach(finding, f"https://qc.example/{i}", auditor=self.admin) + first = query_count() + for i in range(20): + self._attach(finding, f"https://qc.example/b{i}", auditor=self.admin) + second = query_count() + self.assertEqual(first, second, f"sub-resource query count grew: {first} -> {second}") + + +class TestApiV3ProductLocationsSubResource(ApiV3TestCase): + + """GET /products/{id}/locations edge rows: location ref + status only (no audit columns, §12).""" + + def _attach(self, product, value, status="Active"): + location = Location.objects.create(location_type="url", location_value=value) + return LocationProductReference.objects.create(location=location, product=product, status=status) + + def test_edge_shape_and_location_ref(self): + # Fixture: product 1 -> location 6 (Active). + body = self.get_json("products/1/locations") + self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) + self.assertGreaterEqual(body["count"], 1) + row = next(r for r in body["results"] if r["location"]["id"] == 6) + self.assertEqual(_PRODUCT_EDGE_KEYS, set(row)) + self.assertEqual(_LOCATION_REF_KEYS, set(row["location"])) + self.assertEqual("url", row["location"]["type"]) + self.assertEqual("Active", row["status"]) + + def test_unknown_parent_is_404(self): + response = self.client.get(self.v3_url("products/99999999/locations")) + self.assertEqual(404, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_unauthorized_parent_is_404(self): + limited = User.objects.create_user(username="v3_loc_prodlimited", password="x") # noqa: S106 + client = self.token_client(user=limited) + response = client.get(self.v3_url("products/1/locations")) + self.assertEqual(404, response.status_code, response.content[:300]) + + def test_query_count_is_independent_of_edge_count(self): + product = Product.objects.get(pk=1) + + def query_count() -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url(f"products/{product.id}/locations"), {"limit": 250}) + self.assertEqual(200, response.status_code, response.content[:500]) + return len(ctx.captured_queries) + + for i in range(5): + self._attach(product, f"https://pqc.example/{i}") + first = query_count() + for i in range(20): + self._attach(product, f"https://pqc.example/b{i}") + second = query_count() + self.assertEqual(first, second, f"sub-resource query count grew: {first} -> {second}") + + +class TestApiV3FieldsExpandInterplay(ApiV3TestCase): + + """The ?fields= allowlist = schema fields + registered EXPANDABLE keys (OS4 item 4, §12).""" + + def test_expand_key_accepted_in_fields(self): + # `locations` is an expandable key, not a model field; it must be nameable in ?fields=. + finding = Finding.objects.get(pk=227) + body = self.get_json( + "findings", data={"expand": "locations", "fields": "id,title,locations", "id__in": finding.id}, + ) + row = next(r for r in body["results"] if r["id"] == finding.id) + self.assertEqual({"id", "title", "locations"}, set(row)) + self.assertNotIn("locations_count", row) + self.assertGreater(len(row["locations"]), 0) + + def test_fields_without_expand_drops_expand_key_silently(self): + # Naming an expand key in ?fields= without ?expand= is accepted (no 400) but renders nothing. + row = self.get_json("findings", data={"fields": "id,title,locations"})["results"][0] + self.assertEqual({"id", "title"}, set(row)) + + def test_unknown_field_still_400(self): + self.get_json("findings", data={"fields": "id,definitely_not_a_field"}, expected=400) + + def test_unknown_field_alongside_expand_key_still_400(self): + self.get_json( + "findings", data={"expand": "locations", "fields": "id,locations,bogus_field"}, expected=400, + ) + + +class TestApiV3LocationsFlagOff(ApiV3TestCase): + + """With V3_FEATURE_LOCATIONS=False the entire /api/v3-alpha/ tree is unmounted (D5/§4.1).""" + + def test_flag_off_unmounts_entire_v3_tree(self): + # Flag is on in the test settings: the whole v3 tree resolves. + resolve(self.v3_url("locations")) + resolve(self.v3_url("findings")) + + try: + with override_settings(V3_FEATURE_LOCATIONS=False): + clear_url_caches() + importlib.reload(dojo.urls) + # Nothing under the v3 prefix resolves once the flag is off -- the mount is gone. + for path in ("locations", "findings", "products", "import"): + with self.assertRaises(Resolver404): + resolve(self.v3_url(path), urlconf=dojo.urls) + finally: + # Restore the real (flag-on) URLconf for the rest of the suite. + clear_url_caches() + importlib.reload(dojo.urls) + + # Restored: the v3 tree resolves again. + resolve(self.v3_url("locations")) diff --git a/unittests/api_v3/test_apiv3_query_report.py b/unittests/api_v3/test_apiv3_query_report.py index c6b495905c2..9b35b2dff60 100644 --- a/unittests/api_v3/test_apiv3_query_report.py +++ b/unittests/api_v3/test_apiv3_query_report.py @@ -19,6 +19,7 @@ from django.conf import settings from dojo.api_v3.api import api_v3 +from dojo.location.models import Location, LocationFindingReference, LocationProductReference from dojo.models import Engagement, Finding, Product, Product_Type, Test from .base import ApiV3TestCase @@ -63,6 +64,13 @@ def _fan_out_rows(self): clone = model.objects.get(pk=template.pk) clone.pk = None clone.save() + # Fan out location edges so the sub-resource per-row queries (if any) trip the threshold. + self._loc_finding = Finding.objects.order_by("pk").first() + self._loc_product = Product.objects.order_by("pk").first() + for i in range(_ROW_FANOUT): + loc = Location.objects.create(location_type="url", location_value=f"query-report loc {i}") + LocationFindingReference.objects.create(location=loc, finding=self._loc_finding, status="Active") + LocationProductReference.objects.create(location=loc, product=self._loc_product, status="Active") def _representative_requests(self) -> dict[str, str]: """OpenAPI GET path -> concrete request URL. Extend when a phase adds endpoints.""" @@ -84,6 +92,10 @@ def _representative_requests(self) -> dict[str, str]: "/engagements/{engagement_id}": self.v3_url(f"engagements/{Engagement.objects.first().pk}"), "/tests": self.v3_url(f"tests?{limit}"), "/tests/{test_id}": self.v3_url(f"tests/{Test.objects.first().pk}"), + "/locations": self.v3_url(f"locations?{limit}"), + "/locations/{location_id}": self.v3_url(f"locations/{Location.objects.order_by('pk').first().pk}"), + "/findings/{finding_id}/locations": self.v3_url(f"findings/{self._loc_finding.pk}/locations?{limit}"), # noqa: RUF027 + "/products/{product_id}/locations": self.v3_url(f"products/{self._loc_product.pk}/locations?{limit}"), # noqa: RUF027 } def _openapi_get_paths(self) -> set[str]: From 287e3679c1283e4a07ee22011d43ce4395095088 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 14:54:11 +0200 Subject: [PATCH 07/37] feat(api-v3): OS5 - generic notes/tags/files sub-resources - dojo/api_v3/subresources.py: three parameterized kernel factories, attached only where models have real storage (verified matrix in plan \$12): notes+files on finding/engagement/test, tags on finding/engagement/test/product - authorization inherited from parent: authorized-view queryset (404) then per-method permission (403), values mirror v2 related-object permission classes - note privacy mirrors v2: private = report-exclusion only, not a per-user read filter; tags mirror tagulous force_lowercase + inheritance write path; files mirror FileUpload.clean() validation and streamed download - note side-effects (JIRA comment, last_reviewed, mentions) deferred to the service layer per D7 - recorded as a known alpha parity gap - query sweep extended (13 new GET endpoints), zero N+1 flags 252 tests green (229 prior + 23 new). --- dojo/api_v3/api.py | 66 ++++ dojo/api_v3/subresources.py | 341 +++++++++++++++++++ unittests/api_v3/query_report.py | 4 +- unittests/api_v3/test_apiv3_query_report.py | 48 ++- unittests/api_v3/test_apiv3_subresources.py | 344 ++++++++++++++++++++ 5 files changed, 798 insertions(+), 5 deletions(-) create mode 100644 dojo/api_v3/subresources.py create mode 100644 unittests/api_v3/test_apiv3_subresources.py diff --git a/dojo/api_v3/api.py b/dojo/api_v3/api.py index 1c39d2b9973..1ae0e9f6fe5 100644 --- a/dojo/api_v3/api.py +++ b/dojo/api_v3/api.py @@ -78,7 +78,73 @@ def build_api() -> NinjaAPI: api.add_router("", build_finding_locations_router()) api.add_router("", build_product_locations_router()) api.add_router("", build_import_router()) + + _mount_subresources(api) return api +def _mount_subresources(api: NinjaAPI) -> None: + """ + Mount the generic notes / tags / files sub-resources (§4.12, OS5) on the resources whose + *models* actually store them (storage support matrix, see .claude/os5-report.md / §12): + + - notes + files: finding, engagement, test (each has a ``Notes``/``FileUpload`` M2M). + - tags: finding, engagement, test, product (each has a ``TagField`` and a writable v3 + resource). product_type/user have no such fields; location has a ``TagField`` + but is a read-only, superuser-only resource with no v2 tag-mutation endpoint + and already surfaces ``tags[]`` on its read shape -- so no tag sub-resource is + attached to it (§12). + + Deferred here (not at module top) so the kernel ``subresources.py`` stays resource-agnostic; + this mount is the composition root, alongside the router-factory imports above. + """ + from dojo.api_v3.subresources import ( # noqa: PLC0415 + build_files_router, + build_notes_router, + build_tags_router, + ) + from dojo.authorization.roles_permissions import Permissions # noqa: PLC0415 + from dojo.engagement.queries import get_authorized_engagements # noqa: PLC0415 + from dojo.finding.queries import get_authorized_findings # noqa: PLC0415 + from dojo.product.queries import get_authorized_products # noqa: PLC0415 + from dojo.test.queries import get_authorized_tests # noqa: PLC0415 + + # Parent authorized-view queryset resolvers. finding/product take an explicit user; engagement/ + # test read the current user from crum (their signatures take no user kwarg) -- matching how the + # v2 viewsets and the OS3 route factories call them. + def findings_qs(request): + return get_authorized_findings(Permissions.Finding_View, user=request.user) + + def engagements_qs(request): + return get_authorized_engagements(Permissions.Engagement_View) + + def tests_qs(request): + return get_authorized_tests(Permissions.Test_View) + + def products_qs(request): + return get_authorized_products(Permissions.Product_View, user=request.user) + + file_view = Permissions.Product_Tracking_Files_View + file_add = Permissions.Product_Tracking_Files_Add + + notes_and_files = ( + ("findings", "Finding", findings_qs), + ("engagements", "Engagement", engagements_qs), + ("tests", "Test", tests_qs), + ) + for resource, label, qs in notes_and_files: + api.add_router("", build_notes_router(resource=resource, parent_label=label, get_parent_queryset=qs)) + api.add_router("", build_files_router( + resource=resource, parent_label=label, get_parent_queryset=qs, + view_permission=file_view, add_permission=file_add, + )) + + tagged = ( + *notes_and_files, + ("products", "Product", products_qs), + ) + for resource, label, qs in tagged: + api.add_router("", build_tags_router(resource=resource, parent_label=label, get_parent_queryset=qs)) + + api_v3 = build_api() diff --git a/dojo/api_v3/subresources.py b/dojo/api_v3/subresources.py new file mode 100644 index 00000000000..397462467d0 --- /dev/null +++ b/dojo/api_v3/subresources.py @@ -0,0 +1,341 @@ +""" +Generic notes / tags / files sub-resource router factories for API v3 (§4.12, OS5). + +Three router factories (I5), each **parameterized by the parent resource** so this kernel module +imports **no parent-resource model** (finding/product/engagement/test/...): the parent's URL path +segment, a human label, its authorized-view queryset resolver, and the RBAC permission values all +arrive as factory arguments. The only models imported here are the sub-resources' *own* storage +models (``Notes``/``NoteHistory``/``FileUpload``) plus the shared authorization/file helpers -- the +factory legitimately owns those. + +**Authorization is inherited from the parent, mirroring the v2 related-object permission classes** +(``UserHas*NotePermission`` / ``*FilePermission`` / ``*RelatedObjectPermission``): + +1. the parent is resolved through its ``get_authorized_*`` view queryset -- an unknown *or + unauthorized* parent is a **404** (never leak existence, §4.10); +2. the applicable per-method permission is then checked on the parent via ``user_has_permission`` + -- failure is a **403**. The permission *values* mirror v2 exactly: + + ========= ============================ ===================================== + sub-res read (GET) write + ========= ============================ ===================================== + notes ``view`` POST create -> ``view`` (v2 note post_permission) + tags ``view`` PUT/POST/DELETE -> ``edit`` + files ``Product_Tracking_Files_View`` POST -> ``Product_Tracking_Files_Add`` + ========= ============================ ===================================== + +Routes are thin (I6). Sub-resource lists are parent-scoped and simple -- no expand/fields/filter +machinery, only the shared pagination envelope (§12); therefore no FilterSpecs and no snapshot +regeneration. + +Inner view functions are registered manually (not via ``@router.get``) so each can be given a +unique ``__name__`` per resource -- the factory is called once per parent resource, and identical +closure names would otherwise collide into duplicate OpenAPI ``operationId``s. +""" +from __future__ import annotations + +from datetime import datetime # noqa: TC003 -- runtime import: ninja/pydantic resolves the schema field types +from typing import TYPE_CHECKING + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.core.exceptions import ValidationError as DjangoValidationError +from django.http import HttpResponse +from ninja import File, Form, Router, Schema +from ninja.constants import NOT_SET +from ninja.files import UploadedFile # noqa: TC002 -- runtime import: ninja resolves the File() param type + +from dojo.api_v3.errors import json_response, not_found_problem, validation_problem +from dojo.api_v3.pagination import paginate +from dojo.api_v3.refs import Ref, to_ref +from dojo.authorization.authorization import user_has_permission +from dojo.file_uploads.models import FileUpload +from dojo.notes.models import NoteHistory, Notes +from dojo.utils import generate_file_response + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.db.models import Model, QuerySet + from django.http import HttpRequest + + +# --- Schemas (documentation only; runtime serialization is manual) ---------------------------- + +class NoteSchema(Schema): + + """A note (§4.12). ``created`` maps to ``Notes.date``; ``updated`` to ``Notes.edit_time`` (§12).""" + + id: int + entry: str + author: Ref | None + private: bool + edited: bool + created: datetime | None + updated: datetime | None + + +class NoteCreate(Schema): + + """POST body for a note (§4.12). ``note_type`` is out of the alpha write surface (§12).""" + + model_config = {"extra": "forbid"} + + entry: str + private: bool = False + + +class NoteListResponse(Schema): + count: int + next: str | None + previous: str | None + results: list[NoteSchema] + meta: dict | None = None + + +class FileSchema(Schema): + + """An uploaded file (§4.12). ``FileUpload`` has no creation column, so ``created`` is null (§12).""" + + id: int + title: str + size: int + created: datetime | None + + +class FileListResponse(Schema): + count: int + next: str | None + previous: str | None + results: list[FileSchema] + meta: dict | None = None + + +class TagsResponse(Schema): + tags: list[str] + + +class TagsBody(Schema): + model_config = {"extra": "forbid"} + + tags: list[str] + + +# --- Shared helpers --------------------------------------------------------------------------- + +def _resolve_parent( + request: HttpRequest, + get_parent_queryset: Callable[[HttpRequest], QuerySet], + parent_label: str, + parent_id: int, +) -> Model: + """Resolve the parent through its authorized-view queryset; 404 unknown-or-unauthorized (§4.10).""" + parent = get_parent_queryset(request).filter(pk=parent_id).first() + if parent is None: + msg = f"{parent_label} {parent_id} not found" + raise not_found_problem(msg) + return parent + + +def _require(request: HttpRequest, parent: Model, permission) -> None: + """403 when the caller lacks ``permission`` on the (already-viewable) parent (I8).""" + if not user_has_permission(request.user, parent, permission): + raise PermissionDenied + + +def _serialize_note(note: Notes) -> dict: + return { + "id": note.pk, + "entry": note.entry, + "author": to_ref(note.author), + "private": note.private, + "edited": note.edited, + "created": note.date, + "updated": note.edit_time, + } + + +def _serialize_file(file_obj: FileUpload) -> dict: + # ``file.size`` is a storage stat (filesystem), not a DB query -- keeps list query counts flat. + return {"id": file_obj.pk, "title": file_obj.title, "size": file_obj.file.size, "created": None} + + +def _read_tags(parent: Model) -> list[str]: + return [tag.name for tag in parent.tags.all()] + + +def _write_tags(parent: Model, names: list[str]) -> None: + """ + Replace the parent's tags with ``names`` via the v2 write path (assignment + ``save()``): + tagulous normalises (``force_lowercase``) on save and the tag-inheritance signals fire exactly + as they do for v2's ``remove_tags`` (``dojo/finding/api/views.py``). Assigning a *list* (not a + rendered string) is safe for tags containing spaces/commas. + """ + parent.tags = names + parent.save() + + +# --- Factories -------------------------------------------------------------------------------- + +def build_notes_router( + *, + resource: str, + parent_label: str, + get_parent_queryset: Callable[[HttpRequest], QuerySet], + view_permission="view", + create_permission="view", + auth=NOT_SET, +) -> Router: + """``GET`` (paginated) + ``POST`` ``/{resource}/{id}/notes`` (§4.12).""" + router = Router(tags=[resource], auth=auth) + notes_path = f"/{resource}/{{int:parent_id}}/notes" + + def list_notes(request: HttpRequest, parent_id: int): + parent = _resolve_parent(request, get_parent_queryset, parent_label, parent_id) + _require(request, parent, view_permission) + # Mirror v2 exactly: return every note incl. private ones. In v2 the notes @action returns + # `parent.notes.all()`; `private` only excludes a note from generated reports, it is not a + # per-user read filter (§12). `select_related("author")` keeps the list query count flat. + notes = parent.notes + page_qs = notes.select_related("author").order_by("-date", "-id") + envelope = paginate(request, count_qs=notes.all(), page_qs=page_qs, serialize=_serialize_note) + return json_response(envelope) + + def create_note(request: HttpRequest, parent_id: int, payload: NoteCreate): + parent = _resolve_parent(request, get_parent_queryset, parent_label, parent_id) + _require(request, parent, create_permission) + note = Notes(entry=payload.entry, author=request.user, private=payload.private) + note.save() + history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) + note.history.add(history) + parent.notes.add(note) + return json_response(_serialize_note(note), status=201) + + list_notes.__name__ = f"list_{resource}_notes" + create_note.__name__ = f"create_{resource}_note" + router.get(notes_path, response=NoteListResponse, url_name=f"{resource}_notes_list")(list_notes) + router.post(notes_path, response=NoteSchema, url_name=f"{resource}_notes_create")(create_note) + return router + + +def build_files_router( + *, + resource: str, + parent_label: str, + get_parent_queryset: Callable[[HttpRequest], QuerySet], + view_permission, + add_permission, + auth=NOT_SET, +) -> Router: + """``GET`` list + ``POST`` (multipart) + ``GET .../{file_id}/download`` (§4.12).""" + router = Router(tags=[resource], auth=auth) + files_path = f"/{resource}/{{int:parent_id}}/files" + download_path = f"/{resource}/{{int:parent_id}}/files/{{int:file_id}}/download" + + def list_files(request: HttpRequest, parent_id: int): + parent = _resolve_parent(request, get_parent_queryset, parent_label, parent_id) + _require(request, parent, view_permission) + files = parent.files + page_qs = files.all().order_by("id") + envelope = paginate(request, count_qs=files.all(), page_qs=page_qs, serialize=_serialize_file) + return json_response(envelope) + + def create_file( + request: HttpRequest, + parent_id: int, + title: str = Form(...), + file: UploadedFile = File(...), # noqa: B008 -- ninja's declarative param default + ): + parent = _resolve_parent(request, get_parent_queryset, parent_label, parent_id) + _require(request, parent, add_permission) + # Mirror v2 FileSerializer: extension validation via FileUpload.clean() (settings + # .FILE_UPLOAD_TYPES); v2 does NOT enforce a size cap. Title is globally unique on the + # model, so pre-check it here (mirrors DRF's UniqueValidator -> 400, avoids a 500). + upload = FileUpload(title=title, file=file) + try: + upload.clean() + except DjangoValidationError as exc: + raise validation_problem({"file": list(exc.messages)}) from exc + if FileUpload.objects.filter(title=upload.title).exists(): + raise validation_problem({"title": ["A file with this title already exists."]}) + upload.save() + parent.files.add(upload) + return json_response(_serialize_file(upload), status=201) + + def download_file(request: HttpRequest, parent_id: int, file_id: int): + parent = _resolve_parent(request, get_parent_queryset, parent_label, parent_id) + _require(request, parent, view_permission) + file_object = parent.files.filter(id=file_id).first() + if file_object is None: + msg = f"File {file_id} not associated with {parent_label} {parent_id}" + raise not_found_problem(msg) + # Streamed FileResponse with the correct content-type + Content-Disposition (mirrors v2's + # download_file via generate_file_response). Ninja passes HttpResponse subclasses through. + response = generate_file_response(file_object) + response["X-API-Status"] = settings.API_V3_STATUS + return response + + list_files.__name__ = f"list_{resource}_files" + create_file.__name__ = f"create_{resource}_file" + download_file.__name__ = f"download_{resource}_file" + router.get(files_path, response=FileListResponse, url_name=f"{resource}_files_list")(list_files) + router.post(files_path, response=FileSchema, url_name=f"{resource}_files_create")(create_file) + router.get(download_path, url_name=f"{resource}_files_download")(download_file) + return router + + +def build_tags_router( + *, + resource: str, + parent_label: str, + get_parent_queryset: Callable[[HttpRequest], QuerySet], + view_permission="view", + write_permission="edit", + auth=NOT_SET, +) -> Router: + """``GET`` + ``PUT`` (replace) + ``POST`` (append) ``/tags`` and ``DELETE /tags/{tag}`` (§4.12).""" + router = Router(tags=[resource], auth=auth) + tags_path = f"/{resource}/{{int:parent_id}}/tags" + tag_item_path = f"/{resource}/{{int:parent_id}}/tags/{{tag}}" + + def get_tags(request: HttpRequest, parent_id: int): + parent = _resolve_parent(request, get_parent_queryset, parent_label, parent_id) + _require(request, parent, view_permission) + return json_response({"tags": _read_tags(parent)}) + + def replace_tags(request: HttpRequest, parent_id: int, payload: TagsBody): + parent = _resolve_parent(request, get_parent_queryset, parent_label, parent_id) + _require(request, parent, write_permission) + _write_tags(parent, list(payload.tags)) + return json_response({"tags": _read_tags(parent)}) + + def append_tags(request: HttpRequest, parent_id: int, payload: TagsBody): + parent = _resolve_parent(request, get_parent_queryset, parent_label, parent_id) + _require(request, parent, write_permission) + # Case-insensitive dedup (stored tags are lowercased); order-preserving. + merged = list(dict.fromkeys([*_read_tags(parent), *(t.lower() for t in payload.tags)])) + _write_tags(parent, merged) + return json_response({"tags": _read_tags(parent)}) + + def delete_tag(request: HttpRequest, parent_id: int, tag: str): + parent = _resolve_parent(request, get_parent_queryset, parent_label, parent_id) + _require(request, parent, write_permission) + current = _read_tags(parent) + target = tag.lower() # stored tags are force_lowercase; match case-insensitively (§12) + if target not in current: + msg = f"Tag '{tag}' not found on {parent_label} {parent_id}" + raise not_found_problem(msg) + _write_tags(parent, [name for name in current if name != target]) + response = HttpResponse(status=204) + response["X-API-Status"] = settings.API_V3_STATUS + return response + + get_tags.__name__ = f"get_{resource}_tags" + replace_tags.__name__ = f"replace_{resource}_tags" + append_tags.__name__ = f"append_{resource}_tags" + delete_tag.__name__ = f"delete_{resource}_tag" + router.get(tags_path, response=TagsResponse, url_name=f"{resource}_tags_list")(get_tags) + router.put(tags_path, response=TagsResponse, url_name=f"{resource}_tags_replace")(replace_tags) + router.post(tags_path, response=TagsResponse, url_name=f"{resource}_tags_append")(append_tags) + router.delete(tag_item_path, url_name=f"{resource}_tags_delete")(delete_tag) + return router diff --git a/unittests/api_v3/query_report.py b/unittests/api_v3/query_report.py index 98ae7e41a73..26d5d8f2b8f 100644 --- a/unittests/api_v3/query_report.py +++ b/unittests/api_v3/query_report.py @@ -69,7 +69,9 @@ def capture_request(client, label: str, path: str) -> EndpointCapture: response = client.get(path) shapes = Counter(normalize_sql(q["sql"]) for q in ctx.captured_queries) rows = None - if response.status_code == 200: + # Streaming responses (e.g. the files-download FileResponse) have no `.content`; skip body + # parsing for them -- they are single-object endpoints, not paginated lists. + if response.status_code == 200 and not getattr(response, "streaming", False): try: body = response.json() rows = len(body["results"]) if isinstance(body, dict) and "results" in body else None diff --git a/unittests/api_v3/test_apiv3_query_report.py b/unittests/api_v3/test_apiv3_query_report.py index 9b35b2dff60..18f4fe9635b 100644 --- a/unittests/api_v3/test_apiv3_query_report.py +++ b/unittests/api_v3/test_apiv3_query_report.py @@ -17,10 +17,13 @@ from pathlib import Path from django.conf import settings +from django.core.files.base import ContentFile from dojo.api_v3.api import api_v3 +from dojo.file_uploads.models import FileUpload from dojo.location.models import Location, LocationFindingReference, LocationProductReference from dojo.models import Engagement, Finding, Product, Product_Type, Test +from dojo.notes.models import Notes from .base import ApiV3TestCase from .query_report import capture_request, format_report @@ -72,6 +75,29 @@ def _fan_out_rows(self): LocationFindingReference.objects.create(location=loc, finding=self._loc_finding, status="Active") LocationProductReference.objects.create(location=loc, product=self._loc_product, status="Active") + # Fan out notes / files / tags so the notes|files|tags sub-resource lists (OS5) can't hide a + # per-row query. Capture one file id per parent for the download endpoint. + self._sub_finding = self._loc_finding + self._sub_engagement = Engagement.objects.order_by("pk").first() + self._sub_test = Test.objects.order_by("pk").first() + self._download_file_ids: dict[str, int] = {} + for key, parent in ( + ("findings", self._sub_finding), + ("engagements", self._sub_engagement), + ("tests", self._sub_test), + ): + for i in range(_ROW_FANOUT): + note = Notes.objects.create(entry=f"query-report note {i}", author=self.admin) + parent.notes.add(note) + upload = FileUpload.objects.create( + title=f"query-report {key} file {i}.txt", + file=ContentFile(b"query report file body", name=f"qr-{key}-{i}.txt"), + ) + parent.files.add(upload) + if i == 0: + self._download_file_ids[key] = upload.pk + parent.tags.add(f"qr-tag-{key}") + def _representative_requests(self) -> dict[str, str]: """OpenAPI GET path -> concrete request URL. Extend when a phase adds endpoints.""" finding_id = Finding.objects.first().pk @@ -81,13 +107,13 @@ def _representative_requests(self) -> dict[str, str]: limit = f"limit={_ROW_FANOUT + 5}" return { "/findings": self.v3_url(f"findings?{limit}&expand=test.engagement,locations&include=counts"), - "/findings/{finding_id}": self.v3_url(f"findings/{finding_id}?expand=test.engagement"), # noqa: RUF027 -- OpenAPI path template key, not an f-string + "/findings/{finding_id}": self.v3_url(f"findings/{finding_id}?expand=test.engagement"), # noqa: RUF027 "/products": self.v3_url(f"products?{limit}&expand=product_type"), - "/products/{product_id}": self.v3_url(f"products/{product_id}"), # noqa: RUF027 -- OpenAPI path template key, not an f-string + "/products/{product_id}": self.v3_url(f"products/{product_id}"), # noqa: RUF027 "/product_types": self.v3_url(f"product_types?{limit}"), - "/product_types/{product_type_id}": self.v3_url(f"product_types/{product_type_id}"), # noqa: RUF027 -- OpenAPI path template key, not an f-string + "/product_types/{product_type_id}": self.v3_url(f"product_types/{product_type_id}"), # noqa: RUF027 "/users": self.v3_url(f"users?{limit}"), - "/users/{user_id}": self.v3_url(f"users/{user_id}"), # noqa: RUF027 -- OpenAPI path template key, not an f-string + "/users/{user_id}": self.v3_url(f"users/{user_id}"), # noqa: RUF027 "/engagements": self.v3_url(f"engagements?{limit}"), "/engagements/{engagement_id}": self.v3_url(f"engagements/{Engagement.objects.first().pk}"), "/tests": self.v3_url(f"tests?{limit}"), @@ -96,6 +122,20 @@ def _representative_requests(self) -> dict[str, str]: "/locations/{location_id}": self.v3_url(f"locations/{Location.objects.order_by('pk').first().pk}"), "/findings/{finding_id}/locations": self.v3_url(f"findings/{self._loc_finding.pk}/locations?{limit}"), # noqa: RUF027 "/products/{product_id}/locations": self.v3_url(f"products/{self._loc_product.pk}/locations?{limit}"), # noqa: RUF027 + # OS5 notes / files / tags sub-resources (finding/engagement/test; tags also on product). + "/findings/{parent_id}/notes": self.v3_url(f"findings/{self._sub_finding.pk}/notes?{limit}"), + "/engagements/{parent_id}/notes": self.v3_url(f"engagements/{self._sub_engagement.pk}/notes?{limit}"), + "/tests/{parent_id}/notes": self.v3_url(f"tests/{self._sub_test.pk}/notes?{limit}"), + "/findings/{parent_id}/files": self.v3_url(f"findings/{self._sub_finding.pk}/files?{limit}"), + "/engagements/{parent_id}/files": self.v3_url(f"engagements/{self._sub_engagement.pk}/files?{limit}"), + "/tests/{parent_id}/files": self.v3_url(f"tests/{self._sub_test.pk}/files?{limit}"), + "/findings/{parent_id}/files/{file_id}/download": self.v3_url(f"findings/{self._sub_finding.pk}/files/{self._download_file_ids['findings']}/download"), + "/engagements/{parent_id}/files/{file_id}/download": self.v3_url(f"engagements/{self._sub_engagement.pk}/files/{self._download_file_ids['engagements']}/download"), + "/tests/{parent_id}/files/{file_id}/download": self.v3_url(f"tests/{self._sub_test.pk}/files/{self._download_file_ids['tests']}/download"), + "/findings/{parent_id}/tags": self.v3_url(f"findings/{self._sub_finding.pk}/tags"), + "/engagements/{parent_id}/tags": self.v3_url(f"engagements/{self._sub_engagement.pk}/tags"), + "/tests/{parent_id}/tags": self.v3_url(f"tests/{self._sub_test.pk}/tags"), + "/products/{parent_id}/tags": self.v3_url(f"products/{self._loc_product.pk}/tags"), } def _openapi_get_paths(self) -> set[str]: diff --git a/unittests/api_v3/test_apiv3_subresources.py b/unittests/api_v3/test_apiv3_subresources.py new file mode 100644 index 00000000000..ffc0911b296 --- /dev/null +++ b/unittests/api_v3/test_apiv3_subresources.py @@ -0,0 +1,344 @@ +""" +Generic notes / tags / files sub-resource tests for API v3 (§4.12, OS5). + +Covers the storage support matrix (notes/files: finding/engagement/test; tags: those + product), +note privacy (v2 parity: private notes are returned, not per-user filtered), parent-authorization +inheritance (404 unknown-or-unauthorized parent, 403 write), multipart upload + streamed download +roundtrip, tag replace/append/delete semantics + normalization, the pagination envelope on the +list endpoints, and the MANDATORY constant-query guarantee for the finding notes/tags/files lists. +""" +from __future__ import annotations + +from unittest import mock + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from dojo.models import Dojo_User, Engagement, Finding, Product, Test, User +from dojo.notes.models import Notes + +from .base import ApiV3TestCase + +_NOTE_KEYS = {"id", "entry", "author", "private", "edited", "created", "updated"} +_FILE_KEYS = {"id", "title", "size", "created"} +_ENVELOPE_KEYS = {"count", "next", "previous", "results"} + + +def _txt(name: str = "os5upload.txt", body: bytes = b"os5 file body") -> SimpleUploadedFile: + return SimpleUploadedFile(name, body, content_type="text/plain") + + +class _SubResourceBase(ApiV3TestCase): + + def setUp(self): + super().setUp() + self.finding = Finding.objects.first() + self.engagement = Engagement.objects.first() + self.test = Test.objects.first() + self.product = Product.objects.first() + self.note_file_parents = [ + ("findings", self.finding), + ("engagements", self.engagement), + ("tests", self.test), + ] + self.tag_parents = [*self.note_file_parents, ("products", self.product)] + + +class TestApiV3SubresourcesNotes(_SubResourceBase): + + def test_notes_matrix_create_and_list_roundtrip(self): + for resource, parent in self.note_file_parents: + with self.subTest(resource=resource): + created = self.client.post( + self.v3_url(f"{resource}/{parent.pk}/notes"), + {"entry": f"note on {resource}", "private": False}, + format="json", + ) + self.assertEqual(201, created.status_code, created.content[:400]) + body = created.json() + self.assertEqual(_NOTE_KEYS, set(body)) + self.assertEqual(f"note on {resource}", body["entry"]) + self.assertFalse(body["private"]) + self.assertFalse(body["edited"]) + self.assertEqual(self.admin.pk, body["author"]["id"]) + self.assertEqual("admin", body["author"]["name"]) + self.assertIsNotNone(body["created"]) + + listing = self.get_json(f"{resource}/{parent.pk}/notes") + self.assertEqual(_ENVELOPE_KEYS, set(listing) - {"meta"}) + self.assertIn(body["id"], [n["id"] for n in listing["results"]]) + self.assertEqual(_NOTE_KEYS, set(listing["results"][0])) + + def test_note_created_persisted_and_linked_to_parent(self): + before = self.finding.notes.count() + self.client.post( + self.v3_url(f"findings/{self.finding.pk}/notes"), + {"entry": "persisted"}, format="json", + ) + self.assertEqual(before + 1, self.finding.notes.count()) + + def test_note_unknown_field_is_400(self): + response = self.client.post( + self.v3_url(f"findings/{self.finding.pk}/notes"), + {"entry": "x", "bogus": 1}, format="json", + ) + self.assertEqual(400, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_note_missing_entry_is_400(self): + response = self.client.post( + self.v3_url(f"findings/{self.finding.pk}/notes"), {}, format="json", + ) + self.assertEqual(400, response.status_code) + + +class TestApiV3SubresourcesNotePrivacy(_SubResourceBase): + + def test_private_note_returned_and_flagged(self): + created = self.client.post( + self.v3_url(f"findings/{self.finding.pk}/notes"), + {"entry": "secret", "private": True}, format="json", + ).json() + self.assertTrue(created["private"]) + listing = self.get_json(f"findings/{self.finding.pk}/notes") + match = next(n for n in listing["results"] if n["id"] == created["id"]) + self.assertTrue(match["private"]) + + def test_private_note_visible_to_other_authorized_user_v2_parity(self): + """v2 parity: the notes endpoint returns *all* notes; `private` is not a per-user read filter.""" + member = Dojo_User.objects.create_user(username="v3_note_member", password="x") # noqa: S106 + self.finding.test.engagement.product.authorized_users.add(member) + created = self.client.post( + self.v3_url(f"findings/{self.finding.pk}/notes"), + {"entry": "private-but-visible", "private": True}, format="json", + ).json() + + member_view = self.get_json(f"findings/{self.finding.pk}/notes", client=self.token_client(user=member)) + self.assertIn(created["id"], [n["id"] for n in member_view["results"]]) + + +class TestApiV3SubresourcesFiles(_SubResourceBase): + + def test_files_matrix_upload_list_download_roundtrip(self): + for resource, parent in self.note_file_parents: + with self.subTest(resource=resource): + body_bytes = f"payload-{resource}".encode() + created = self.client.post( + self.v3_url(f"{resource}/{parent.pk}/files"), + {"title": f"os5 {resource} attachment", "file": _txt(body=body_bytes)}, + format="multipart", + ) + self.assertEqual(201, created.status_code, created.content[:400]) + fbody = created.json() + self.assertEqual(_FILE_KEYS, set(fbody)) + self.assertEqual(f"os5 {resource} attachment", fbody["title"]) + self.assertEqual(len(body_bytes), fbody["size"]) + self.assertIsNone(fbody["created"]) + + listing = self.get_json(f"{resource}/{parent.pk}/files") + self.assertEqual(_ENVELOPE_KEYS, set(listing) - {"meta"}) + self.assertIn(fbody["id"], [f["id"] for f in listing["results"]]) + + dl = self.client.get(self.v3_url(f"{resource}/{parent.pk}/files/{fbody['id']}/download")) + self.assertEqual(200, dl.status_code) + self.assertIn("attachment", dl["Content-Disposition"]) + self.assertEqual(body_bytes, b"".join(dl.streaming_content)) + + def test_upload_rejects_disallowed_extension(self): + response = self.client.post( + self.v3_url(f"findings/{self.finding.pk}/files"), + {"title": "os5 evil", "file": _txt(name="evil.exe")}, + format="multipart", + ) + self.assertEqual(400, response.status_code, response.content[:400]) + self.assertEqual("application/problem+json", response["Content-Type"]) + self.assertIn("file", response.json()["fields"]) + + def test_duplicate_title_is_400(self): + payload = {"title": "os5 dup title", "file": _txt()} + first = self.client.post(self.v3_url(f"findings/{self.finding.pk}/files"), payload, format="multipart") + self.assertEqual(201, first.status_code) + dup = self.client.post( + self.v3_url(f"tests/{self.test.pk}/files"), + {"title": "os5 dup title", "file": _txt()}, format="multipart", + ) + self.assertEqual(400, dup.status_code, dup.content[:400]) + + def test_download_missing_file_is_404(self): + response = self.client.get(self.v3_url(f"findings/{self.finding.pk}/files/9999999/download")) + self.assertEqual(404, response.status_code) + + +class TestApiV3SubresourcesTags(_SubResourceBase): + + def test_tags_get_shape_all_resources(self): + for resource, parent in self.tag_parents: + with self.subTest(resource=resource): + body = self.get_json(f"{resource}/{parent.pk}/tags") + self.assertEqual({"tags"}, set(body)) + self.assertIsInstance(body["tags"], list) + + def test_tags_replace_append_delete_semantics(self): + # Product tags have clean semantics (no inheritance-sticky re-add); inheritance is off by + # default anyway, but products are the crispest surface for the exact-set assertions. + for resource, parent in self.tag_parents: + with self.subTest(resource=resource): + base = self.v3_url(f"{resource}/{parent.pk}/tags") + # PUT replace + normalization (force_lowercase). + replaced = self.client.put(base, {"tags": ["PCI", "Sox"]}, format="json") + self.assertEqual(200, replaced.status_code, replaced.content[:400]) + self.assertEqual({"pci", "sox"}, set(replaced.json()["tags"])) + # POST append (dedup, case-insensitive). + appended = self.client.post(base, {"tags": ["Extra", "PCI"]}, format="json") + self.assertEqual(200, appended.status_code) + self.assertEqual({"pci", "sox", "extra"}, set(appended.json()["tags"])) + # DELETE one (case-insensitive match on the stored lowercase tag). + removed = self.client.delete(self.v3_url(f"{resource}/{parent.pk}/tags/PCI")) + self.assertEqual(204, removed.status_code) + self.assertEqual({"sox", "extra"}, set(self.get_json(f"{resource}/{parent.pk}/tags")["tags"])) + + def test_delete_absent_tag_is_404(self): + self.client.put(self.v3_url(f"findings/{self.finding.pk}/tags"), {"tags": ["keep"]}, format="json") + response = self.client.delete(self.v3_url(f"findings/{self.finding.pk}/tags/nope")) + self.assertEqual(404, response.status_code) + self.assertEqual("application/problem+json", response["Content-Type"]) + + def test_tags_unknown_body_field_is_400(self): + response = self.client.put( + self.v3_url(f"findings/{self.finding.pk}/tags"), + {"tags": ["a"], "bogus": 1}, format="json", + ) + self.assertEqual(400, response.status_code) + + +class TestApiV3SubresourcesSupportMatrix(_SubResourceBase): + + """Sub-resources are attached only where the model stores them; everything else is 404.""" + + def test_unsupported_notes_and_files_are_404(self): + pt_id = self.product.prod_type_id + for path in ( + f"product_types/{pt_id}/notes", + f"product_types/{pt_id}/files", + f"products/{self.product.pk}/notes", # product has tags but no notes/files + f"products/{self.product.pk}/files", + f"users/{self.admin.pk}/notes", + f"users/{self.admin.pk}/files", + ): + with self.subTest(path=path): + self.assertEqual(404, self.client.get(self.v3_url(path)).status_code) + + def test_unsupported_tags_are_404(self): + # product_type / user have no TagField; location has one but is read-only + superuser-only + # with no v2 tag-mutation endpoint, so no tag sub-resource is attached (tags[] is on its + # read shape instead). + from dojo.location.models import Location # noqa: PLC0415 + location = Location.objects.first() + for path in ( + f"product_types/{self.product.prod_type_id}/tags", + f"users/{self.admin.pk}/tags", + *([f"locations/{location.pk}/tags"] if location else []), + ): + with self.subTest(path=path): + self.assertEqual(404, self.client.get(self.v3_url(path)).status_code) + + +class TestApiV3SubresourcesAuthInheritance(_SubResourceBase): + + """Authorization inherited from the parent: 404 unknown-or-unauthorized parent, 403 on write.""" + + def test_unknown_parent_is_404(self): + for path in ( + "findings/9999999/notes", + "engagements/9999999/files", + "tests/9999999/tags", + "products/9999999/tags", + ): + with self.subTest(path=path): + self.assertEqual(404, self.client.get(self.v3_url(path)).status_code) + + def test_unauthorized_parent_is_404_not_403(self): + """A non-member cannot see the parent, so every sub-resource op is 404 (never leak existence).""" + limited = User.objects.create_user(username="v3_sub_limited", password="x") # noqa: S106 + client = self.token_client(user=limited) + self.assertEqual(404, client.get(self.v3_url(f"findings/{self.finding.pk}/notes")).status_code) + self.assertEqual(404, client.get(self.v3_url(f"findings/{self.finding.pk}/files")).status_code) + self.assertEqual(404, client.get(self.v3_url(f"findings/{self.finding.pk}/tags")).status_code) + post = client.post( + self.v3_url(f"findings/{self.finding.pk}/notes"), {"entry": "x"}, format="json", + ) + self.assertEqual(404, post.status_code) + + def test_write_forbidden_when_permission_fails_is_403(self): + """ + Parent visible (admin/superuser) but the write permission check fails -> 403. Under the OS + legacy model a viewing member also has edit/add, so this exercises the 403 code path via a + permission-check failure (see .claude/os5-report.md / §12). + """ + with mock.patch("dojo.api_v3.subresources.user_has_permission", return_value=False): + note = self.client.post( + self.v3_url(f"findings/{self.finding.pk}/notes"), {"entry": "x"}, format="json", + ) + self.assertEqual(403, note.status_code, note.content[:300]) + self.assertEqual("application/problem+json", note["Content-Type"]) + + tag = self.client.put( + self.v3_url(f"findings/{self.finding.pk}/tags"), {"tags": ["x"]}, format="json", + ) + self.assertEqual(403, tag.status_code) + + upload = self.client.post( + self.v3_url(f"findings/{self.finding.pk}/files"), + {"title": "os5 403", "file": _txt()}, format="multipart", + ) + self.assertEqual(403, upload.status_code) + + +class TestApiV3SubresourcesPagination(_SubResourceBase): + + def test_notes_list_pagination_envelope(self): + for i in range(5): + note = Notes.objects.create(entry=f"pag {i}", author=self.admin) + self.finding.notes.add(note) + body = self.get_json(f"findings/{self.finding.pk}/notes", data={"limit": 2}) + self.assertGreaterEqual(body["count"], 5) + self.assertEqual(2, len(body["results"])) + self.assertIsNotNone(body["next"]) + self.assertIsNone(body["previous"]) + + +class TestApiV3SubresourcesQueryCounts(_SubResourceBase): + + """MANDATORY: finding notes/tags/files list query counts are independent of row count.""" + + def _query_count(self, path: str) -> int: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url(path)) + self.assertEqual(200, response.status_code) + return len(ctx.captured_queries) + + def test_notes_list_query_count_constant(self): + path = f"findings/{self.finding.pk}/notes?limit=250" + for i in range(5): + self.finding.notes.add(Notes.objects.create(entry=f"n{i}", author=self.admin)) + first = self._query_count(path) + for i in range(50): + self.finding.notes.add(Notes.objects.create(entry=f"m{i}", author=self.admin)) + self.assertEqual(first, self._query_count(path)) + + def test_files_list_query_count_constant(self): + path = f"findings/{self.finding.pk}/files?limit=250" + for i in range(5): + self.finding.files.create(title=f"qc-a-{i}.txt", file=_txt()) + first = self._query_count(path) + for i in range(50): + self.finding.files.create(title=f"qc-b-{i}.txt", file=_txt()) + self.assertEqual(first, self._query_count(path)) + + def test_tags_list_query_count_constant(self): + path = f"findings/{self.finding.pk}/tags" + self.finding.tags.add("t1", "t2", "t3") + first = self._query_count(path) + self.finding.tags.add("t4", "t5", "t6", "t7", "t8", "t9", "t10") + self.assertEqual(first, self._query_count(path)) From 7339474f6656fe5494319d73649c7ead48b8c368 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 15:23:03 +0200 Subject: [PATCH 08/37] feat(api-v3): OS6 - verification sweep, docs, examples, benchmark - RBAC expand sweep (15 tests): no expanded object, included count, denormalized ref, or sub-resource row from outside authorized querysets (ports the v2 prefetch-RBAC test intent) - honest benchmark (1021 findings, limit=100, in-process): v3 constant 5 queries / 37.9ms median vs v2 636 queries / 521ms with ?prefetch; latency directional, query counts load-bearing; CI-excluded harness (DD_API_V3_BENCH=1) - api_v3_examples.md: auto-generated verbatim request/response pairs (findings incl. expand/filters/pagination/counts/notes/locations/ import/PATCH; products as simple contrast; DD_API_V3_EXAMPLES=1) - docs page: automation/api/api-v3-alpha-docs.md with known-alpha-gaps section and beta URL-migration notice - invariant checklist I1-I10 verified: all pass - v2 regression untouched: test_rest_framework 879 OK, test_apiv2_prefetch_rbac 10 OK 267 tests green (+2 CI-excluded harnesses). --- api_v3_examples.md | 896 ++++++++++++++++++ .../automation/api/api-v3-alpha-docs.md | 207 ++++ unittests/api_v3/test_apiv3_benchmark.py | 191 ++++ unittests/api_v3/test_apiv3_examples.py | 310 ++++++ unittests/api_v3/test_apiv3_expand_rbac.py | 230 +++++ 5 files changed, 1834 insertions(+) create mode 100644 api_v3_examples.md create mode 100644 docs/content/automation/api/api-v3-alpha-docs.md create mode 100644 unittests/api_v3/test_apiv3_benchmark.py create mode 100644 unittests/api_v3/test_apiv3_examples.py create mode 100644 unittests/api_v3/test_apiv3_expand_rbac.py diff --git a/api_v3_examples.md b/api_v3_examples.md new file mode 100644 index 00000000000..a8b4a59f50f --- /dev/null +++ b/api_v3_examples.md @@ -0,0 +1,896 @@ +# DefectDojo API v3 (alpha) — worked examples + +> **Auto-generated, do not hand-edit.** Every request/response below was captured by `unittests/api_v3/test_apiv3_examples.py` (`DD_API_V3_EXAMPLES=1`, CI-excluded) making **real** in-process requests against the test fixture. Tokens are redacted; long lists are truncated to ~3 rows. Regenerate with the command in that file's docstring. + +Captured: 2026-07-19T13:07:38.917110+00:00 + +## Conventions (see `API_V3_PLAN.md` §4) + +- **Mount:** alpha lives at `/api/v3-alpha/` (moves to `/api/v3/` at beta — one migration, D1). Every response carries `X-API-Status: alpha`. +- **Auth (D8):** send an existing v2 token as `Authorization: Token ` (works unchanged on v3), or a Django session cookie + `X-CSRFToken` on unsafe methods. +- **Envelope (§4.3):** every list is `{count, next, previous, results, meta?}` and nothing else (I1). `next`/`previous` are opaque URLs; default `limit=25`, max `250`. +- **Refs (§4.4):** relations render as `{id, name}` (locations add `type`). Write payloads reference relations by integer id — the asymmetry is intentional (§4.11). +- **`?expand=` (§4.6):** dotted paths swap refs for slim objects inline and drive the queryset (the real N+1 fix). Budget-guarded. +- **`?fields=` (§4.7):** comma-separated allowlist; `id` is always included. +- **`?include=counts` (§4.8):** adds aggregate totals to `meta` over the filtered, authorized queryset. +- **Errors (§4.10):** RFC 9457 `application/problem+json` with a `fields` extension for validation errors. + +--- +### Finding — GET detail (slim + detail fields) + +Retrieve a single finding. Relations render as closed `{id, name}` refs (§4.4); the parent chain (`test`/`engagement`/`product`/`product_type`) is denormalized onto the finding. `locations_count` is an annotation; the full list is a sub-resource (§4.14). + +**Request** + +```http +GET /api/v3-alpha/findings/2 +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "id": 2, + "title": "High Impact Test Finding", + "severity": "High", + "active": true, + "verified": true, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2020-05-21", + "cwe": null, + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "product": { + "id": 2, + "name": "Security How-to" + }, + "product_type": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 3, + "tags": [], + "created": "2017-12-01T00:00:00Z", + "updated": null, + "description": "test finding", + "mitigation": "test mitigation", + "impact": "High", + "steps_to_reproduce": null, + "severity_justification": null, + "references": "", + "file_path": "", + "line": null, + "mitigated": null, + "mitigated_by": null +} +``` + + +--- + +### Finding — GET detail with `?expand=test.engagement,locations` + +`?expand=` swaps a ref for the target's slim object inline and drives real `select_related`/`prefetch_related` (§4.6). `expand=locations` replaces `locations_count` with edge rows `{location, status, audit_time, auditor}`. + +**Request** + +```http +GET /api/v3-alpha/findings/2?expand=test.engagement,locations +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "id": 2, + "title": "High Impact Test Finding", + "severity": "High", + "active": true, + "verified": true, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2020-05-21", + "cwe": null, + "test": { + "id": 3, + "name": null, + "test_type": { + "id": 1, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement", + "product": { + "id": 2, + "name": "Security How-to" + }, + "product_type": { + "id": 2, + "name": "ebooks" + }, + "lead": { + "id": 2, + "name": "user1" + }, + "status": "In Progress", + "engagement_type": "Interactive", + "target_start": "2018-04-12", + "target_end": "2018-04-12", + "active": true, + "tags": [], + "created": null, + "updated": null + }, + "product": { + "id": 2, + "name": "Security How-to" + }, + "product_type": { + "id": 2, + "name": "ebooks" + }, + "environment": { + "id": 1, + "name": "Development" + }, + "lead": null, + "target_start": "2017-12-01T00:00:00Z", + "target_end": "2017-12-10T00:00:00Z", + "percent_complete": 100, + "tags": [], + "created": null, + "updated": null + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "product": { + "id": 2, + "name": "Security How-to" + }, + "product_type": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "tags": [], + "created": "2017-12-01T00:00:00Z", + "updated": null, + "description": "test finding", + "mitigation": "test mitigation", + "impact": "High", + "steps_to_reproduce": null, + "severity_justification": null, + "references": "", + "file_path": "", + "line": null, + "mitigated": null, + "mitigated_by": null, + "locations": [ + { + "location": { + "id": 2, + "name": "ftp://localhost", + "type": "url" + }, + "status": "Active", + "audit_time": null, + "auditor": null + }, + { + "location": { + "id": 9, + "name": "https://example.com/login", + "type": "url" + }, + "status": "Active", + "audit_time": null, + "auditor": null + }, + { + "location": { + "id": 10, + "name": "https://example.com/admin", + "type": "url" + }, + "status": "Mitigated", + "audit_time": null, + "auditor": null + } + ] +} +``` + + +--- + +### Finding — GET list, filtered (`severity=High&active=true`) + pagination page 2 + +The filter grammar is a documented, snapshot-tested vocabulary (§4.9). The list envelope is always `{count, next, previous, results, meta?}` (I1); `next`/`previous` are opaque URLs (D4). Here `limit=2&offset=2` is page 2, so both are non-null. + +**Request** + +```http +GET /api/v3-alpha/findings?severity=High&active=true&limit=2&offset=2 +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "count": 8, + "next": "http://testserver/api/v3-alpha/findings?severity=High&active=true&limit=2&offset=4", + "previous": "http://testserver/api/v3-alpha/findings?severity=High&active=true&limit=2&offset=0", + "results": [ + { + "id": 234, + "title": "example high active 0", + "severity": "High", + "active": true, + "verified": true, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2026-07-19", + "cwe": 0, + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "product": { + "id": 2, + "name": "Security How-to" + }, + "product_type": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 0, + "tags": [], + "created": "2026-07-19T13:07:38.625Z", + "updated": "2026-07-19T13:07:38.625Z" + }, + { + "id": 235, + "title": "example high active 1", + "severity": "High", + "active": true, + "verified": true, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2026-07-19", + "cwe": 0, + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "product": { + "id": 2, + "name": "Security How-to" + }, + "product_type": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 0, + "tags": [], + "created": "2026-07-19T13:07:38.625Z", + "updated": "2026-07-19T13:07:38.625Z" + } + ] +} +``` + + +--- + +### Finding — GET list with `?include=counts` + +`?include=counts` adds severity/status totals computed over the *filtered, authorized* queryset into `meta` in one aggregate query — no second round-trip (§4.8). + +**Request** + +```http +GET /api/v3-alpha/findings?include=counts&limit=2 +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "count": 27, + "next": "http://testserver/api/v3-alpha/findings?include=counts&limit=2&offset=2", + "previous": null, + "results": [ + { + "id": 2, + "title": "High Impact Test Finding", + "severity": "High", + "active": true, + "verified": true, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2020-05-21", + "cwe": null, + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "product": { + "id": 2, + "name": "Security How-to" + }, + "product_type": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 3, + "tags": [], + "created": "2017-12-01T00:00:00Z", + "updated": null + }, + { + "id": 3, + "title": "High Impact Test Finding", + "severity": "High", + "active": false, + "verified": false, + "false_p": false, + "duplicate": true, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2021-01-01", + "cwe": null, + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "product": { + "id": 2, + "name": "Security How-to" + }, + "product_type": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 0, + "tags": [], + "created": "2017-12-01T00:00:00Z", + "updated": null + } + ], + "meta": { + "counts": { + "total": 27, + "active": 17, + "verified": 18, + "duplicate": 8, + "severity": { + "Critical": 0, + "High": 13, + "Medium": 1, + "Low": 7, + "Info": 6 + } + } + } +} +``` + + +--- + +### Finding — POST a note (sub-resource) + +Notes are one generic sub-resource across resources (§4.12). Authorization is inherited from the parent finding. + +**Request** + +```http +POST /api/v3-alpha/findings/2/notes +Authorization: Token +Content-Type: application/json + +{ + "entry": "Reviewed with the product team; scheduled for the next sprint.", + "private": false +} +``` + +**Response** — `201` + +```json +{ + "id": 2, + "entry": "Reviewed with the product team; scheduled for the next sprint.", + "author": { + "id": 1, + "name": "admin" + }, + "private": false, + "edited": false, + "created": "2026-07-19T13:07:38.688Z", + "updated": "2026-07-19T13:07:38.688Z" +} +``` + + +--- + +### Finding — GET notes (sub-resource) + +List a finding's notes (paginated envelope). v2 parity: all notes are returned; `private` is a label, not a per-user read filter (§12). + +**Request** + +```http +GET /api/v3-alpha/findings/2/notes +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 2, + "entry": "Reviewed with the product team; scheduled for the next sprint.", + "author": { + "id": 1, + "name": "admin" + }, + "private": false, + "edited": false, + "created": "2026-07-19T13:07:38.688Z", + "updated": "2026-07-19T13:07:38.688Z" + } + ] +} +``` + + +--- + +### Finding — GET locations (sub-resource, edge rows) + +Finding↔Location is many-to-many with status on the edge (D5). Each row is a location ref (carrying `type`) plus the edge `status`/`audit_time`/`auditor` (§4.14). + +**Request** + +```http +GET /api/v3-alpha/findings/2/locations +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "count": 3, + "next": null, + "previous": null, + "results": [ + { + "location": { + "id": 2, + "name": "ftp://localhost", + "type": "url" + }, + "status": "Active", + "audit_time": null, + "auditor": null + }, + { + "location": { + "id": 9, + "name": "https://example.com/login", + "type": "url" + }, + "status": "Active", + "audit_time": null, + "auditor": null + }, + { + "location": { + "id": 10, + "name": "https://example.com/admin", + "type": "url" + }, + "status": "Mitigated", + "audit_time": null, + "auditor": null + } + ] +} +``` + + +--- + +### Finding — PATCH (partial update) + +Partial update (PATCH-only in alpha; §12). Write payloads reference relations by integer id and only send changed fields; the response is the updated detail shape. + +**Request** + +```http +PATCH /api/v3-alpha/findings/240 +Authorization: Token +Content-Type: application/json + +{ + "severity": "Medium", + "verified": true +} +``` + +**Response** — `200` + +```json +{ + "id": 240, + "title": "Example Finding to Patch", + "severity": "Medium", + "active": true, + "verified": true, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2026-07-19", + "cwe": 0, + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "product": { + "id": 2, + "name": "Security How-to" + }, + "product_type": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 0, + "tags": [], + "created": "2026-07-19T13:07:38.711Z", + "updated": "2026-07-19T13:07:38.759Z", + "description": "before patch", + "mitigation": null, + "impact": null, + "steps_to_reproduce": null, + "severity_justification": null, + "references": null, + "file_path": null, + "line": null, + "mitigated": null, + "mitigated_by": null +} +``` + + +--- + +### Import — POST /import (consolidated import/reimport/auto) + +One endpoint for import, reimport and auto-resolve (§4.13). Destructive flags are never implied by mode; the response echoes the resolved mode + effective flags. + +**Request** + +```http +POST /api/v3-alpha/import +Authorization: Token +Content-Type: multipart/form-data + +multipart/form-data fields: + mode=import # auto | import | reimport (default auto) + scan_type=ZAP Scan + engagement=4 # or test= / product_name+engagement_name+auto_create_context + file=@0_zap_sample.xml + active=true + verified=true +``` + +**Response** — `200` + +```json +{ + "mode_resolved": "import", + "test": { + "id": 92, + "name": "ZAP Scan" + }, + "statistics": { + "new": 4, + "reactivated": 0, + "closed": 0, + "untouched": 0 + }, + "close_old_findings": false +} +``` + + +--- + +### Product — GET detail + +A simple entity for contrast with findings: identity, `product_type` ref, and the documented heavier detail fields. + +**Request** + +```http +GET /api/v3-alpha/products/1 +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "id": 1, + "name": "Python How-to", + "description": "test product", + "product_type": { + "id": 1, + "name": "books" + }, + "lifecycle": null, + "tags": [], + "created": null, + "updated": null, + "business_criticality": null, + "platform": null, + "origin": null, + "external_audience": false, + "internet_accessible": false, + "product_manager": { + "id": 1, + "name": "admin" + }, + "technical_contact": { + "id": 3, + "name": "user2" + }, + "team_manager": { + "id": 2, + "name": "user1" + } +} +``` + + +--- + +### Product — GET list + +Same envelope and grammar as findings; slim rows only on list (§4.5). + +**Request** + +```http +GET /api/v3-alpha/products?limit=2 +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "count": 3, + "next": "http://testserver/api/v3-alpha/products?limit=2&offset=2", + "previous": null, + "results": [ + { + "id": 1, + "name": "Python How-to", + "description": "test product", + "product_type": { + "id": 1, + "name": "books" + }, + "lifecycle": null, + "tags": [], + "created": null, + "updated": null + }, + { + "id": 2, + "name": "Security How-to", + "description": "test product", + "product_type": { + "id": 2, + "name": "ebooks" + }, + "lifecycle": null, + "tags": [], + "created": null, + "updated": null + } + ] +} +``` + + +--- + +### Product — POST (create) + +Create a product. Relations are referenced by integer id (§4.11); unknown fields are rejected (400). Response is the created detail shape (`201`). + +**Request** + +```http +POST /api/v3-alpha/products +Authorization: Token +Content-Type: application/json + +{ + "name": "Example v3 Product", + "description": "Created via the v3 API examples harness", + "prod_type": 1, + "lifecycle": "production", + "tags": [ + "pci", + "example" + ] +} +``` + +**Response** — `201` + +```json +{ + "id": 4, + "name": "Example v3 Product", + "description": "Created via the v3 API examples harness", + "product_type": { + "id": 1, + "name": "books" + }, + "lifecycle": "production", + "tags": [ + "example", + "pci" + ], + "created": "2026-07-19T13:07:38.890Z", + "updated": "2026-07-19T13:07:38.890Z", + "business_criticality": null, + "platform": null, + "origin": null, + "external_audience": false, + "internet_accessible": false, + "product_manager": null, + "technical_contact": null, + "team_manager": null +} +``` + + +--- + +### Product — PATCH (partial update) + +Partial update; only the changed field is sent. + +**Request** + +```http +PATCH /api/v3-alpha/products/4 +Authorization: Token +Content-Type: application/json + +{ + "description": "Updated description via PATCH" +} +``` + +**Response** — `200` + +```json +{ + "id": 4, + "name": "Example v3 Product", + "description": "Updated description via PATCH", + "product_type": { + "id": 1, + "name": "books" + }, + "lifecycle": "production", + "tags": [ + "example", + "pci" + ], + "created": "2026-07-19T13:07:38.890Z", + "updated": "2026-07-19T13:07:38.912Z", + "business_criticality": null, + "platform": null, + "origin": null, + "external_audience": false, + "internet_accessible": false, + "product_manager": null, + "technical_contact": null, + "team_manager": null +} +``` + diff --git a/docs/content/automation/api/api-v3-alpha-docs.md b/docs/content/automation/api/api-v3-alpha-docs.md new file mode 100644 index 00000000000..c3c4d088020 --- /dev/null +++ b/docs/content/automation/api/api-v3-alpha-docs.md @@ -0,0 +1,207 @@ +--- +title: "DefectDojo API v3 (alpha)" +description: "A new, parallel API mounted alongside v2 with refs/expand/fields, one list envelope, and a documented filter contract. Alpha — the contract may change." +draft: false +weight: 3 +aliases: + - /en/api/api-v3-alpha-docs +--- + +> **Alpha — do not build production dependencies on this.** +> API v3 is in **alpha**. It is mounted at **`/api/v3-alpha/`** precisely so the instability is +> impossible to miss: the alpha contract may change at any time. At **beta the API moves to +> `/api/v3/` and stays there** through GA — so you migrate the URL exactly once, at the moment you +> re-verify against the settled contract. Every v3 response also carries the header +> `X-API-Status: alpha`. + +## Overview + +API v3 is a **new, parallel API** that runs next to the existing [API v2](../api-v2-docs/). v2 is +**not changed** by v3 and remains fully supported — v3 is entirely additive. + +The alpha covers seven objects — `product_type`, `product`, `engagement`, `test`, `finding`, +`location`, `user` — plus a single consolidated `import` endpoint. Everything else stays on v2 until +v3 reaches parity. + +What v3 gives you over v2: + +- **A slim default response with `{id, name}` refs**, opt-in `?expand=` to inline related objects, + and `?fields=` to pick columns — instead of v2's post-serialization `?prefetch=`. Because expand + drives real `select_related`/`prefetch_related`, the query count is **constant regardless of how + many rows you fetch** (v2's `?prefetch=` issues per-row queries). +- **One list envelope** for every collection, with optional aggregate totals in `meta`. +- **One documented, versioned filter contract** shared across every list. +- **RFC 9457 `application/problem+json`** errors with a `fields` extension for validation. + +### Interactive docs + +- Interactive API reference (Swagger UI): **`/api/v3-alpha/docs`** +- OpenAPI schema: **`/api/v3-alpha/openapi.json`** (`info.version = 3.0.0-alpha`) + +The "try it out" flow works with your logged-in session, or paste a token (see below). + +## Authentication + +Two modes are active on **every** endpoint: + +- **Token — your existing v2 token works unchanged.** Send `Authorization: Token `. v3 + validates against the same token store as v2, so a key that works on `/api/v2/` works on + `/api/v3-alpha/` the same day — no new token, no re-issue. + + ``` + Authorization: Token c8572a5adf107a693aa6c72584da31f4d1f1dcff + ``` + +- **Django session + CSRF.** A session cookie plus an `X-CSRFToken` header on unsafe methods + (`POST`/`PATCH`/`PUT`/`DELETE`). This is what the first-party web UI uses. + +Anonymous requests get `401` `application/problem+json`. Authentication is separate from +authorization: after a request authenticates, object access flows through exactly the same +`get_authorized_*` querysets and RBAC as v2. + +## The contract in one page + +### List envelope + +Every list returns the same closed envelope — and nothing else: + +```jsonc +{ + "count": 4321, // exact below a cap; a planner estimate above it (see Known gaps) + "next": "https://.../api/v3-alpha/findings?limit=25&offset=50&severity=High", + "previous": null, + "results": [ /* slim objects */ ], + "meta": { } // present only when ?include= adds content +} +``` + +`limit` defaults to `25`, max `250`; `offset` ≥ 0. **Treat `next`/`previous` as opaque URLs** — do +not construct them yourself. + +### Refs + +Every relation renders by default as a closed ref: + +```jsonc +{ "id": 42, "name": "Q3 Pentest" } +``` + +The relation key tells you the type, so refs carry no `type` field — **except** location refs, which +add `"type": ""`. On writes you reference a relation by its **integer id** +(`"test": 9`); on reads you get a ref back. This asymmetry is intentional. + +### `?expand=`, `?fields=`, `?include=` + +| Param | Does | Example | +|---|---|---| +| `?expand=` | Swaps a ref for the target's slim object **inline** and drives the queryset. Comma-separated dotted paths, budget-guarded. | `?expand=test.engagement,reporter` | +| `?fields=` | Allowlist of fields to return (`id` always included). The picker facility. | `?fields=id,title,severity` | +| `?include=counts` | Adds severity/status totals over the **filtered, authorized** queryset to `meta`, in one query — no second round-trip. | `?include=counts` | + +A finding's locations are special: `?expand=locations` replaces the cheap `locations_count` with the +full edge rows `{location, status, audit_time, auditor}`. + +### Filter grammar + +The grammar is fixed and the per-object vocabulary is documented and snapshot-tested: + +- exact `field=`, and lookups `field__gte` / `__lte` / `__gt` / `__lt` / `__in` / `__icontains` / + `__isnull` (`__in` takes comma-separated values), +- multi-sort `o=` (comma list, `-` prefix for descending, e.g. `o=-severity,date`), +- free-text `q=`. + +Unknown filter params, unknown `?fields=`, and unknown/over-budget `?expand=` all return `400` +`application/problem+json`. + +### Errors + +```jsonc +{ "type": "https://docs.defectdojo.com/api/v3/errors/validation", + "title": "Validation failed", "status": 400, + "detail": "2 fields failed validation", + "fields": { "severity": ["Not a valid choice."], "date": ["Required."] } } +``` + +`401` unauthenticated · `403` forbidden · `404` unknown **or unauthorized** object (existence is +never leaked) · `400` validation/expand/filter errors. + +## v2 → v3 mapping + +| Object | v2 | v3 (alpha) | Notes | +|---|---|---|---| +| Product type | `/api/v2/product_types/` | `/api/v3-alpha/product_types` | CRUD (PATCH-only partial update) | +| Product | `/api/v2/products/` | `/api/v3-alpha/products` | CRUD | +| Engagement | `/api/v2/engagements/` | `/api/v3-alpha/engagements` | CRUD | +| Test | `/api/v2/tests/` | `/api/v3-alpha/tests` | CRUD | +| Finding | `/api/v2/findings/` | `/api/v3-alpha/findings` | CRUD + `?expand=`, `?include=counts` | +| Location | `/api/v2/endpoints/` (legacy `Endpoint`) | `/api/v3-alpha/locations` | **Concept change** (see below); read-only in alpha | +| User | `/api/v2/users/` | `/api/v3-alpha/users` | Read + self; admin-only writes | +| Import | `/api/v2/import-scan/` + `/api/v2/reimport-scan/` | `/api/v3-alpha/import` | **Consolidated** — one endpoint, `mode=auto\|import\|reimport` | + +### Locations replace endpoints + +v3 does not expose the legacy `Endpoint` model. A finding relates to **locations** many-to-many, with +the status (`Active` / `Mitigated` / `FalsePositive` / `RiskAccepted` / `OutOfScope`) carried on the +edge. Slim findings carry `locations_count`; the full edge list is a sub-resource: + +- `GET /api/v3-alpha/findings/{id}/locations` → `{ location: {id, name, type}, status, audit_time, auditor }` +- `GET /api/v3-alpha/products/{id}/locations` → `{ location: {id, name, type}, status }` + +### Import in one call + +`POST /api/v3-alpha/import` (multipart) covers all three flows. `mode=auto` resolves an existing +target via product/engagement names; `import` and `reimport` are explicit. **Destructive flags are +never implied by mode** — if you omit `close_old_findings`, the importer default applies and the +response echoes the effective value: + +```jsonc +{ "mode_resolved": "reimport", "test": { "id": 9, "name": "ZAP Scan" }, + "statistics": { "new": 4, "reactivated": 1, "closed": 2, "untouched": 37 }, + "close_old_findings": true } +``` + +## One way to do notes, tags and files + +Where v2 accreted per-resource actions and mechanisms, v3 has **one generic sub-resource** for each, +attached to every resource whose model stores it (notes/files: finding, engagement, test; tags: +those plus product): + +``` +GET / POST /api/v3-alpha//{id}/notes { "entry": "...", "private": false } +GET / PUT / POST /api/v3-alpha//{id}/tags { "tags": ["pci"] } # PUT replaces, POST appends +DELETE /api/v3-alpha//{id}/tags/{tag} +GET / POST /api/v3-alpha//{id}/files (multipart: file, title) +GET /api/v3-alpha//{id}/files/{file_id}/download +``` + +Authorization for a sub-resource is inherited from its parent object. + +More worked request/response pairs live in [`api_v3_examples.md`](https://github.com/DefectDojo/django-DefectDojo/blob/dev/api_v3_examples.md) +at the repository root. + +## Known alpha gaps + +The alpha delivers the contract and the seven read/write surfaces above. These are **known, +deliberate** gaps — several exist so the alpha stayed additive and reviewable: + +- **Note side-effects are not fired yet.** Creating a note via `POST .../{id}/notes` persists the + note but does **not** yet trigger the side-effects the v2 UI/serializer path does — JIRA comment + sync, `last_reviewed` / `last_reviewed_by` stamping, and `@mention` notifications. These arrive on + the post-alpha convergence track. +- **Finding writes mirror the v2 serializer, not the UI view.** The finding create/update service + reproduces the v2 API serializer semantics (JIRA push, risk acceptance, vuln-ids/CWE). UI-only + behaviors are deferred: auto-mitigation when `active` flips off, false-positive-history + reactivation, `last_reviewed` stamping, finding-group push, GitHub sync, and JIRA link/unlink. +- **Locations are URL-only.** Only the `URL` location subtype persists today; `Code` and + `Dependency` subtypes are dropped on import until their models land. Locations are **read-only** in + the alpha (lifecycle is import-driven). +- **No bulk or workflow actions yet.** No bulk operations, no workflow actions + (`close`/`request_review`/`mark_duplicate`), no aggregation/chart endpoints, no saved views, no + CSV export, no delete-impact preview. Cursor pagination and background-import jobs are reserved in + the grammar (they return `400 "not yet available"`) but not implemented. +- **Counts above the cap are estimates.** Below `COUNT_CAP` (default 10,000) `count` is exact. Above + it, `count` is the Postgres planner's row estimate for the filtered query, clamped to ≥ CAP+1 and + flagged `"count_exact": false` in `meta`. Estimates depend on planner statistics and may drift; + when you page-jump near an estimated end you may get an empty `results` with `next: null`. + +See `API_V3_PLAN.md` in the repository for the full contract specification and decision log. diff --git a/unittests/api_v3/test_apiv3_benchmark.py b/unittests/api_v3/test_apiv3_benchmark.py new file mode 100644 index 00000000000..c379aed9515 --- /dev/null +++ b/unittests/api_v3/test_apiv3_benchmark.py @@ -0,0 +1,191 @@ +r""" +In-process latency + query-count benchmark: v2 ``?prefetch=`` vs v3 ``?expand=`` (OS6, §11). + +**CI-EXCLUDED.** Gated behind ``DD_API_V3_BENCH=1`` so the normal suite never pays for it. Run: + + docker compose exec -e DD_API_V3_BENCH=1 uwsgi \\ + python manage.py test unittests.api_v3.test_apiv3_benchmark -v2 --keepdb + +It seeds ~1000 findings on ONE product/test, then times ``N=30`` repeated in-process test-client +GETs against each endpoint and captures the per-request SQL count. The methodology + caveats and +the results table are written **verbatim** to ``.claude/os6-benchmark.md`` (repo root is bind-mounted +into the container) and echoed to stdout. Numbers are **never fabricated**. + +Honesty caveats (also written into the report): these are IN-PROCESS numbers -- no HTTP layer, no +uwsgi worker, no network, no connection-pool warmup; single-threaded; the requests share one test +transaction against the (kept) test DB. They are therefore **directional** (they isolate the +serialization + ORM cost that the query-count gap predicts), not a substitute for a wall-clock +latency benchmark against a running uwsgi stack (the procedure for which is in +``.claude/os1-gate-report.md``). The query counts, by contrast, are exact and environment-independent. +""" +from __future__ import annotations + +import math +import os +import statistics +import time +from pathlib import Path +from unittest import skipUnless + +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from dojo.models import Finding, Test +from dojo.utils import get_system_setting + +from .base import ApiV3TestCase + +_BENCH_ON = os.environ.get("DD_API_V3_BENCH") == "1" +_SEED = 1000 # findings bulk-created on one test/product +_N = 30 # timed repetitions per endpoint +_LIMIT = 100 # page size under test +_REPO_ROOT = Path(__file__).resolve().parents[2] +_REPORT = _REPO_ROOT / ".claude" / "os6-benchmark.md" + + +def _percentile(values: list[float], pct: float) -> float: + """Nearest-rank percentile (e.g. p95 of 30 samples -> the 29th smallest).""" + ordered = sorted(values) + idx = max(0, math.ceil(pct / 100 * len(ordered)) - 1) + return ordered[idx] + + +@skipUnless(_BENCH_ON, "benchmark is CI-excluded; set DD_API_V3_BENCH=1 to run") +class TestApiV3Benchmark(ApiV3TestCase): + + def _v2_url(self, query: str) -> str: + prefix = get_system_setting("url_prefix") + return f"/{prefix}api/v2/findings/{query}" + + def _time_endpoint(self, path: str) -> tuple[dict, int, int, int]: + """Warm up once, then time N GETs. Returns (stats, status, query_count, rows).""" + # Warmup (populates any per-request caches; excluded from timings). + warm = self.client.get(path) + # One capture for the exact query count. + with CaptureQueriesContext(connection) as ctx: + captured = self.client.get(path) + query_count = len(ctx.captured_queries) + rows = None + if captured.status_code == 200: + body = captured.json() + rows = len(body["results"]) if isinstance(body, dict) and "results" in body else None + + samples: list[float] = [] + for _ in range(_N): + start = time.perf_counter() + self.client.get(path) + samples.append((time.perf_counter() - start) * 1000.0) # ms + stats = { + "median_ms": statistics.median(samples), + "p95_ms": _percentile(samples, 95), + "min_ms": min(samples), + "max_ms": max(samples), + } + return stats, warm.status_code, query_count, rows + + def test_benchmark(self): + test = Test.objects.first() + self.assertIsNotNone(test, "fixture must provide at least one test") + Finding.objects.bulk_create([ + Finding( + title=f"bench finding {i}", severity="High", numerical_severity="S1", + description="benchmark seed", test=test, reporter=self.admin, + active=True, verified=False, + ) + for i in range(_SEED) + ]) + total_findings = Finding.objects.count() + + scenarios = [ + ("v2 list ?prefetch=test", self._v2_url(f"?limit={_LIMIT}&prefetch=test")), + ("v2 list (no prefetch)", self._v2_url(f"?limit={_LIMIT}")), + ("v3 list (slim, no expand)", self.v3_url(f"findings?limit={_LIMIT}")), + ("v3 list ?expand=test.engagement", self.v3_url(f"findings?limit={_LIMIT}&expand=test.engagement")), + ] + + results = [] + for label, path in scenarios: + stats, status, query_count, rows = self._time_endpoint(path) + results.append((label, path, status, query_count, rows, stats)) + + self._write_report(total_findings, results) + + # Assertions turn the harness into a real (if CI-excluded) test: everything responds 200 and + # the v3 query count is dramatically lower than v2's (the headline claim), constant vs rows. + by_label = {label: (status, qc) for label, _, status, qc, _, _ in results} + for label, (status, _qc) in by_label.items(): + self.assertEqual(200, status, f"{label} returned {status}") + self.assertLess( + by_label["v3 list (slim, no expand)"][1], + by_label["v2 list ?prefetch=test"][1], + "v3 slim must issue far fewer queries than v2 prefetch", + ) + + def _write_report(self, total_findings: int, results: list) -> None: + lines = [ + "# API v3 — OS6 latency & query-count benchmark", + "", + ("**Generated by** `unittests/api_v3/test_apiv3_benchmark.py` " + "(`DD_API_V3_BENCH=1`, CI-excluded). Numbers are real captures, never fabricated."), + "", + "## Methodology", + "", + (f"- Seeded **{_SEED}** findings on a single product/test via `bulk_create`; " + f"total findings in DB at measurement time: **{total_findings}**."), + (f"- Page size `limit={_LIMIT}`. For each endpoint: one warmup GET (discarded), one " + f"capture GET for the exact SQL count, then **N={_N} timed** GETs."), + ("- Timing via `time.perf_counter()` around the in-process Django test client " + "(`APIClient`, token auth). Median and nearest-rank p95 over the N samples."), + "- Query count via `CaptureQueriesContext` (one representative request).", + "", + "## CAVEATS (read before quoting the latency numbers)", + "", + ("These are **in-process** measurements. They deliberately isolate the ORM + " + "serialization cost, but they are **NOT** production latency:"), + "", + ("- **No HTTP / uwsgi / network layer** — no WSGI worker, no request parsing over a " + "socket, no gzip, no reverse proxy. The v3 wire win from gzip'd repeated refs (D3) is " + "not captured here at all."), + ("- **Single-threaded**, no connection-pool warmup, no concurrent load — the regime where " + "the v2 per-row query fan-out hurts most (connection contention) is absent."), + ("- **Shared test transaction** against the kept test DB; planner stats and cache state " + "differ from production."), + ("- Therefore: treat **latency** as *directional* (it should track the query-count gap) " + "and treat the **query counts** as the load-bearing, environment-independent evidence."), + ("- The honest wall-clock procedure against a running uwsgi stack is recorded in " + "`.claude/os1-gate-report.md` ('Latency numbers: PENDING') and remains the way to get " + "quotable production latency."), + "", + "## Results", + "", + "| Scenario | Status | Queries | Rows | Median (ms) | p95 (ms) | Min (ms) | Max (ms) |", + "|---|---:|---:|---:|---:|---:|---:|---:|", + ] + for label, _path, status, query_count, rows, stats in results: + lines.append( + f"| {label} | {status} | {query_count} | {rows if rows is not None else '-'} " + f"| {stats['median_ms']:.2f} | {stats['p95_ms']:.2f} " + f"| {stats['min_ms']:.2f} | {stats['max_ms']:.2f} |", + ) + lines += [ + "", + "## Interpretation", + "", + ("The query counts are the headline: v3's slim/expand paths issue a **constant** number " + "of queries independent of row count (the N+1 fix, D3), whereas v2's post-serialization " + "`?prefetch=` issues per-row queries. The in-process median/p95 should move in the same " + "direction as the query gap; quote them only with the caveats above."), + "", + ] + report = "\n".join(lines) + # Echo to stdout FIRST so the numbers are captured even if the file write is not permitted + # (the container user may not own .claude/); the markdown between the markers is verbatim. + print("\n===== OS6 BENCHMARK REPORT (verbatim) =====") # noqa: T201 + print(report) # noqa: T201 + print("===== END OS6 BENCHMARK REPORT =====") # noqa: T201 + try: + _REPORT.parent.mkdir(parents=True, exist_ok=True) + _REPORT.write_text(report, encoding="utf-8") + print(f"[benchmark] wrote {_REPORT}") # noqa: T201 + except OSError as exc: # best-effort: .claude/ may be host-owned/read-only for this uid + print(f"[benchmark] could not write {_REPORT} ({exc}); use the verbatim block above") # noqa: T201 diff --git a/unittests/api_v3/test_apiv3_examples.py b/unittests/api_v3/test_apiv3_examples.py new file mode 100644 index 00000000000..b71faca4c00 --- /dev/null +++ b/unittests/api_v3/test_apiv3_examples.py @@ -0,0 +1,310 @@ +r""" +Real request/response capture harness -> ``api_v3_examples.md`` (repo root) (OS6, §6). + +**CI-EXCLUDED.** Gated behind ``DD_API_V3_EXAMPLES=1`` so the normal suite never runs it. Run: + + docker compose exec -e DD_API_V3_EXAMPLES=1 uwsgi \\ + python manage.py test unittests.api_v3.test_apiv3_examples -v2 --keepdb + +Every example is a **real** request executed through the in-process Django test client against the +fixture data; the response bodies are rendered **verbatim** (only the caller's token is redacted and +long ``results`` lists are truncated to ~3 rows with an explicit marker, per the task). The rendered +markdown is written to ``/app/api_v3_examples.md`` (the repo root is bind-mounted, so it persists to +the host) and echoed to stdout between markers as a fallback. Nothing is hand-written or fabricated. +""" +from __future__ import annotations + +import datetime +import json +import os +from pathlib import Path +from unittest import skipUnless + +from dojo.location.models import Location, LocationFindingReference +from dojo.models import Finding, Product, Product_Type +from dojo.utils import get_system_setting + +from .base import ApiV3TestCase + +_EXAMPLES_ON = os.environ.get("DD_API_V3_EXAMPLES") == "1" +_REPO_ROOT = Path(__file__).resolve().parents[2] +_OUT = _REPO_ROOT / "api_v3_examples.md" +_TOKEN_PLACEHOLDER = "" # doc placeholder, not a secret +_MAX_ROWS = 3 + + +def _truncate(body: object) -> object: + """Truncate a list envelope's ``results`` to ~3 rows with an explicit marker (task rule).""" + if isinstance(body, dict) and isinstance(body.get("results"), list): + results = body["results"] + if len(results) > _MAX_ROWS: + trimmed = dict(body) + hidden = len(results) - _MAX_ROWS + trimmed["results"] = [*results[:_MAX_ROWS], f"... {hidden} more result(s) truncated"] + return trimmed + return body + + +def _pretty(body: object) -> str: + return json.dumps(body, indent=2, default=str) + + +@skipUnless(_EXAMPLES_ON, "examples harness is CI-excluded; set DD_API_V3_EXAMPLES=1 to run") +class TestApiV3Examples(ApiV3TestCase): + + def setUp(self): + super().setUp() + self.blocks: list[str] = [] + self.prefix = get_system_setting("url_prefix") + + def _v2_findings_url(self, query: str = "") -> str: + return f"/{self.prefix}api/v2/findings/{query}" + + # --- capture / render ----------------------------------------------------------------------- + def _record( + self, *, title: str, intro: str, method: str, url: str, response, + req_headers: list[str] | None = None, req_body: str | None = None, truncate: bool = False, + ) -> None: + headers = req_headers or [f"Authorization: Token {_TOKEN_PLACEHOLDER}"] + lines = [f"### {title}", "", intro, "", "**Request**", "", "```http", f"{method} {url}"] + lines += headers + if req_body is not None: + lines += ["", req_body] + lines += ["```", "", f"**Response** — `{response.status_code}`", ""] + if getattr(response, "streaming", False): + lines += ["```", "", "```", ""] + else: + try: + body = response.json() + if truncate: + body = _truncate(body) + rendered = _pretty(body) + except ValueError: + rendered = response.content.decode("utf-8", "replace") or "" + lines += ["```json", rendered, "```", ""] + self.blocks.append("\n".join(lines)) + + # --- the harness ---------------------------------------------------------------------------- + def test_capture_examples(self): + finding = Finding.objects.select_related("test__engagement__product").first() + self.assertIsNotNone(finding) + + # Attach a couple of URL location edges so the expand=locations / sub-resource examples show + # real edge rows (locations are import-driven; seeding here only makes the doc illustrative). + for value, status in (("https://example.com/login", "Active"), ("https://example.com/admin", "Mitigated")): + loc = Location.objects.create(location_type="url", location_value=value) + LocationFindingReference.objects.create(location=loc, finding=finding, status=status) + + # Seed a few High+active findings so the filtered page-2 envelope shows next AND previous. + Finding.objects.bulk_create([ + Finding(title=f"example high active {i}", severity="High", numerical_severity="S1", + description="doc seed", test=finding.test, reporter=self.admin, active=True, verified=True) + for i in range(6) + ]) + + self._capture_findings(finding) + self._capture_products() + self._write(_OUT) + + # --- findings (the complex entity) ---------------------------------------------------------- + def _capture_findings(self, finding: Finding) -> None: + fid = finding.id + + self._record( + title="Finding — GET detail (slim + detail fields)", + intro="Retrieve a single finding. Relations render as closed `{id, name}` refs (§4.4); the " + "parent chain (`test`/`engagement`/`product`/`product_type`) is denormalized onto the " + "finding. `locations_count` is an annotation; the full list is a sub-resource (§4.14).", + method="GET", url=self.v3_url(f"findings/{fid}"), + response=self.client.get(self.v3_url(f"findings/{fid}")), + ) + + self._record( + title="Finding — GET detail with `?expand=test.engagement,locations`", + intro="`?expand=` swaps a ref for the target's slim object inline and drives real " + "`select_related`/`prefetch_related` (§4.6). `expand=locations` replaces " + "`locations_count` with edge rows `{location, status, audit_time, auditor}`.", + method="GET", url=self.v3_url(f"findings/{fid}?expand=test.engagement,locations"), + response=self.client.get(self.v3_url(f"findings/{fid}?expand=test.engagement,locations")), + ) + + self._record( + title="Finding — GET list, filtered (`severity=High&active=true`) + pagination page 2", + intro="The filter grammar is a documented, snapshot-tested vocabulary (§4.9). The list " + "envelope is always `{count, next, previous, results, meta?}` (I1); `next`/`previous` " + "are opaque URLs (D4). Here `limit=2&offset=2` is page 2, so both are non-null.", + method="GET", + url=self.v3_url("findings?severity=High&active=true&limit=2&offset=2"), + response=self.client.get(self.v3_url("findings?severity=High&active=true&limit=2&offset=2")), + truncate=True, + ) + + self._record( + title="Finding — GET list with `?include=counts`", + intro="`?include=counts` adds severity/status totals computed over the *filtered, " + "authorized* queryset into `meta` in one aggregate query — no second round-trip (§4.8).", + method="GET", url=self.v3_url("findings?include=counts&limit=2"), + response=self.client.get(self.v3_url("findings?include=counts&limit=2")), + truncate=True, + ) + + # notes sub-resource: POST then GET + note_body = {"entry": "Reviewed with the product team; scheduled for the next sprint.", "private": False} + self._record( + title="Finding — POST a note (sub-resource)", + intro="Notes are one generic sub-resource across resources (§4.12). Authorization is " + "inherited from the parent finding.", + method="POST", url=self.v3_url(f"findings/{fid}/notes"), + req_headers=[f"Authorization: Token {_TOKEN_PLACEHOLDER}", "Content-Type: application/json"], + req_body=_pretty(note_body), + response=self.client.post(self.v3_url(f"findings/{fid}/notes"), note_body, format="json"), + ) + self._record( + title="Finding — GET notes (sub-resource)", + intro="List a finding's notes (paginated envelope). v2 parity: all notes are returned; " + "`private` is a label, not a per-user read filter (§12).", + method="GET", url=self.v3_url(f"findings/{fid}/notes"), + response=self.client.get(self.v3_url(f"findings/{fid}/notes")), truncate=True, + ) + + self._record( + title="Finding — GET locations (sub-resource, edge rows)", + intro="Finding↔Location is many-to-many with status on the edge (D5). Each row is a " + "location ref (carrying `type`) plus the edge `status`/`audit_time`/`auditor` (§4.14).", + method="GET", url=self.v3_url(f"findings/{fid}/locations"), + response=self.client.get(self.v3_url(f"findings/{fid}/locations")), truncate=True, + ) + + # PATCH a *separate* finding so the read examples above stay pristine. + patch_target = Finding.objects.create( + title="Example finding to patch", severity="Low", numerical_severity="S3", + description="before patch", test=finding.test, reporter=self.admin, active=True, verified=False, + ) + patch_body = {"severity": "Medium", "verified": True} + self._record( + title="Finding — PATCH (partial update)", + intro="Partial update (PATCH-only in alpha; §12). Write payloads reference relations by " + "integer id and only send changed fields; the response is the updated detail shape.", + method="PATCH", url=self.v3_url(f"findings/{patch_target.id}"), + req_headers=[f"Authorization: Token {_TOKEN_PLACEHOLDER}", "Content-Type: application/json"], + req_body=_pretty(patch_body), + response=self.client.patch(self.v3_url(f"findings/{patch_target.id}"), patch_body, format="json"), + ) + + # POST /import (multipart). Described request + real JSON response. + self._capture_import() + + def _capture_import(self) -> None: + from unittests.dojo_test_case import get_unit_tests_scans_path # noqa: PLC0415 + + scan_path = get_unit_tests_scans_path("zap") / "0_zap_sample.xml" + import_desc = ( + "multipart/form-data fields:\n" + " mode=import # auto | import | reimport (default auto)\n" + " scan_type=ZAP Scan\n" + " engagement=4 # or test= / product_name+engagement_name+auto_create_context\n" + " file=@0_zap_sample.xml\n" + " active=true\n" + " verified=true" + ) + with scan_path.open(encoding="utf-8") as scan: + payload = {"scan_type": "ZAP Scan", "mode": "import", "engagement": 4, + "file": scan, "active": "true", "verified": "true"} + response = self.client.post(self.v3_url("import"), payload, format="multipart") + self._record( + title="Import — POST /import (consolidated import/reimport/auto)", + intro="One endpoint for import, reimport and auto-resolve (§4.13). Destructive flags are " + "never implied by mode; the response echoes the resolved mode + effective flags.", + method="POST", url=self.v3_url("import"), + req_headers=[f"Authorization: Token {_TOKEN_PLACEHOLDER}", + "Content-Type: multipart/form-data"], + req_body=import_desc, response=response, + ) + + # --- products (the simple contrast) --------------------------------------------------------- + def _capture_products(self) -> None: + product = Product.objects.first() + self._record( + title="Product — GET detail", + intro="A simple entity for contrast with findings: identity, `product_type` ref, and the " + "documented heavier detail fields.", + method="GET", url=self.v3_url(f"products/{product.id}"), + response=self.client.get(self.v3_url(f"products/{product.id}")), + ) + self._record( + title="Product — GET list", + intro="Same envelope and grammar as findings; slim rows only on list (§4.5).", + method="GET", url=self.v3_url("products?limit=2"), + response=self.client.get(self.v3_url("products?limit=2")), truncate=True, + ) + + pt = Product_Type.objects.first() + create_body = {"name": "Example v3 Product", "description": "Created via the v3 API examples harness", + "prod_type": pt.id, "lifecycle": "production", "tags": ["pci", "example"]} + create_resp = self.client.post(self.v3_url("products"), create_body, format="json") + self._record( + title="Product — POST (create)", + intro="Create a product. Relations are referenced by integer id (§4.11); unknown fields " + "are rejected (400). Response is the created detail shape (`201`).", + method="POST", url=self.v3_url("products"), + req_headers=[f"Authorization: Token {_TOKEN_PLACEHOLDER}", "Content-Type: application/json"], + req_body=_pretty(create_body), response=create_resp, + ) + + new_id = create_resp.json().get("id") if create_resp.status_code == 201 else product.id + patch_body = {"description": "Updated description via PATCH"} + self._record( + title="Product — PATCH (partial update)", + intro="Partial update; only the changed field is sent.", + method="PATCH", url=self.v3_url(f"products/{new_id}"), + req_headers=[f"Authorization: Token {_TOKEN_PLACEHOLDER}", "Content-Type: application/json"], + req_body=_pretty(patch_body), + response=self.client.patch(self.v3_url(f"products/{new_id}"), patch_body, format="json"), + ) + + # --- output --------------------------------------------------------------------------------- + def _header(self) -> str: + prefix = f"/{self.prefix}" if self.prefix else "/" + base = f"{prefix.rstrip('/')}/api/v3-alpha" + return "\n".join([ + "# DefectDojo API v3 (alpha) — worked examples", + "", + ("> **Auto-generated, do not hand-edit.** Every request/response below was captured by " + "`unittests/api_v3/test_apiv3_examples.py` (`DD_API_V3_EXAMPLES=1`, CI-excluded) making " + "**real** in-process requests against the test fixture. Tokens are redacted; long lists " + "are truncated to ~3 rows. Regenerate with the command in that file's docstring."), + "", + f"Captured: {datetime.datetime.now(tz=datetime.UTC).isoformat()}", + "", + "## Conventions (see `API_V3_PLAN.md` §4)", + "", + (f"- **Mount:** alpha lives at `{base}/` (moves to `/api/v3/` at beta — one migration, D1). " + "Every response carries `X-API-Status: alpha`."), + ("- **Auth (D8):** send an existing v2 token as `Authorization: Token ` (works " + "unchanged on v3), or a Django session cookie + `X-CSRFToken` on unsafe methods."), + ("- **Envelope (§4.3):** every list is `{count, next, previous, results, meta?}` and nothing " + "else (I1). `next`/`previous` are opaque URLs; default `limit=25`, max `250`."), + ("- **Refs (§4.4):** relations render as `{id, name}` (locations add `type`). Write payloads " + "reference relations by integer id — the asymmetry is intentional (§4.11)."), + ("- **`?expand=` (§4.6):** dotted paths swap refs for slim objects inline and drive the " + "queryset (the real N+1 fix). Budget-guarded."), + "- **`?fields=` (§4.7):** comma-separated allowlist; `id` is always included.", + ("- **`?include=counts` (§4.8):** adds aggregate totals to `meta` over the filtered, " + "authorized queryset."), + ("- **Errors (§4.10):** RFC 9457 `application/problem+json` with a `fields` extension for " + "validation errors."), + "", + "---", + "", + ]) + + def _write(self, out: Path) -> None: + document = self._header() + "\n\n---\n\n".join(self.blocks) + "\n" + print("\n===== OS6 API V3 EXAMPLES (verbatim) =====") # noqa: T201 + print(document) # noqa: T201 + print("===== END OS6 API V3 EXAMPLES =====") # noqa: T201 + try: + out.write_text(document, encoding="utf-8") + print(f"[examples] wrote {out}") # noqa: T201 + except OSError as exc: + print(f"[examples] could not write {out} ({exc}); use the verbatim block above") # noqa: T201 diff --git a/unittests/api_v3/test_apiv3_expand_rbac.py b/unittests/api_v3/test_apiv3_expand_rbac.py new file mode 100644 index 00000000000..8684d01cafe --- /dev/null +++ b/unittests/api_v3/test_apiv3_expand_rbac.py @@ -0,0 +1,230 @@ +""" +Expand / include / sub-resource RBAC sweep for API v3 (OS6, §11). + +Ports the *intent* of ``unittests/test_apiv2_prefetch_rbac.py`` to the v3 ref / expand / include / +sub-resource surface. The v2 test pinned that ``?prefetch=`` could not reach objects outside the +caller's authorized querysets; this file pins the equivalent guarantee for v3's replacements: + + A user must NEVER see an **expanded** object, an **included** count, a **denormalized parent + ref**, or a **sub-resource** row that was drawn from outside their authorized querysets. + +The v3 design makes this structurally simpler than v2: every projection (slim refs, ``?expand=`` +select/prefetch, ``?include=counts`` aggregate, and the ``/{id}/`` routes) is computed over +``get_authorized_findings(Permissions.Finding_View, user=request.user)`` -- so a finding the caller +cannot see never enters the pipeline, and nothing reachable *from* an authorized finding (its parent +hierarchy, reporter, locations, notes) can therefore belong to another product. These tests build a +two-product world, authorize a non-superuser on exactly one product, and assert the other product's +data never leaks through any v3 projection. + +Setup mirrors the OSS authorization model used by the other v3 RBAC tests +(``test_apiv3_products.py`` / ``test_apiv3_subresources.py``): membership via the legacy +``product.authorized_users`` M2M, which the auth-filter plugin resolves into the finding queryset. +""" +from __future__ import annotations + +import datetime + +from dojo.location.models import Location, LocationFindingReference +from dojo.models import ( + Dojo_User, + Engagement, + Finding, + Notes, + Product, + Product_Type, + Test, + Test_Type, +) + +from .base import ApiV3TestCase + +_NUMERICAL = {"Critical": "S0", "High": "S1", "Medium": "S2", "Low": "S3", "Info": "S4"} + + +class _TwoProductWorld(ApiV3TestCase): + + """ + Build two isolated products (A authorized to ``member``, B not) with a known finding multiset, + so leakage assertions can name exact counts and identify B's objects by their distinctive data. + """ + + def setUp(self): + super().setUp() + self.prod_type = Product_Type.objects.create(name="v3-rbac-pt") + self.test_type = Test_Type.objects.create(name="v3-rbac-tt") + + # A non-superuser member authorized on product A only (authorized_users targets Dojo_User). + self.member = Dojo_User.objects.create_user(username="v3_expand_member", password="x") # noqa: S106 + # A distinctive reporter for product B's findings -- if this user ever appears in an + # expanded/denormalized ref seen by the member, a user-enumeration leak has occurred (the + # v2 sub-vector 4a case). + self.reporter_b = Dojo_User.objects.create_user(username="v3_secret_reporter_b", password="x") # noqa: S106 + + self.product_a, self.test_a = self._make_product("v3-rbac-product-A") + self.product_b, self.test_b = self._make_product("v3-rbac-product-B") + self.product_a.authorized_users.add(self.member) + + # Product A: 2 Critical + 1 High, reported by admin. Product B: 3 Low + 1 Medium, reported + # by the secret reporter. Distinct multisets make count-isolation assertions unambiguous. + self.findings_a = self._make_findings(self.test_a, ["Critical", "Critical", "High"], self.admin) + self.findings_b = self._make_findings(self.test_b, ["Low", "Low", "Low", "Medium"], self.reporter_b) + + def _make_product(self, name: str) -> tuple[Product, Test]: + product = Product.objects.create(name=name, description="x", prod_type=self.prod_type, sla_configuration_id=1) + engagement = Engagement.objects.create( + product=product, name=f"{name}-eng", + target_start=datetime.date(2026, 1, 1), target_end=datetime.date(2026, 1, 2), + ) + test = Test.objects.create( + engagement=engagement, test_type=self.test_type, + target_start=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), + target_end=datetime.datetime(2026, 1, 2, tzinfo=datetime.UTC), lead=self.admin, + ) + return product, test + + def _make_findings(self, test: Test, severities: list[str], reporter: Dojo_User) -> list[Finding]: + findings = Finding.objects.bulk_create([ + Finding( + title=f"{test.engagement.product.name} f{i} {sev}", + severity=sev, numerical_severity=_NUMERICAL[sev], description="x", + test=test, reporter=reporter, active=True, verified=False, + ) + for i, sev in enumerate(severities) + ]) + return list(findings) + + def member_client(self): + return self.token_client(user=self.member) + + +class TestApiV3ExpandRbacPositive(_TwoProductWorld): + + """A user authorized on the finding's product CAN expand its relations (the positive case).""" + + def test_authorized_member_can_expand_test_engagement(self): + finding = self.findings_a[0] + detail = self.get_json( + f"findings/{finding.id}", client=self.member_client(), data={"expand": "test.engagement"}, + ) + # test ref swapped for the test slim, engagement inlined inside it, all belonging to product A. + self.assertIn("engagement", detail["test"]) + self.assertEqual(self.test_a.engagement.id, detail["test"]["engagement"]["id"]) + self.assertEqual(self.product_a.id, detail["test"]["engagement"]["product"]["id"]) + + def test_authorized_member_list_expands_only_own_product(self): + body = self.get_json( + "findings", client=self.member_client(), + data={"expand": "test.engagement,product,product_type", "limit": 250}, + ) + self.assertEqual(len(self.findings_a), body["count"]) + for row in body["results"]: + # Denormalized parent refs AND expanded objects must all be product A. + self.assertEqual(self.product_a.id, row["product"]["id"]) + self.assertEqual(self.product_a.id, row["test"]["engagement"]["product"]["id"]) + + def test_authorized_member_include_counts_reflects_own_product(self): + body = self.get_json("findings", client=self.member_client(), data={"include": "counts"}) + counts = body["meta"]["counts"] + self.assertEqual(len(self.findings_a), counts["total"]) + self.assertEqual(2, counts["severity"]["Critical"]) + self.assertEqual(1, counts["severity"]["High"]) + + +class TestApiV3ExpandRbacCrossProduct(_TwoProductWorld): + + """Product B's data must never leak into the member's list / detail / expand / filter.""" + + def test_list_never_includes_other_product_rows(self): + body = self.get_json("findings", client=self.member_client(), data={"limit": 250}) + seen_products = {row["product"]["id"] for row in body["results"]} + self.assertEqual({self.product_a.id}, seen_products) + self.assertNotIn(self.product_b.id, seen_products) + + def test_detail_of_other_product_finding_is_404_no_expansion(self): + # Even asking to expand cannot coerce disclosure of an unauthorized finding (§4.10 404). + self.get_json( + f"findings/{self.findings_b[0].id}", client=self.member_client(), + data={"expand": "test.engagement"}, expected=404, + ) + + def test_filter_by_other_product_returns_empty(self): + # A filter naming product B must still be intersected with the authorized queryset -> empty. + body = self.get_json("findings", client=self.member_client(), data={"product": self.product_b.id}) + self.assertEqual(0, body["count"]) + self.assertEqual([], body["results"]) + + def test_expand_reporter_never_enumerates_other_product_users(self): + # The v2 sub-vector 4a analogue: expanding reporter must not surface a user attached only to + # findings the caller cannot see. Product B's secret reporter must never appear. + body = self.get_json( + "findings", client=self.member_client(), data={"expand": "reporter", "limit": 250}, + ) + # expand=reporter swaps the ref for the full UserSlim (keyed by `username`, §4.5). + reporter_names = {row["reporter"]["username"] for row in body["results"] if row["reporter"]} + self.assertNotIn(self.reporter_b.username, reporter_names) + self.assertEqual({self.admin.username}, reporter_names) + + def test_include_counts_never_counts_other_product(self): + body = self.get_json("findings", client=self.member_client(), data={"include": "counts"}) + counts = body["meta"]["counts"] + # Product B's 3 Low + 1 Medium must not inflate the member's aggregate. + self.assertEqual(0, counts["severity"]["Low"]) + self.assertEqual(0, counts["severity"]["Medium"]) + self.assertEqual(len(self.findings_a), counts["total"]) + + def test_superuser_counts_exceed_member_counts(self): + # Sanity contrast: the admin (who can see both products) counts strictly more than the member, + # proving the member's lower number is authorization, not an empty database. + admin_counts = self.get_json("findings", data={"include": "counts"})["meta"]["counts"] + member_counts = self.get_json( + "findings", client=self.member_client(), data={"include": "counts"}, + )["meta"]["counts"] + self.assertGreater(admin_counts["total"], member_counts["total"]) + + +class TestApiV3SubResourceRbacInheritance(_TwoProductWorld): + + """ + Sub-resource rows (locations, notes) are drawn from the parent's own managers; an unauthorized + parent is a 404 so no row from another product can ever be read (parent-inherited authorization). + """ + + def setUp(self): + super().setUp() + self.finding_a = self.findings_a[0] + self.finding_b = self.findings_b[0] + # A location edge + a note on a finding in EACH product. + for finding, marker in ((self.finding_a, "a"), (self.finding_b, "b")): + location = Location.objects.create(location_type="url", location_value=f"https://example.com/{marker}") + LocationFindingReference.objects.create(location=location, finding=finding, status="Active") + note = Notes.objects.create(entry=f"note-{marker}", author=self.admin) + finding.notes.add(note) + + def test_locations_subresource_authorized_parent_ok(self): + body = self.get_json(f"findings/{self.finding_a.id}/locations", client=self.member_client()) + self.assertEqual(1, body["count"]) + self.assertEqual("https://example.com/a", body["results"][0]["location"]["name"]) + + def test_locations_subresource_unauthorized_parent_is_404(self): + self.get_json(f"findings/{self.finding_b.id}/locations", client=self.member_client(), expected=404) + + def test_notes_subresource_authorized_parent_ok(self): + body = self.get_json(f"findings/{self.finding_a.id}/notes", client=self.member_client()) + self.assertIn("note-a", [n["entry"] for n in body["results"]]) + + def test_notes_subresource_unauthorized_parent_is_404(self): + self.get_json(f"findings/{self.finding_b.id}/notes", client=self.member_client(), expected=404) + + def test_note_create_on_unauthorized_parent_is_404(self): + # Write attempts on an unseen parent are 404 too (never leak existence, never mutate). + response = self.member_client().post( + self.v3_url(f"findings/{self.finding_b.id}/notes"), {"entry": "x"}, format="json", + ) + self.assertEqual(404, response.status_code, response.content[:300]) + + def test_expand_locations_on_unauthorized_finding_is_404(self): + # expand=locations edge rows must not disclose another product's location edges either. + self.get_json( + f"findings/{self.finding_b.id}", client=self.member_client(), + data={"expand": "locations"}, expected=404, + ) From e3e002caab0d3a1ae09dc373358bba864e51bd3a Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 19:39:54 +0200 Subject: [PATCH 09/37] docs(api-v3): finding write-path divergence analysis + fix proposal 19 catalogued divergences (D1-D19) between the v2 API serializer, the classic UI views, and the v3 finding service - every row verified in code with file:line anchors; 3 rows of the original extraction table materially corrected, 2 new divergences surfaced. Key outcomes: - D17: v3 delete skips delete-time JIRA sync (Finding.delete() sentinel default) - confirmed v3 regression, scheduled for the alpha PR - D8 (auto-mitigation on deactivation) and D7 (last_reviewed stamping) proposed canonical behaviors for the convergence track - consolidated v2-consumer impact assessment: 1 potentially breaking, 4 behavioral, 12 invisible - sequencing: alpha fixes only clear v3 bugs; CONV1 refactor-then- converge with release notes; CONV2 with behavior-pinning tests --- API_V3_DIVERGENCE_ANALYSIS.md | 499 ++++++++++++++++++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 API_V3_DIVERGENCE_ANALYSIS.md diff --git a/API_V3_DIVERGENCE_ANALYSIS.md b/API_V3_DIVERGENCE_ANALYSIS.md new file mode 100644 index 00000000000..ada4c3c804e --- /dev/null +++ b/API_V3_DIVERGENCE_ANALYSIS.md @@ -0,0 +1,499 @@ +# Finding write path — UI / v2 API / v3 API divergence analysis and fix proposal + +Status: analysis for review. No code, tests, or plan are changed by this document. + +Scope: the finding **write** path (create / update / delete). The v3 API's write path was extracted +into `dojo/finding/services.py` by reconciling two pre-existing implementations of the same behaviour: + +- the **v2 API serializer** — `FindingSerializer` / `FindingCreateSerializer` in + `dojo/finding/api/serializer.py` (`.create` / `.update` / `.validate`), plus the viewset overrides + in `dojo/finding/api/views.py`; and +- the **classic UI** — `EditFinding` in `dojo/finding/ui/views.py` (the `process_*` form flow), with + field-level validation in `FindingForm` (`dojo/finding/ui/forms.py`). + +The extraction chose the serializer as the reference for v3 and deferred UI-only behaviours. That was +a reasonable way to ship an additive alpha, but it means **the same user action can behave differently +depending on whether it arrives via the UI or the API** — and, in one case, differently between the v2 +and v3 APIs. Each such gap is a latent bug class. This document catalogues every divergence found in +the code (verified line-by-line, not taken on trust), proposes a canonical behaviour for each, and +sequences the fixes across the alpha PR and the post-alpha convergence track. + +This document supersedes the working divergence table that seeded it. It is self-contained; nothing +below depends on any other note. + +--- + +## 1. Summary + +Business logic for editing a finding currently lives in three places (UI view, v2 serializer, and — +now — the v3 service). Because they were each patched independently over time, they have drifted. The +observable symptoms fall into three buckets: + +1. **Side-effects the UI performs and the API does not** — when a finding is deactivated, closed, or + flipped out of false-positive, the UI runs mitigation stamping, endpoint/location mitigation, + retroactive false-positive history, and `last_reviewed` stamping. The API write path runs none of + these. So a finding closed through the UI and the "same" finding closed through the API end up in + materially different states (mitigation flags, endpoint statuses, review dates, and — for FP + history — other findings entirely). +2. **A v2-vs-v3 regression** — deleting a JIRA-linked finding through v3 does not close or reassign the + JIRA issue, whereas v2 (and the UI) do. +3. **Shape/consistency differences** — CWE relation resync on a scalar `cwe` update (v3 does it, v2 + does not), and a create-time JIRA "push all" asymmetry present in both APIs. + +**Fix strategy.** One service layer (`dojo/finding/services.py`) becomes the single home for finding +write orchestration and its side-effects. The v2 serializer and the UI view then migrate to call it +(the convergence track), so there is exactly one place a workflow bug can live. This document decides, +per divergence, *which* behaviour the single service should implement — defaulting to the behaviour +that is correct for a security workflow rather than merely the one the serializer happened to have — +and flags which of those decisions change what a v2 API consumer sees. + +**How to read the ratings.** Severity reflects the real-world consequence of the two channels +disagreeing, not the size of the diff. Most rows are low: the two implementations already agree, and +convergence is a pure refactor. The high/medium rows are where the API silently skips a workflow +control the UI enforces. + +--- + +## 2. Divergence catalogue + +Each entry states the behaviour today in all three implementations (with `file:line` anchors), the +concrete impact and its severity, the proposed canonical behaviour with rationale, and the fix plan +across the three consumers. "CONV1" = v2 serializer delegates to the service; "CONV2" = UI view +delegates to the service; "CONV3" = delete the now-dead duplicate bodies. + +Entries D1–D17 correspond to the seventeen reconciled rows. D18 (mandatory closing notes) and D19 +(note-creation side-effects) are divergences that were not in the original reconciliation table and +are surfaced here. + +--- + +### D1 — Status invariants (active / verified / duplicate / false_p / risk_accepted) + +- **v2 serializer:** enforced in `validate()` — update path `dojo/finding/api/serializer.py:538-554` + (with PATCH defaults pulled from the instance at `:523-536`); create path `:714-736`. +- **UI:** enforced field-level in `FindingForm.clean()` `dojo/finding/ui/forms.py:632-640`. Note the + "simple risk acceptance disabled" case is *not* raised in the UI — the form instead removes the + `risk_accepted` field entirely when simple risk acceptance is off and the finding is not already + accepted (`dojo/finding/ui/forms.py:590-595`), so the invalid state is unreachable rather than + rejected. +- **v3 service:** `_validate_status_invariants()` `dojo/finding/services.py:83-97`, called from both + `create_finding` (`:148-156`, defaults treat "absent" as falsy) and `update_finding` + (`:215-223`, PATCH defaults from the instance). +- **Impact:** low. All three enforce the same four core invariants. The only observable difference is + cosmetic: the API returns a 400 for a disabled-simple-risk-acceptance attempt, whereas the UI never + offers the control. Both outcomes are correct for their medium. +- **Proposed canonical:** the service's ported invariants. Keep the API's explicit 400 rejection — an + API must reject invalid input loudly rather than silently drop a field. +- **Fix plan:** service already correct. CONV1: serializer delegates — invisible to v2 (identical + logic). CONV2: the UI keeps its field-removal UX for the simple-risk-acceptance case (a template + concern) and delegates the four invariants; no UX change. No release note. + +### D2 — Risk-acceptance processing + +- **v2 serializer:** `process_risk_acceptance()` runs inside `validate()` (`:558`, defined at + `:425-435`): `simple_risk_accept` / `risk_unaccept`, gated on `enable_simple_risk_acceptance`, before + the field updates in `update()`. +- **UI:** `simple_risk_accept(..., perform_save=False)` / `risk_unaccept(..., perform_save=False)` + followed by an explicit `finding.save()` (`dojo/finding/ui/views.py:919-923`, saved at `:1045-1047`). +- **v3 service:** `_process_risk_acceptance()` (`dojo/finding/services.py:100-116`) runs before the + field updates (`:225`). +- **Impact:** low. Behaviours converge. The UI's `perform_save=False` variant is an ordering + optimisation tied to the single form save; the trigger conditions differ slightly in wording but + resolve to the same transitions. +- **Proposed canonical:** the service's port of the serializer path. It is the field-level authority + and does not depend on a form's save lifecycle. +- **Fix plan:** service correct. CONV1 invisible. CONV2: the UI drops its `perform_save=False` dance + and delegates; no behaviour change. No release note. + +### D3 — JIRA push on update + +- **v2 serializer:** `if push_to_jira or is_keep_in_sync(instance): push(force_sync=True)` and raise on + failure (`dojo/finding/api/serializer.py:499-503`). The viewset ORs `push_to_jira` with the JIRA + project's `push_all_issues` first (`dojo/finding/api/views.py:169-176`). +- **UI:** `push_to_jira = push_all_issues or checkbox or is_keep_in_sync` (`dojo/finding/ui/views.py:970`), + then `finding.save(push_to_jira=...)` (`:1045-1047`); failures surface as page messages, not + exceptions. +- **v3 service:** synchronous `push(force_sync=True)`, raising `ValidationError` on failure + (`dojo/finding/services.py:260-263`); the route ORs `push_all_issues` + (`dojo/finding/api_v3/routes.py:231-234`). +- **Impact:** low. v2 and v3 already behave identically; the UI's message-based error handling is + appropriate for a browser and its exception-free save is appropriate for a template render. +- **Proposed canonical:** the service's synchronous push that raises on failure. An API caller needs a + real error at request time, not a swallowed background failure. +- **Fix plan:** service correct. CONV1 invisible. CONV2: when the UI delegates, its wrapper must catch + the raised error and translate it into a page message (so the template still renders) — a small + adapter, not a behaviour change. No release note. + +### D4 — JIRA push on create + +- **v2 serializer:** `if push_to_jira: push(new_finding)` with **no** `force_sync` + (`dojo/finding/api/serializer.py:678-679`). The create path does **not** OR `push_all_issues`: the + viewset overrides `perform_update` but not `perform_create` (`dojo/finding/api/views.py:169`), so a + product configured to "push all issues" does not push findings created via the API until they are + next updated. +- **UI:** create is a separate view (add-finding), out of the reconciled edit flow. +- **v3 service:** mirrors the serializer — `if push_to_jira: push(new_finding)` + (`dojo/finding/services.py:187-188`); the create route does not OR `push_all_issues` + (`dojo/finding/api_v3/routes.py:198-217`). +- **Impact:** low-to-medium. The create-vs-update asymmetry is present identically in v2 and v3, so it + is not a channel divergence — but it is a latent bug: "push all issues" quietly fails to cover + API-created findings. +- **Proposed canonical:** honour `push_all_issues` on create as well as update, so "push all" means + what it says regardless of channel. This is a behaviour change for v2, so treat it as a deliberate, + release-noted convergence step rather than a silent fix. +- **Fix plan:** low priority. Optionally OR `push_all_issues` in `create_finding`'s callers (both the + v3 route and, at CONV1, the v2 create path). Behavioural change for v2 — release note. Not required + for alpha. + +### D5 — JIRA link / unlink / change issue key + +- **v2 serializer:** not supported. +- **UI:** `process_jira_form()` links, unlinks, or re-keys the JIRA issue + (`dojo/finding/ui/views.py:977-995`). +- **v3 service:** not implemented (deferred). +- **Impact:** low. This is a UI-only capability the API never had; its absence is a feature gap, not a + behavioural disagreement over a shared field. +- **Proposed canonical:** keep it out of the finding write contract. Linking/unlinking a JIRA issue is + a distinct action and belongs on a dedicated JIRA sub-resource, not folded into a field write where + it would be an API-only side-channel. +- **Fix plan:** none in the service now. CONV2: the UI keeps this logic (or it moves to a JIRA + sub-resource service later); it does not block the UI from delegating the rest of the edit flow. No + release note. + +### D6 — Finding-group handling and group push + +- **v2 serializer:** not supported. +- **UI:** `update_finding_group()` on save (`dojo/finding/ui/views.py:915-917`) and a post-save group + push (`:1050-1051`). +- **v3 service:** not implemented (deferred). +- **Impact:** low. Group membership is a separate write concern; the API never modelled it here. +- **Proposed canonical:** keep deferred; finding-group writes belong to their own endpoint. +- **Fix plan:** none now. CONV2: the UI retains group handling outside the shared service, or it moves + to a group sub-resource later. No release note. + +### D7 — `last_reviewed` / `last_reviewed_by` stamping + +- **v2 serializer:** does not stamp. +- **UI:** stamps `last_reviewed = now` and `last_reviewed_by = request.user` on **every** edit + (`dojo/finding/ui/views.py:911-912`). +- **v3 service:** does not stamp. +- **Impact:** **medium.** `last_reviewed` feeds "stale finding" / review-age reporting. A finding + edited through the UI is marked reviewed; the identical edit through the API is not. Review-age + metrics therefore depend on which channel a team happens to use. +- **Proposed canonical:** **needs a product decision — do not silently adopt either side.** The UI's + "any edit counts as a review" is defensible for a human at a form, but stamping `last_reviewed` on an + automated bulk field write (e.g. a tag sync) is arguably wrong. The right convergence is probably to + stamp on *substantive review actions* (status changes, verification) rather than on every attribute + write — but that is a behaviour change for the UI too and should be decided deliberately. +- **Fix plan:** leave the service as-is for alpha. Raise the "when does an edit count as a review?" + question with the team; whatever is decided, implement it once in the service so both channels agree. + Whichever way it lands is a behavioural change for at least one channel — release note. (See also D19: + note creation stamps `last_reviewed` from the note date, which is a separate, already-agreed rule.) + +### D8 — Auto-mitigation side-effects on deactivation (`process_mitigated_data`) + +- **v2 serializer:** does not run mitigation side-effects; `validate()` only gates *editing* the + `mitigated` / `mitigated_by` fields (`dojo/finding/api/serializer.py:508-521`). +- **UI:** when `active` is unchecked (or `false_p` / `out_of_scope` set) and the finding is not a + duplicate, and the user may edit mitigated data, sets `is_mitigated = True` and mitigates every + endpoint/location status (`dojo/finding/ui/views.py:833-863`, invoked at `:938`). +- **v3 service:** not implemented (deferred). +- **Impact:** **high.** A finding deactivated through the UI becomes `is_mitigated=True` with its + endpoint/location statuses mitigated; the same finding deactivated through the API (v2 or v3) is left + `is_mitigated=False` with live endpoint statuses. The two are then inconsistent for metrics, SLA, and + endpoint reporting, and the discrepancy is invisible to the caller. This is the clearest + "same action, different result" gap. +- **Proposed canonical:** **the UI behaviour is correct** — deactivating a finding should mitigate it + and its endpoints. Note that a shared `close_finding` helper already does exactly this consistently + and is reachable via the v2 `close` action (`dojo/finding/api/views.py:255-284`). The real defect is + that the *generic field-write path* lets a caller set `active=false` while bypassing the close + workflow. Preferred long-term shape: route status-closing through the close service so there is one + mitigation code path. Faithful interim: have `update_finding` apply the same mitigation side-effects + (guarded by `can_edit_mitigated_data`, `dojo/finding/helper.py:204-205`) when `active` flips off. +- **Fix plan:** service gains the side-effect (as a step toward, or a call into, the shared close + logic). This is a behavioural change for v2 when it delegates (CONV1) — a finding closed via the v2 + field-write would begin mitigating endpoints. Release note; strong candidate for a behaviour-pinning + test before the change so the "before" state is documented. Not a clear v3-alpha bug (v3 matches v2 + today), so this belongs in convergence, not alpha. + +### D9 — False-positive history (`process_false_positive_history`) + +- **v2 serializer:** not implemented. +- **UI:** when a finding is reactivated out of false-positive and both `false_positive_history` and + `retroactive_false_positive_history` are enabled, retroactively reactivates all matching findings + (`dojo/finding/ui/views.py:865-885`, invoked at `:939`). +- **v3 service:** not implemented (deferred). +- **Impact:** **medium** (gated on a non-default setting, but high blast radius when on — it edits + *other* findings). With retroactive FP history configured, clearing `false_p` through the UI + cascades to sibling findings; through the API it does not. +- **Proposed canonical:** the UI behaviour. This is a configured, system-wide policy; it should apply + regardless of the channel that triggers the transition. +- **Fix plan:** move the FP-history cascade into the service so both channels honour it. Behavioural + change for v2 (CONV1), visible only when the setting is on — arguably a bug fix, but still worth a + release note because it changes cross-finding state. Behaviour-pinning test first. Not alpha. + +### D10 — Burp request/response persistence + +- **v2 serializer:** exposes `request_response` as a read-only `SerializerMethodField` + (`dojo/finding/api/serializer.py:331`); it cannot be written. +- **UI:** `process_burp_request_response()` writes it (`dojo/finding/ui/views.py:887-900`, invoked at + `:940`). +- **v3 service:** not implemented (deferred). +- **Impact:** low. A UI-only write capability the API never had; a feature gap, not a disagreement. +- **Proposed canonical:** keep deferred; add as an explicit write field/sub-resource later if wanted. +- **Fix plan:** none now. CONV2: UI retains this outside the shared service. No release note. + +### D11 — GitHub issue sync + +- **v2 serializer:** not implemented. +- **UI:** `process_github_form()` (`dojo/finding/ui/views.py:1008-1021`, invoked at `:1035`). +- **v3 service:** not implemented (deferred). +- **Impact:** low. UI-only feature gap. +- **Proposed canonical:** keep deferred. +- **Fix plan:** none now. CONV2: UI retains it outside the shared service. No release note. + +### D12 — `numerical_severity` + +- **v2 serializer:** does not set it. +- **UI:** sets it explicitly on save (`dojo/finding/ui/views.py:910`). +- **v3 service:** does not set it. +- **Model:** `Finding.save()` always computes it (`dojo/finding/models.py:583`). +- **Impact:** none observable. The UI's explicit assignment is redundant with the model. +- **Proposed canonical:** rely on `Finding.save()`. Do not duplicate. +- **Fix plan:** service correct. CONV2: drop the redundant UI line when the view delegates. Invisible. + +### D13 — `finding_added` notification on create + +- **v2 serializer:** dispatches the `finding_added` notification + (`dojo/finding/api/serializer.py:682-690`). +- **UI:** create is a separate view, out of scope for the edit reconciliation. +- **v3 service:** dispatches it identically (`dojo/finding/services.py:190-198`). +- **Impact:** low. v2 and v3 agree. +- **Proposed canonical:** the service's dispatch. +- **Fix plan:** service correct. CONV1 invisible. No release note. + +### D14 — Notification on update + +- **v2 serializer:** none on the update path itself. +- **UI:** the edit flow dispatches no finding notification (notes and tags notify through their own + flows — see D19). +- **v3 service:** none (`update_finding` dispatches nothing). +- **Impact:** low. All agree. +- **Proposed canonical:** none on the field-write update path. +- **Fix plan:** service correct. Invisible. + +### D15 — Vulnerability-id and CWE persistence + +- **v2 serializer:** `save_vulnerability_ids` (also writing the first id into `Finding.cve`) and + `save_cwes`, both guarded on the nested field being present — update + (`dojo/finding/api/serializer.py:463-491`), create (`:644-676`). On the **scalar** `cwe` path, v2 + does **not** touch the `Finding_CWE` rows (only the nested `cwes` list does, `:489-491`). +- **UI:** the same helpers (`dojo/finding/ui/views.py:942-943`). +- **v3 service:** `save_vulnerability_ids` before save (create `:180`, update `:238-239`) with the + `cve` mirror written pre-save (create `:168-169`); `save_cwes` on create always (`:182`) and on + update whenever the scalar `cwe` is in the change set (`:253-254`). +- **Impact:** low-to-medium. v3 exposes a scalar `cwe` (not v2's nested `cwes` list) and **resyncs** + the `Finding_CWE` rows when the scalar changes; v2's scalar path leaves `Finding.cwe` and its + `Finding_CWE` rows able to drift apart. +- **Proposed canonical:** v3's resync — keep `Finding.cwe` and `Finding_CWE` consistent. It is the more + correct of the two. +- **Fix plan:** service correct. CONV1: when v2 delegates, v2's scalar-cwe path gains the resync — a + behavioural change (a consistency fix). Release note or silent-fix at the team's discretion; + behaviour-pinning test first. The contract difference (scalar `cwe` vs nested `cwes`) is an + intentional v3 simplification and stays. + +### D16 — `found_by` handling + +- **v2 serializer:** set when a non-empty list is provided; clear on an explicit empty list; leave + untouched when absent — update (`dojo/finding/api/serializer.py:467-475`), create set-only + (`:668-669`). +- **UI:** via the form. +- **v3 service:** the same set/clear/untouched semantics using an `_UNSET` sentinel to distinguish + "absent" from "empty" (update `dojo/finding/services.py:241-245`; create `:177-178`). +- **Impact:** none observable. Matches the reference exactly. +- **Proposed canonical:** the service's ported semantics. +- **Fix plan:** service correct. CONV1 invisible. + +### D17 — Delete-time dedup / grading / JIRA sync *(table corrected)* + +- **v2 viewset:** `destroy()` reads an optional `push_to_jira` query param via `get_request_boolean` + (which returns `None` when the param is absent, `dojo/api_v2/views.py:103-109`) and calls + `instance.delete(push_to_jira=...)` (`dojo/finding/api/views.py:178-185`). +- **UI:** the delete flow calls `finding.delete(push_to_jira=...)`. +- **v3 service:** `delete_finding()` calls `finding.delete()` with **no** `push_to_jira` + (`dojo/finding/services.py:267-275`). +- **What that actually means.** `Finding.delete()` defaults `push_to_jira` to a sentinel + (`DELETE_JIRA_SYNC_UNSET`, `dojo/finding/models.py:716`). Inside `finding_delete`, the JIRA + delete-sync is gated on `jira_sync_requested = push_to_jira is None or isinstance(push_to_jira, bool)` + (`dojo/finding/helper.py:578`). v2 passes `None` (or a real bool) ⇒ `jira_sync_requested = True`; the + sentinel from v3 is neither ⇒ `jira_sync_requested = False`. Consequently the two JIRA delete-sync + actions — reassigning the linked issue to the surviving duplicate original + (`dojo/finding/helper.py:585-591`) and closing the issue for the deleted finding (`:595-601`) — are + **both skipped on v3 deletes** and **run on v2 deletes**. The dedup-cluster reconfiguration and + product grading run in both (they are not JIRA-gated: `dojo/finding/models.py:719-733`). +- **Correction to the original table.** The seed table framed this as merely "the service omits the + `push_to_jira` query-param; delete-time JIRA closure is additive later." That understates it: because + of the sentinel default, v3 deletes perform **no** JIRA delete-sync at all, which is a behavioural + **regression against v2 and the UI**, not just a missing optional parameter. +- **Impact:** **medium-to-high.** Deleting a JIRA-linked finding through v3 leaves the JIRA issue + untouched — not closed, and (if the finding was the original of a duplicate cluster) not reassigned to + the surviving finding. DefectDojo and JIRA drift, silently. +- **Proposed canonical:** v3 delete should engage JIRA delete-sync exactly as v2 does. The minimal, + faithful fix is for `delete_finding` to pass `push_to_jira=None` by default (matching v2's + no-param behaviour), with an optional explicit override added later. +- **Fix plan:** **this is the one clear bug in v3's current choice — fix it in the alpha PR.** Change + `delete_finding` to call `finding.delete(push_to_jira=None)` (or thread an optional argument through + the route). No change needed for v2 (it already does this) or the UI. Invisible to v2. Add a v3 + test asserting a JIRA-linked finding's issue is closed/reassigned on delete. + +### D18 — Mandatory closing notes on status change *(not in the original table)* + +- **v2 serializer:** the field-write path does not enforce mandatory closing notes. (The dedicated + `close` action, `dojo/finding/api/views.py:255-284`, accepts a note but the generic update does not + require one.) +- **UI:** `validate_status_change()` blocks setting a finding inactive / false-positive / out-of-scope + unless all mandatory note types are present (`dojo/finding/ui/views.py:794-831`). +- **v3 service:** not enforced. +- **Impact:** **medium.** Where an installation configures mandatory note types, the UI refuses to + close a finding without the required justification notes; the API (v2 and v3) closes it without them. + An integration can therefore bypass a governance control the UI enforces. +- **Proposed canonical:** the UI behaviour is the correct governance stance, but the plain field-write + is the wrong place to demand a note (it has no note field). The right home is the close workflow + action, which already carries a note. Recommendation: enforce mandatory-note presence in the shared + close path, and either reject `active=false` on the generic field-write when mandatory notes are + configured, or document that the field-write bypasses the control and steer integrations to the close + action. +- **Fix plan:** not alpha. Decide alongside D8 (both concern closing a finding through the field-write + vs the close action). Behavioural change for v2 if the field-write starts rejecting note-less closes + — potentially **breaking** for an existing integration, so this one needs a deprecation window rather + than a silent flip. + +### D19 — Note-creation side-effects *(in progress for alpha — see §5)* + +Recorded here for completeness; **not proposed** in this document because it is being implemented in v3 +now by a separate work stream (the notes sub-resource). Adding a note through the UI +(`dojo/finding/ui/views.py:609-641`) stamps `last_reviewed` from the note date and +`last_reviewed_by` (`:624-625`), posts a JIRA comment when the finding or its group has an issue +(`:628-631`), and fires mention/tag notifications (`process_tag_notifications`, `:637`). See §5. + +--- + +## 3. v2 impact assessment + +The v3 alpha is additive: nothing here changes v2 today. The changes below are what a v2 API consumer +would observe **after CONV1**, when the v2 serializer delegates to the shared service and the canonical +behaviours above are adopted. Categorised by consumer-visible effect: + +| Divergence | v2-visible change | Category | Recommended communication | +|---|---|---|---| +| D8 auto-mitigation on deactivate | field-write closes now mitigate `is_mitigated` + endpoint/location statuses | **behavioural** | Release note. Behaviour-pinning test first. | +| D9 false-positive history | reactivating out of `false_p` cascades to matching findings (when the setting is on) | **behavioural** | Release note (cross-finding effect), even though it is a bug fix. | +| D15 CWE resync on scalar `cwe` | scalar `cwe` update now resyncs `Finding_CWE` rows | **behavioural** | Release note or silent fix (consistency correction). | +| D4 `push_all_issues` on create | findings created via API now push to JIRA under "push all issues" | **behavioural** | Release note. Optional; lowest priority. | +| D18 mandatory closing notes | field-write may begin rejecting note-less closes | **breaking** (if adopted as a rejection) | Deprecation window; do not silent-flip. | +| D7 `last_reviewed` on edit | *pending product decision* — not yet a committed change | (t.b.d.) | Decide first; whatever lands is behavioural. | +| D1 status invariants | none (service ports v2 logic verbatim) | invisible | — | +| D2 risk acceptance | none | invisible | — | +| D3 JIRA push on update | none (v2 already does this) | invisible | — | +| D12 `numerical_severity` | none (model computes it) | invisible | — | +| D13 `finding_added` notification | none | invisible | — | +| D14 update notification | none | invisible | — | +| D16 `found_by` | none | invisible | — | +| D17 delete JIRA sync | none for v2 (v2 already syncs; the fix is on v3) | invisible | — | +| D5/D6/D10/D11 UI-only features | none (v2 never had them) | invisible | — | + +**Tally of v2-visible changes:** breaking **1** (D18, only if the field-write starts rejecting +note-less closes); behavioural **4** (D8, D9, D15, D4), plus D7 pending a decision; invisible **the +remaining 12** rows (D1, D2, D3, D12, D13, D14, D16, D17, and the four deferred UI-only features D5/D6/ +D10/D11 which never touched v2). + +Guidance: the invisible rows are pure refactors and can ship in CONV1 with the existing `test_apiv2_*` +suite as the guard. The four behavioural rows should be layered *after* the pure refactor as separate, +individually release-noted commits, so a bisect can attribute any behaviour change to a single change. +The one potentially breaking row (D18) should go through a deprecation window. + +--- + +## 4. Sequencing proposal + +Guiding rule (from the convergence track): **behaviour-pinning tests land before each refactor**, never +after. CONV1 leans on the extensive `test_apiv2_*` suite; CONV2 needs new tests because UI-flow +coverage is thinner. + +**Step 0 — Alpha PR (only clear bugs in v3's *current* choice).** +- **D17** — make `delete_finding` engage JIRA delete-sync by default (pass `push_to_jira=None`), so v3 + deletes match v2/UI. Add a v3 test asserting the linked JIRA issue is closed/reassigned on delete. +- Everything else is either already-agreed (no change) or a convergence-track change to v2/UI (not a + v3-alpha concern). D15 (CWE resync) is v3's deliberate, already-implemented choice and stays. + +**Step 1 — CONV1 pure refactor (v2 serializer → service, zero contract change).** +- Pin current v2 behaviour first (extend `test_apiv2_*` to freeze exact output, *including* the + behaviours we intend to change later: no auto-mitigation, no FP cascade, no scalar-cwe resync). +- Refactor `FindingSerializer.update` / `FindingCreateSerializer.create` to call `create_finding` / + `update_finding`, preserving v2 semantics exactly (parameterise the service where v2 and the desired + canonical differ, so this step changes nothing observable). + +**Step 2 — CONV1 behavioural convergence (deliberate, release-noted, one change per commit).** +- D15 CWE resync → D9 FP history → D8 auto-mitigation → (optional) D4 push-all-on-create. Each with its + own behaviour-pinning test flipped from "old" to "new" and its own release note. D8 depends on the + shared close logic being the mitigation home, so land the close-path consolidation first. +- D18 (mandatory notes) and D7 (`last_reviewed`) are gated on decisions, not code — resolve those + before scheduling; D18 additionally needs a deprecation window. + +**Step 3 — CONV2 (UI view → service, view by view).** +- The service must first grow the UI-only side-effects it does not yet cover, as opt-in + parameters/hooks: D5 (JIRA link/unlink), D6 (finding-group), D7 (`last_reviewed`, per the decision), + D8 (already added in Step 2), D9 (already added), D10 (burp), D11 (github). Only then can `EditFinding` + delegate without losing behaviour. +- Write behaviour-pinning tests for each UI flow *before* migrating it (coverage is thin today). Start + with the flows the service already reconciled (edit / risk-acceptance). Zero template/UX change. + +**Step 4 — CONV3 (delete dead duplicates).** Remove the now-unused serializer `update`/`create` +internals and the duplicated UI `process_*` bodies, only after CONV1 and CONV2 have proven the service. + +**Dependency order:** Step 0 (alpha) → Step 1 (pin + delegate v2) → Step 2 (v2 behavioural, close-path +consolidation before D8) → Step 3 (service grows UI side-effects → pin UI → migrate views) → Step 4 +(delete duplicates). + +--- + +## 5. Addendum — note-creation side-effects (in progress for alpha) + +One divergence adjacent to the write path is **already being implemented in v3** by a parallel work +stream (the notes sub-resource) and is therefore recorded, not proposed, here. + +Adding a note to a finding through the UI (`dojo/finding/ui/views.py:609-641`) performs three +side-effects that a naive "create a note row" implementation would miss: + +1. **`last_reviewed` stamping from the note** — sets `finding.last_reviewed = new_note.date` and + `finding.last_reviewed_by` (`:624-625`). This is a distinct, already-agreed rule and should not be + conflated with D7 (edit-time stamping, which is still open). +2. **JIRA comment** — posts the note as a comment on the finding's JIRA issue, or the finding group's + issue (`:628-631`). +3. **Mentions / tag notifications** — `process_tag_notifications` for `@`-mentions and tag subscribers + (`:637`). + +The v3 notes work stream should reproduce all three so that a note added via the API has the same +downstream effect as one added via the UI. It is tracked there; this document makes no separate +proposal for it beyond flagging the three side-effects as the acceptance criteria. + +--- + +## Appendix — verification record + +Every row above was checked against the code rather than taken from the seed table. All 17 reconciled +rows were verified. Material corrections and additions: + +- **D17 corrected** — the delete-time JIRA sync is skipped entirely on v3 (sentinel-default gate, + `dojo/finding/helper.py:578`), a behavioural regression against v2/UI, not a merely-omitted optional + parameter as the seed table implied. Reclassified to a v3-alpha bug. +- **D8 sharpened** — raised to high severity and tied to the existing shared close helper; the seed + table listed it only as "deferred". +- **D1 refined** — the UI's status invariants live in `FindingForm.clean` (`dojo/finding/ui/forms.py:632-640`); + `validate_status_change` is a *separate*, stronger check (mandatory notes, now D18); the + simple-risk-acceptance case is handled by field removal (`:590-595`), not a raised error. +- **D4 refined** — noted the create-vs-update `push_all_issues` asymmetry, present in both v2 and v3. +- **D15 refined** — spelled out the scalar-`cwe` resync consistency difference between v2 and v3. +- **D18 added** — mandatory closing notes, a UI governance control absent from the API, not in the seed + table. +- **D19 added** — note-creation side-effects, in progress via the notes work stream (§5). From 937ea90db6782b730e991f7171ffb1ef7df7206f Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 19:51:28 +0200 Subject: [PATCH 10/37] feat(api-v3): note side-effect parity + D17 delete JIRA-sync fix Note side-effects (architect directive - closes the OS5 alpha gap): - generic notes factory gains an optional on_note_created callback; kernel stays free of JIRA/notification imports (I5) - finding: process_note_added service fires JIRA comment sync (linked issue or finding-group issue), last_reviewed stamping, and @mention notifications - mirrors v2's notes action exactly - engagement/test: mention notifications only (verified v2 parity - their v2 actions fire nothing else) - mentions use the shared v2 process_tag_notifications helper via the crum request bridge; skipped only for non-HTTP service calls (I6) D17 fix (API_V3_DIVERGENCE_ANALYSIS.md - confirmed v3 regression): - delete_finding now passes the v2 tri-state default push_to_jira=None so the JIRA delete close/reassign runs; bare Finding.delete() hits the suppress-sentinel and silently skipped it - regression-pinning test asserts finding_delete receives None Docs: known-gaps section updated (note side-effects now at parity; locations reclassified as platform limitation; count estimation reclassified as design decision); plan gains an explicit post-alpha OS backlog with architect-confirmed TODOs. 276 tests green (+2 CI-excluded). --- .../automation/api/api-v3-alpha-docs.md | 36 ++++-- dojo/api_v3/api.py | 17 ++- dojo/api_v3/subresources.py | 23 +++- dojo/engagement/services.py | 22 +++- dojo/finding/services.py | 51 ++++++++- dojo/test/services.py | 22 +++- unittests/api_v3/test_apiv3_finding_writes.py | 16 +++ unittests/api_v3/test_apiv3_subresources.py | 107 ++++++++++++++++++ 8 files changed, 275 insertions(+), 19 deletions(-) diff --git a/docs/content/automation/api/api-v3-alpha-docs.md b/docs/content/automation/api/api-v3-alpha-docs.md index c3c4d088020..a31b9048840 100644 --- a/docs/content/automation/api/api-v3-alpha-docs.md +++ b/docs/content/automation/api/api-v3-alpha-docs.md @@ -184,24 +184,36 @@ at the repository root. The alpha delivers the contract and the seven read/write surfaces above. These are **known, deliberate** gaps — several exist so the alpha stayed additive and reviewable: -- **Note side-effects are not fired yet.** Creating a note via `POST .../{id}/notes` persists the - note but does **not** yet trigger the side-effects the v2 UI/serializer path does — JIRA comment - sync, `last_reviewed` / `last_reviewed_by` stamping, and `@mention` notifications. These arrive on - the post-alpha convergence track. +- **Note side-effects have v2 parity in the alpha.** Creating a note via `POST .../{id}/notes` fires + the same side-effects as the v2 notes endpoints, per resource: findings get JIRA comment sync (on a + linked issue, else the finding-group issue), `last_reviewed` / `last_reviewed_by` stamping, and + `@mention` notifications; engagements and tests get `@mention` notifications only (their v2 + endpoints have neither JIRA sync nor `last_reviewed` stamping). - **Finding writes mirror the v2 serializer, not the UI view.** The finding create/update service reproduces the v2 API serializer semantics (JIRA push, risk acceptance, vuln-ids/CWE). UI-only behaviors are deferred: auto-mitigation when `active` flips off, false-positive-history reactivation, `last_reviewed` stamping, finding-group push, GitHub sync, and JIRA link/unlink. -- **Locations are URL-only.** Only the `URL` location subtype persists today; `Code` and - `Dependency` subtypes are dropped on import until their models land. Locations are **read-only** in - the alpha (lifecycle is import-driven). - **No bulk or workflow actions yet.** No bulk operations, no workflow actions (`close`/`request_review`/`mark_duplicate`), no aggregation/chart endpoints, no saved views, no CSV export, no delete-impact preview. Cursor pagination and background-import jobs are reserved in - the grammar (they return `400 "not yet available"`) but not implemented. -- **Counts above the cap are estimates.** Below `COUNT_CAP` (default 10,000) `count` is exact. Above - it, `count` is the Postgres planner's row estimate for the filtered query, clamped to ≥ CAP+1 and - flagged `"count_exact": false` in `meta`. Estimates depend on planner statistics and may drift; - when you page-jump near an estimated end you may get an empty `results` with `next: null`. + the grammar (they return `400 "not yet available"`) but not implemented. These are recorded as + explicit post-alpha work items in the plan's backlog. + +## Platform limitations (not v3 gaps) + +- **Locations are URL-only.** Only the `URL` location subtype has a persistable model in the + codebase today; `Code` and `Dependency` location data is dropped on import until their models + land. This is a limitation of the underlying Location subsystem, not of the v3 API — v3 simply + refuses to advertise location types the backend cannot store. Locations are **read-only** in the + alpha (lifecycle is import-driven). + +## Design decisions to be aware of + +- **Counts above the cap are estimates — by design.** Below `COUNT_CAP` (default 10,000) `count` is + exact. Above it, `count` is the Postgres planner's row estimate for the filtered query, clamped to + ≥ CAP+1 and flagged `"count_exact": false` in `meta`. This is the documented count strategy + (bounded cost on unbounded tables), not a deficiency awaiting a fix. Estimates depend on planner + statistics and may drift; when you page-jump near an estimated end you may get an empty `results` + with `next: null`. See `API_V3_PLAN.md` in the repository for the full contract specification and decision log. diff --git a/dojo/api_v3/api.py b/dojo/api_v3/api.py index 1ae0e9f6fe5..c353f2ae574 100644 --- a/dojo/api_v3/api.py +++ b/dojo/api_v3/api.py @@ -105,9 +105,12 @@ def _mount_subresources(api: NinjaAPI) -> None: ) from dojo.authorization.roles_permissions import Permissions # noqa: PLC0415 from dojo.engagement.queries import get_authorized_engagements # noqa: PLC0415 + from dojo.engagement.services import process_note_added as engagement_process_note_added # noqa: PLC0415 from dojo.finding.queries import get_authorized_findings # noqa: PLC0415 + from dojo.finding.services import process_note_added as finding_process_note_added # noqa: PLC0415 from dojo.product.queries import get_authorized_products # noqa: PLC0415 from dojo.test.queries import get_authorized_tests # noqa: PLC0415 + from dojo.test.services import process_note_added as test_process_note_added # noqa: PLC0415 # Parent authorized-view queryset resolvers. finding/product take an explicit user; engagement/ # test read the current user from crum (their signatures take no user kwarg) -- matching how the @@ -127,13 +130,25 @@ def products_qs(request): file_view = Permissions.Product_Tracking_Files_View file_add = Permissions.Product_Tracking_Files_Add + # Note-created side-effect callbacks per resource (v2 parity): the finding service fires JIRA + # comment sync + last_reviewed stamping + @mention notifications; engagement/test fire @mention + # notifications only (their v2 @actions have no JIRA/last_reviewed) -- see §12. The kernel notes + # factory imports none of this machinery; it only invokes the callback wired here (I5/I6). + note_callbacks = { + "findings": finding_process_note_added, + "engagements": engagement_process_note_added, + "tests": test_process_note_added, + } notes_and_files = ( ("findings", "Finding", findings_qs), ("engagements", "Engagement", engagements_qs), ("tests", "Test", tests_qs), ) for resource, label, qs in notes_and_files: - api.add_router("", build_notes_router(resource=resource, parent_label=label, get_parent_queryset=qs)) + api.add_router("", build_notes_router( + resource=resource, parent_label=label, get_parent_queryset=qs, + on_note_created=note_callbacks[resource], + )) api.add_router("", build_files_router( resource=resource, parent_label=label, get_parent_queryset=qs, view_permission=file_view, add_permission=file_add, diff --git a/dojo/api_v3/subresources.py b/dojo/api_v3/subresources.py index 397462467d0..1978b852781 100644 --- a/dojo/api_v3/subresources.py +++ b/dojo/api_v3/subresources.py @@ -28,6 +28,14 @@ machinery, only the shared pagination envelope (§12); therefore no FilterSpecs and no snapshot regeneration. +**Note side-effects stay out of the kernel (I5/I6).** v2's per-resource notes ``@action`` fires +resource-specific side-effects on create (finding: JIRA comment sync + ``last_reviewed`` stamping + +@mention notifications; engagement/test: @mention notifications only). To reach v2 parity without +importing any JIRA/notification machinery here, ``build_notes_router`` takes an optional +``on_note_created(parent, note, *, user)`` callback, invoked *after* the note is persisted and linked +to the parent. The callback is a resource **service** function (``dojo//services.py``) that +owns all such side-effects; the kernel merely calls it. + Inner view functions are registered manually (not via ``@router.get``) so each can be given a unique ``__name__`` per resource -- the factory is called once per parent resource, and identical closure names would otherwise collide into duplicate OpenAPI ``operationId``s. @@ -184,9 +192,16 @@ def build_notes_router( get_parent_queryset: Callable[[HttpRequest], QuerySet], view_permission="view", create_permission="view", + on_note_created: Callable[..., None] | None = None, auth=NOT_SET, ) -> Router: - """``GET`` (paginated) + ``POST`` ``/{resource}/{id}/notes`` (§4.12).""" + """ + ``GET`` (paginated) + ``POST`` ``/{resource}/{id}/notes`` (§4.12). + + ``on_note_created(parent, note, *, user)`` -- optional resource-service callback fired after the + new note is persisted and linked; it owns the resource's v2 note side-effects (JIRA comment sync, + ``last_reviewed`` stamping, @mention notifications). The kernel imports none of that machinery. + """ router = Router(tags=[resource], auth=auth) notes_path = f"/{resource}/{{int:parent_id}}/notes" @@ -209,6 +224,12 @@ def create_note(request: HttpRequest, parent_id: int, payload: NoteCreate): history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) note.history.add(history) parent.notes.add(note) + # Resource-specific side-effects (JIRA comment sync, last_reviewed stamping, @mention + # notifications) live in the resource's service layer (I5/I6); the kernel imports none of + # that machinery -- it only invokes the callback the factory was configured with, matching + # v2's per-resource notes @action (dojo//api/views.py). + if on_note_created is not None: + on_note_created(parent, note, user=request.user) return json_response(_serialize_note(note), status=201) list_notes.__name__ = f"list_{resource}_notes" diff --git a/dojo/engagement/services.py b/dojo/engagement/services.py index 6a5b7570103..5d947c534ca 100644 --- a/dojo/engagement/services.py +++ b/dojo/engagement/services.py @@ -1,6 +1,7 @@ # # engagements import logging +from crum import get_current_request from django.db.models.signals import pre_save from django.dispatch import receiver from django.urls import reverse @@ -9,12 +10,31 @@ from dojo.celery_dispatch import dojo_dispatch_task from dojo.jira import services as jira_services from dojo.models import Engagement -from dojo.notifications.helper import create_notification +from dojo.notifications.helper import create_notification, process_tag_notifications from dojo.utils import calculate_grade logger = logging.getLogger(__name__) +def process_note_added(engagement, note, *, user): + """ + Fire the same side-effects as the v2 engagement notes ``@action`` create branch + (``dojo/engagement/api/views.py``) after a note is persisted and linked: @mention notifications + only -- the engagement @action has **no** JIRA comment sync and **no** ``last_reviewed`` stamping. + Reuses the exact v2 parsing/notification helper (``process_tag_notifications``); the request is read + from crum (see ``dojo/finding/services.py``), and mentions are skipped with no request. ``user`` is + part of the callback contract (I6) but unused here (no side-effect needs it). + """ + request = get_current_request() + if request is not None: + process_tag_notifications( + request=request, + note=note, + parent_url=request.build_absolute_uri(reverse("view_engagement", args=(engagement.id,))), + parent_title=f"Engagement: {engagement.name}", + ) + + def close_engagement(eng): eng.active = False eng.status = "Completed" diff --git a/dojo/finding/services.py b/dojo/finding/services.py index 8ba6530fe8e..bb29d6bc3f5 100644 --- a/dojo/finding/services.py +++ b/dojo/finding/services.py @@ -28,6 +28,7 @@ import logging from typing import TYPE_CHECKING +from crum import get_current_request from django.urls import reverse from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ValidationError @@ -40,11 +41,12 @@ ) from dojo.jira import services as jira_services from dojo.models import SEVERITIES, Dojo_User, Finding, Test_Type -from dojo.notifications.helper import async_create_notification +from dojo.notifications.helper import async_create_notification, process_tag_notifications from dojo.utils import get_object_or_none if TYPE_CHECKING: from dojo.models import Test + from dojo.notes.models import Notes logger = logging.getLogger(__name__) @@ -264,12 +266,55 @@ def update_finding(finding: Finding, *, changes: dict, user, push_to_jira: bool return finding -def delete_finding(finding: Finding, *, user) -> None: +def delete_finding(finding: Finding, *, user, push_to_jira: bool | None = None) -> None: """ Delete ``finding``. Mirrors the v2 ``FindingViewSet.destroy`` calculation/dedup hooks: the model's ``Finding.delete()`` runs ``finding_helper.finding_delete`` (dedup reassignment) and ``perform_product_grading`` (§12). ``user`` is part of the service contract (I6); the model resolves the acting user via crum. + + ``push_to_jira`` is the v2 tri-state: ``None`` (default) lets the JIRA delete-sync run with its + default semantics — exactly what v2's ``destroy`` passes when the query param is absent. Passing + no value at all would hit ``Finding.delete()``'s suppress-sentinel and silently skip the JIRA + close/reassign (divergence D17, a confirmed v3 regression — see API_V3_DIVERGENCE_ANALYSIS.md). """ logger.debug("api_v3 delete_finding id=%s by user=%s", finding.pk, getattr(user, "username", user)) - finding.delete() + finding.delete(push_to_jira=push_to_jira) + + +def process_note_added(finding: Finding, note: Notes, *, user) -> None: + """ + Fire the same side-effects as the v2 finding notes ``@action`` create branch + (``dojo/finding/api/views.py`` -- ``finding.last_reviewed`` stamping, ``process_tag_notifications``, + and ``jira_services.add_comment``) after a note has been persisted and linked to ``finding``: + + 1. ``last_reviewed`` / ``last_reviewed_by`` stamping (finding notes only -- engagement/test don't); + 2. @mention notifications -- the **exact** v2 parsing/notification helper is reused, not reimplemented; + 3. JIRA comment sync on the finding's linked issue, else its finding-group issue -- same conditions as v2. + + I6: keyword-only ``user``, no HTTP object in the signature. The v2 @mention helper + (``process_tag_notifications``) needs the request to build the absolute mention URL, so it is read + from crum -- set for the whole request by ``CurrentRequestUserMiddleware``, the same context bridge + ``delete_finding`` relies on. With no request available (a pure non-HTTP call) mentions are skipped + while the other side-effects still fire (§12). + """ + # (1) last_reviewed / last_reviewed_by stamping -- mirrors the v2 finding notes @action exactly. + finding.last_reviewed = note.date + finding.last_reviewed_by = user + finding.save(update_fields=["last_reviewed", "last_reviewed_by", "updated"]) + + # (2) @mention notifications -- reuse the exact v2 parsing/notification code path. + request = get_current_request() + if request is not None: + process_tag_notifications( + request=request, + note=note, + parent_url=request.build_absolute_uri(reverse("view_finding", args=(finding.id,))), + parent_title=f"Finding: {finding.title}", + ) + + # (3) JIRA comment sync -- same conditions as v2 (linked issue, else finding-group issue). + if finding.has_jira_issue: + jira_services.add_comment(finding, note) + elif finding.has_jira_group_issue: + jira_services.add_comment(finding.finding_group, note) diff --git a/dojo/test/services.py b/dojo/test/services.py index fbd5dbf3b59..062fc27b575 100644 --- a/dojo/test/services.py +++ b/dojo/test/services.py @@ -1,16 +1,36 @@ # # tests import logging +from crum import get_current_request from django.urls import reverse from django.utils.translation import gettext as _ from dojo.celery_dispatch import dojo_dispatch_task -from dojo.notifications.helper import create_notification +from dojo.notifications.helper import create_notification, process_tag_notifications from dojo.utils import calculate_grade logger = logging.getLogger(__name__) +def process_note_added(test, note, *, user): + """ + Fire the same side-effects as the v2 test notes ``@action`` create branch + (``dojo/test/api/views.py``) after a note is persisted and linked: @mention notifications only -- + the test @action has **no** JIRA comment sync and **no** ``last_reviewed`` stamping. Reuses the + exact v2 parsing/notification helper (``process_tag_notifications``); the request is read from crum + (see ``dojo/finding/services.py``), and mentions are skipped with no request. ``user`` is part of + the callback contract (I6) but unused here (no side-effect needs it). + """ + request = get_current_request() + if request is not None: + process_tag_notifications( + request=request, + note=note, + parent_url=request.build_absolute_uri(reverse("view_test", args=(test.id,))), + parent_title=f"Test: {test.title}", + ) + + def copy_test(test, engagement, user): """ Copy a test (and its findings) into the given engagement, recalculate the product diff --git a/unittests/api_v3/test_apiv3_finding_writes.py b/unittests/api_v3/test_apiv3_finding_writes.py index 9c191ffc7f7..017513226dd 100644 --- a/unittests/api_v3/test_apiv3_finding_writes.py +++ b/unittests/api_v3/test_apiv3_finding_writes.py @@ -193,6 +193,22 @@ def test_delete(self): self.assertEqual(204, response.status_code) self.assertFalse(Finding.objects.filter(pk=finding.id).exists()) + def test_delete_runs_jira_sync_with_v2_default(self): + # D17 regression pin (API_V3_DIVERGENCE_ANALYSIS.md): v3 delete must pass the v2 tri-state + # default push_to_jira=None so finding_delete's JIRA close/reassign runs. A bare + # finding.delete() hits the model's suppress-sentinel and silently skips it. + # Use a fresh standalone finding: mocking finding_delete skips its dedup FK reassignment, + # so deleting a fixture finding referenced via duplicate_finding would violate the FK. + finding = Finding.objects.first() + finding.pk = None + finding.title = "d17 regression pin" + finding.save() + with patch("dojo.finding.helper.finding_delete") as mock_finding_delete: + response = self.client.delete(self.v3_url(f"findings/{finding.id}")) + self.assertEqual(204, response.status_code) + mock_finding_delete.assert_called_once() + self.assertIsNone(mock_finding_delete.call_args.kwargs.get("push_to_jira", "MISSING")) + class TestApiV3FindingWriteRbac(ApiV3TestCase): diff --git a/unittests/api_v3/test_apiv3_subresources.py b/unittests/api_v3/test_apiv3_subresources.py index ffc0911b296..291c699cfc5 100644 --- a/unittests/api_v3/test_apiv3_subresources.py +++ b/unittests/api_v3/test_apiv3_subresources.py @@ -308,6 +308,113 @@ def test_notes_list_pagination_envelope(self): self.assertIsNone(body["previous"]) +class TestApiV3NoteSideEffectsFinding(_SubResourceBase): + + """ + v2 parity for finding note-create side-effects (mirrors ``dojo/finding/api/views.py`` notes + @action): ``last_reviewed`` stamping, JIRA comment sync on the linked issue / finding-group issue, + and @mention notifications. JIRA is mocked at the service seam (``dojo.finding.services + .jira_services.add_comment``); the mention path is exercised end-to-end through the real + ``process_tag_notifications`` parser, capturing ``create_notification``. + """ + + _ADD_COMMENT = "dojo.finding.services.jira_services.add_comment" + _CREATE_NOTIFICATION = "dojo.notifications.helper.create_notification" + + def _post_note(self, *, entry="side-effect note", parent=None): + parent = parent or self.finding + response = self.client.post( + self.v3_url(f"findings/{parent.pk}/notes"), {"entry": entry}, format="json", + ) + self.assertEqual(201, response.status_code, response.content[:400]) + return response.json() + + def test_finding_note_stamps_last_reviewed(self): + body = self._post_note() + note = Notes.objects.get(pk=body["id"]) + self.finding.refresh_from_db() + self.assertEqual(note.date, self.finding.last_reviewed) + self.assertEqual(self.admin.pk, self.finding.last_reviewed_by_id) + + def test_finding_note_jira_comment_when_linked(self): + with mock.patch.object(Finding, "has_jira_issue", new_callable=mock.PropertyMock, return_value=True), \ + mock.patch(self._ADD_COMMENT) as add_comment: + self._post_note() + add_comment.assert_called_once() + self.assertEqual(self.finding.pk, add_comment.call_args.args[0].pk) + + def test_finding_note_no_jira_comment_when_not_linked(self): + # The default fixture finding has neither a linked issue nor a finding-group issue. + self.assertFalse(self.finding.has_jira_issue) + self.assertFalse(self.finding.has_jira_group_issue) + with mock.patch(self._ADD_COMMENT) as add_comment: + self._post_note() + add_comment.assert_not_called() + + def test_finding_note_group_jira_comment_when_group_linked(self): + with mock.patch.object(Finding, "has_jira_issue", new_callable=mock.PropertyMock, return_value=False), \ + mock.patch.object(Finding, "has_jira_group_issue", new_callable=mock.PropertyMock, return_value=True), \ + mock.patch(self._ADD_COMMENT) as add_comment: + self._post_note() + add_comment.assert_called_once() + # The finding-group branch comments on the group, not the finding (v2 parity). + expected_pk = getattr(self.finding.finding_group, "pk", None) + self.assertEqual(expected_pk, getattr(add_comment.call_args.args[0], "pk", None)) + + def test_finding_note_mention_dispatches_user_mentioned_notification(self): + Dojo_User.objects.create_user(username="v3_mention_target", password="x") # noqa: S106 + with mock.patch(self._CREATE_NOTIFICATION) as create_notif: + self._post_note(entry="hey @v3_mention_target please review") + mention_calls = [c for c in create_notif.call_args_list if c.kwargs.get("event") == "user_mentioned"] + self.assertEqual(1, len(mention_calls)) + self.assertIn("v3_mention_target", mention_calls[0].kwargs["recipients"]) + + +class TestApiV3NoteSideEffectsEngagementTest(_SubResourceBase): + + """ + v2 parity for engagement/test note-create side-effects: @mention notifications **only** -- their + v2 @actions (``dojo/engagement/api/views.py``, ``dojo/test/api/views.py``) have no JIRA comment + sync and no ``last_reviewed`` stamping (neither model even has that field, so a 201 is itself + evidence the finding-only side-effects did not run). + """ + + _CREATE_NOTIFICATION = "dojo.notifications.helper.create_notification" + _JIRA_ADD_COMMENT = "dojo.jira.services.add_comment" + + def _assert_mention_notified(self, resource, parent, username): + Dojo_User.objects.create_user(username=username, password="x") # noqa: S106 + with mock.patch(self._CREATE_NOTIFICATION) as create_notif: + response = self.client.post( + self.v3_url(f"{resource}/{parent.pk}/notes"), {"entry": f"cc @{username}"}, format="json", + ) + self.assertEqual(201, response.status_code, response.content[:400]) + mention_calls = [c for c in create_notif.call_args_list if c.kwargs.get("event") == "user_mentioned"] + self.assertEqual(1, len(mention_calls)) + self.assertIn(username, mention_calls[0].kwargs["recipients"]) + + def test_engagement_note_mention_dispatches_notification(self): + self._assert_mention_notified("engagements", self.engagement, "v3_eng_mention") + + def test_test_note_mention_dispatches_notification(self): + self._assert_mention_notified("tests", self.test, "v3_test_mention") + + def test_engagement_and_test_notes_fire_no_jira_comment(self): + # Parity guard: unlike the finding @action, the engagement/test @actions never sync a JIRA + # comment. Patched at the source (dojo.jira.services.add_comment) so a mis-wired finding + # callback would also be caught here. + with mock.patch(self._JIRA_ADD_COMMENT) as add_comment: + eng = self.client.post( + self.v3_url(f"engagements/{self.engagement.pk}/notes"), {"entry": "no jira"}, format="json", + ) + tst = self.client.post( + self.v3_url(f"tests/{self.test.pk}/notes"), {"entry": "no jira"}, format="json", + ) + self.assertEqual(201, eng.status_code, eng.content[:400]) + self.assertEqual(201, tst.status_code, tst.content[:400]) + add_comment.assert_not_called() + + class TestApiV3SubresourcesQueryCounts(_SubResourceBase): """MANDATORY: finding notes/tags/files list query counts are independent of row count.""" From d640ab19e501be1415d6bec1f7b17c4fcb09aa5e Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 20:36:37 +0200 Subject: [PATCH 11/37] feat(api-v3): link v3 API docs from the profile menu Adds an 'API v3 Docs (alpha)' entry next to the existing v2 OpenAPI link, pointing at the interactive docs (/api/v3-alpha/docs via the api_v3:openapi-view URL name). Guarded by V3_FEATURE_LOCATIONS - the v3 mount is conditional on that flag (D5), so an unguarded url tag would raise NoReverseMatch on every page when the flag is off. Verified: URL name resolves to /api/v3-alpha/docs; base.html compiles. --- dojo/templates/base.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dojo/templates/base.html b/dojo/templates/base.html index cfa212a7be2..95c7bfd9adc 100644 --- a/dojo/templates/base.html +++ b/dojo/templates/base.html @@ -470,6 +470,12 @@ {% trans "API v2 OpenAPI3 Docs" %} + {% if V3_FEATURE_LOCATIONS %} + {# v3 mounts conditionally on this flag (D5) — an unguarded url tag would break every page when it is off #} + + {% trans "API v3 Docs (alpha)" %} + + {% endif %} {% trans "Documentation" %} From 4643a9b54518836519e7315ac42f2ca88e477284 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 21:19:21 +0200 Subject: [PATCH 12/37] feat(api-v3): D11 - rename wire surface to organizations/assets v3 speaks the product's new domain language (the UI relabel is already default-on): product_type -> organization, product -> asset, across the entire wire surface, all-or-nothing: - paths: /organizations, /assets (+ /assets/{id}/locations|tags) - ref keys + expand: finding.asset, finding.organization, engagement.asset, asset.organization - filter params: asset=, asset__in=, organization= (+ registry names, snapshot regenerated - diff is exactly the rename) - schema classes (AssetSlim, OrganizationSlim, ...), router factories, OpenAPI tags/operationIds, error messages - import form: asset_name, organization_name (mapped internally to the AutoCreateContextManager's product_* context keys) - asset user-role ref renamed to asset_manager (relabel's canonical term); critical_product/key_product kept (DB-column booleans the UI relabel also keeps) - docs: mapping table doubles as rename documentation + explicit rule that API naming does NOT follow the UI relabel flag (per-instance API contracts are codegen poison); examples regenerated from real requests NOT renamed (D11 scope): Django models, DB tables, dojo/product*/ module paths, and services/queries internals - the DTO layer decouples wire names from models. Verified: OpenAPI render contains zero product/product_type wire tokens; regenerated api_v3_examples.md contains zero legacy wire keys; 276 tests green (+2 CI-excluded), same counts as pre-rename. --- api_v3_examples.md | 122 ++++++++--------- .../automation/api/api-v3-alpha-docs.md | 48 +++++-- dojo/api_v3/api.py | 29 ++-- dojo/api_v3/import_routes.py | 37 ++--- dojo/engagement/api_v3/routes.py | 32 ++--- dojo/engagement/api_v3/schemas.py | 31 +++-- dojo/finding/api_v3/routes.py | 6 +- dojo/finding/api_v3/schemas.py | 29 ++-- dojo/location/api_v3/routes.py | 54 ++++---- dojo/location/api_v3/schemas.py | 29 ++-- dojo/product/api_v3/routes.py | 127 ++++++++++-------- dojo/product/api_v3/schemas.py | 55 ++++---- dojo/product_type/api_v3/routes.py | 70 +++++----- dojo/product_type/api_v3/schemas.py | 25 ++-- dojo/test/api_v3/routes.py | 8 +- dojo/test/api_v3/schemas.py | 21 +-- unittests/api_v3/snapshots/filters.json | 68 +++++----- ...apiv3_products.py => test_apiv3_assets.py} | 110 +++++++-------- unittests/api_v3/test_apiv3_engagements.py | 48 +++---- unittests/api_v3/test_apiv3_examples.py | 44 +++--- unittests/api_v3/test_apiv3_expand_rbac.py | 14 +- unittests/api_v3/test_apiv3_findings.py | 8 +- unittests/api_v3/test_apiv3_import.py | 8 +- unittests/api_v3/test_apiv3_locations.py | 32 ++--- unittests/api_v3/test_apiv3_openapi.py | 25 +++- ...t_types.py => test_apiv3_organizations.py} | 62 ++++----- unittests/api_v3/test_apiv3_query_report.py | 14 +- unittests/api_v3/test_apiv3_subresources.py | 26 ++-- unittests/api_v3/test_apiv3_tests.py | 6 +- 29 files changed, 645 insertions(+), 543 deletions(-) rename unittests/api_v3/{test_apiv3_products.py => test_apiv3_assets.py} (57%) rename unittests/api_v3/{test_apiv3_product_types.py => test_apiv3_organizations.py} (73%) diff --git a/api_v3_examples.md b/api_v3_examples.md index a8b4a59f50f..a0e215d11e5 100644 --- a/api_v3_examples.md +++ b/api_v3_examples.md @@ -2,7 +2,7 @@ > **Auto-generated, do not hand-edit.** Every request/response below was captured by `unittests/api_v3/test_apiv3_examples.py` (`DD_API_V3_EXAMPLES=1`, CI-excluded) making **real** in-process requests against the test fixture. Tokens are redacted; long lists are truncated to ~3 rows. Regenerate with the command in that file's docstring. -Captured: 2026-07-19T13:07:38.917110+00:00 +Captured: 2026-07-19T19:08:11.540226+00:00 ## Conventions (see `API_V3_PLAN.md` §4) @@ -18,7 +18,7 @@ Captured: 2026-07-19T13:07:38.917110+00:00 --- ### Finding — GET detail (slim + detail fields) -Retrieve a single finding. Relations render as closed `{id, name}` refs (§4.4); the parent chain (`test`/`engagement`/`product`/`product_type`) is denormalized onto the finding. `locations_count` is an annotation; the full list is a sub-resource (§4.14). +Retrieve a single finding. Relations render as closed `{id, name}` refs (§4.4); the parent chain (`test`/`engagement`/`asset`/`organization`) is denormalized onto the finding. `locations_count` is an annotation; the full list is a sub-resource (§4.14). **Request** @@ -51,11 +51,11 @@ Authorization: Token "id": 1, "name": "1st Quarter Engagement" }, - "product": { + "asset": { "id": 2, "name": "Security How-to" }, - "product_type": { + "organization": { "id": 2, "name": "ebooks" }, @@ -120,11 +120,11 @@ Authorization: Token "engagement": { "id": 1, "name": "1st Quarter Engagement", - "product": { + "asset": { "id": 2, "name": "Security How-to" }, - "product_type": { + "organization": { "id": 2, "name": "ebooks" }, @@ -141,11 +141,11 @@ Authorization: Token "created": null, "updated": null }, - "product": { + "asset": { "id": 2, "name": "Security How-to" }, - "product_type": { + "organization": { "id": 2, "name": "ebooks" }, @@ -165,11 +165,11 @@ Authorization: Token "id": 1, "name": "1st Quarter Engagement" }, - "product": { + "asset": { "id": 2, "name": "Security How-to" }, - "product_type": { + "organization": { "id": 2, "name": "ebooks" }, @@ -268,11 +268,11 @@ Authorization: Token "id": 1, "name": "1st Quarter Engagement" }, - "product": { + "asset": { "id": 2, "name": "Security How-to" }, - "product_type": { + "organization": { "id": 2, "name": "ebooks" }, @@ -282,8 +282,8 @@ Authorization: Token }, "locations_count": 0, "tags": [], - "created": "2026-07-19T13:07:38.625Z", - "updated": "2026-07-19T13:07:38.625Z" + "created": "2026-07-19T19:08:10.759Z", + "updated": "2026-07-19T19:08:10.759Z" }, { "id": 235, @@ -306,11 +306,11 @@ Authorization: Token "id": 1, "name": "1st Quarter Engagement" }, - "product": { + "asset": { "id": 2, "name": "Security How-to" }, - "product_type": { + "organization": { "id": 2, "name": "ebooks" }, @@ -320,8 +320,8 @@ Authorization: Token }, "locations_count": 0, "tags": [], - "created": "2026-07-19T13:07:38.625Z", - "updated": "2026-07-19T13:07:38.625Z" + "created": "2026-07-19T19:08:10.759Z", + "updated": "2026-07-19T19:08:10.759Z" } ] } @@ -370,11 +370,11 @@ Authorization: Token "id": 1, "name": "1st Quarter Engagement" }, - "product": { + "asset": { "id": 2, "name": "Security How-to" }, - "product_type": { + "organization": { "id": 2, "name": "ebooks" }, @@ -408,11 +408,11 @@ Authorization: Token "id": 1, "name": "1st Quarter Engagement" }, - "product": { + "asset": { "id": 2, "name": "Security How-to" }, - "product_type": { + "organization": { "id": 2, "name": "ebooks" }, @@ -459,7 +459,7 @@ Authorization: Token Content-Type: application/json { - "entry": "Reviewed with the product team; scheduled for the next sprint.", + "entry": "Reviewed with the security team; scheduled for the next sprint.", "private": false } ``` @@ -469,15 +469,15 @@ Content-Type: application/json ```json { "id": 2, - "entry": "Reviewed with the product team; scheduled for the next sprint.", + "entry": "Reviewed with the security team; scheduled for the next sprint.", "author": { "id": 1, "name": "admin" }, "private": false, "edited": false, - "created": "2026-07-19T13:07:38.688Z", - "updated": "2026-07-19T13:07:38.688Z" + "created": "2026-07-19T19:08:10.880Z", + "updated": "2026-07-19T19:08:10.880Z" } ``` @@ -505,15 +505,15 @@ Authorization: Token "results": [ { "id": 2, - "entry": "Reviewed with the product team; scheduled for the next sprint.", + "entry": "Reviewed with the security team; scheduled for the next sprint.", "author": { "id": 1, "name": "admin" }, "private": false, "edited": false, - "created": "2026-07-19T13:07:38.688Z", - "updated": "2026-07-19T13:07:38.688Z" + "created": "2026-07-19T19:08:10.880Z", + "updated": "2026-07-19T19:08:10.880Z" } ] } @@ -619,11 +619,11 @@ Content-Type: application/json "id": 1, "name": "1st Quarter Engagement" }, - "product": { + "asset": { "id": 2, "name": "Security How-to" }, - "product_type": { + "organization": { "id": 2, "name": "ebooks" }, @@ -633,8 +633,8 @@ Content-Type: application/json }, "locations_count": 0, "tags": [], - "created": "2026-07-19T13:07:38.711Z", - "updated": "2026-07-19T13:07:38.759Z", + "created": "2026-07-19T19:08:11.022Z", + "updated": "2026-07-19T19:08:11.072Z", "description": "before patch", "mitigation": null, "impact": null, @@ -665,7 +665,7 @@ Content-Type: multipart/form-data multipart/form-data fields: mode=import # auto | import | reimport (default auto) scan_type=ZAP Scan - engagement=4 # or test= / product_name+engagement_name+auto_create_context + engagement=4 # or test= / asset_name+engagement_name+auto_create_context file=@0_zap_sample.xml active=true verified=true @@ -693,14 +693,14 @@ multipart/form-data fields: --- -### Product — GET detail +### Asset — GET detail -A simple entity for contrast with findings: identity, `product_type` ref, and the documented heavier detail fields. +A simple entity for contrast with findings: identity, `organization` ref, and the documented heavier detail fields. **Request** ```http -GET /api/v3-alpha/products/1 +GET /api/v3-alpha/assets/1 Authorization: Token ``` @@ -711,7 +711,7 @@ Authorization: Token "id": 1, "name": "Python How-to", "description": "test product", - "product_type": { + "organization": { "id": 1, "name": "books" }, @@ -724,7 +724,7 @@ Authorization: Token "origin": null, "external_audience": false, "internet_accessible": false, - "product_manager": { + "asset_manager": { "id": 1, "name": "admin" }, @@ -742,14 +742,14 @@ Authorization: Token --- -### Product — GET list +### Asset — GET list Same envelope and grammar as findings; slim rows only on list (§4.5). **Request** ```http -GET /api/v3-alpha/products?limit=2 +GET /api/v3-alpha/assets?limit=2 Authorization: Token ``` @@ -758,14 +758,14 @@ Authorization: Token ```json { "count": 3, - "next": "http://testserver/api/v3-alpha/products?limit=2&offset=2", + "next": "http://testserver/api/v3-alpha/assets?limit=2&offset=2", "previous": null, "results": [ { "id": 1, "name": "Python How-to", "description": "test product", - "product_type": { + "organization": { "id": 1, "name": "books" }, @@ -778,7 +778,7 @@ Authorization: Token "id": 2, "name": "Security How-to", "description": "test product", - "product_type": { + "organization": { "id": 2, "name": "ebooks" }, @@ -794,21 +794,21 @@ Authorization: Token --- -### Product — POST (create) +### Asset — POST (create) -Create a product. Relations are referenced by integer id (§4.11); unknown fields are rejected (400). Response is the created detail shape (`201`). +Create an asset. Relations are referenced by integer id (§4.11); unknown fields are rejected (400). Response is the created detail shape (`201`). **Request** ```http -POST /api/v3-alpha/products +POST /api/v3-alpha/assets Authorization: Token Content-Type: application/json { - "name": "Example v3 Product", + "name": "Example v3 Asset", "description": "Created via the v3 API examples harness", - "prod_type": 1, + "organization": 1, "lifecycle": "production", "tags": [ "pci", @@ -822,9 +822,9 @@ Content-Type: application/json ```json { "id": 4, - "name": "Example v3 Product", + "name": "Example v3 Asset", "description": "Created via the v3 API examples harness", - "product_type": { + "organization": { "id": 1, "name": "books" }, @@ -833,14 +833,14 @@ Content-Type: application/json "example", "pci" ], - "created": "2026-07-19T13:07:38.890Z", - "updated": "2026-07-19T13:07:38.890Z", + "created": "2026-07-19T19:08:11.467Z", + "updated": "2026-07-19T19:08:11.467Z", "business_criticality": null, "platform": null, "origin": null, "external_audience": false, "internet_accessible": false, - "product_manager": null, + "asset_manager": null, "technical_contact": null, "team_manager": null } @@ -849,14 +849,14 @@ Content-Type: application/json --- -### Product — PATCH (partial update) +### Asset — PATCH (partial update) Partial update; only the changed field is sent. **Request** ```http -PATCH /api/v3-alpha/products/4 +PATCH /api/v3-alpha/assets/4 Authorization: Token Content-Type: application/json @@ -870,9 +870,9 @@ Content-Type: application/json ```json { "id": 4, - "name": "Example v3 Product", + "name": "Example v3 Asset", "description": "Updated description via PATCH", - "product_type": { + "organization": { "id": 1, "name": "books" }, @@ -881,14 +881,14 @@ Content-Type: application/json "example", "pci" ], - "created": "2026-07-19T13:07:38.890Z", - "updated": "2026-07-19T13:07:38.912Z", + "created": "2026-07-19T19:08:11.467Z", + "updated": "2026-07-19T19:08:11.530Z", "business_criticality": null, "platform": null, "origin": null, "external_audience": false, "internet_accessible": false, - "product_manager": null, + "asset_manager": null, "technical_contact": null, "team_manager": null } diff --git a/docs/content/automation/api/api-v3-alpha-docs.md b/docs/content/automation/api/api-v3-alpha-docs.md index a31b9048840..c861e33971a 100644 --- a/docs/content/automation/api/api-v3-alpha-docs.md +++ b/docs/content/automation/api/api-v3-alpha-docs.md @@ -19,9 +19,10 @@ aliases: API v3 is a **new, parallel API** that runs next to the existing [API v2](../api-v2-docs/). v2 is **not changed** by v3 and remains fully supported — v3 is entirely additive. -The alpha covers seven objects — `product_type`, `product`, `engagement`, `test`, `finding`, +The alpha covers seven objects — `organization`, `asset`, `engagement`, `test`, `finding`, `location`, `user` — plus a single consolidated `import` endpoint. Everything else stays on v2 until -v3 reaches parity. +v3 reaches parity. (`organization` and `asset` are the v3 wire names for what v2 calls `product_type` +and `product` — see [Naming: organizations and assets](#naming-organizations-and-assets).) What v3 gives you over v2: @@ -127,10 +128,13 @@ never leaked) · `400` validation/expand/filter errors. ## v2 → v3 mapping +This table doubles as the rename documentation (D11): `product_type` → `organization`, +`product` → `asset` on the v3 wire. + | Object | v2 | v3 (alpha) | Notes | |---|---|---|---| -| Product type | `/api/v2/product_types/` | `/api/v3-alpha/product_types` | CRUD (PATCH-only partial update) | -| Product | `/api/v2/products/` | `/api/v3-alpha/products` | CRUD | +| Organization (v2 "product type") | `/api/v2/product_types/` | `/api/v3-alpha/organizations` | **Renamed** (D11); CRUD (PATCH-only partial update) | +| Asset (v2 "product") | `/api/v2/products/` | `/api/v3-alpha/assets` | **Renamed** (D11); CRUD | | Engagement | `/api/v2/engagements/` | `/api/v3-alpha/engagements` | CRUD | | Test | `/api/v2/tests/` | `/api/v3-alpha/tests` | CRUD | | Finding | `/api/v2/findings/` | `/api/v3-alpha/findings` | CRUD + `?expand=`, `?include=counts` | @@ -138,6 +142,32 @@ never leaked) · `400` validation/expand/filter errors. | User | `/api/v2/users/` | `/api/v3-alpha/users` | Read + self; admin-only writes | | Import | `/api/v2/import-scan/` + `/api/v2/reimport-scan/` | `/api/v3-alpha/import` | **Consolidated** — one endpoint, `mode=auto\|import\|reimport` | +### Naming: organizations and assets + +v3 speaks the product's new domain language: what v2 calls a **product type** is an +**`organization`** on the v3 wire, and what v2 calls a **product** is an **`asset`**. This renames +the wire surface consistently and completely (D11, "all-or-nothing"): the paths (`/organizations`, +`/assets`), the ref keys (`finding.asset`, `finding.organization`, `engagement.asset`, +`asset.organization`), the filter params (`asset=`, `organization=`, …), the `?expand=` keys +(`expand=asset`, `expand=organization`), the import form fields (`asset_name`, `organization_name`), +the OpenAPI tags and schema classes, and this documentation. + +**Why now:** a brand-new API should not bake the legacy names into a surface that clients will +codegen against for years. The alpha is the only time this rename is free — after beta it would mean +permanent aliases and deprecations. + +**Unconditional — the API naming does not follow the UI relabel flag.** The web UI's +product-type→organization / product→asset relabel is governed by +`DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL` (default on). The **v3 API ignores that flag**: v3 always +speaks `organizations`/`assets` regardless of the deployment's UI labels. A per-instance API contract +would be poison for code generation, so the wire names are fixed. + +**Not renamed:** the underlying Django models (`Product_Type`, `Product`), the database tables, and +the internal module paths are unchanged — the DTO layer is exactly what decouples the wire names from +the models. (A handful of scalar model-column fields keep their names on the wire — the +`organization` flags `critical_product`/`key_product` and the `asset` user-role ref `asset_manager`, +whose model column is `product_manager`; these are model-field passthroughs, not entity references.) + ### Locations replace endpoints v3 does not expose the legacy `Endpoint` model. A finding relates to **locations** many-to-many, with @@ -145,14 +175,14 @@ the status (`Active` / `Mitigated` / `FalsePositive` / `RiskAccepted` / `OutOfSc edge. Slim findings carry `locations_count`; the full edge list is a sub-resource: - `GET /api/v3-alpha/findings/{id}/locations` → `{ location: {id, name, type}, status, audit_time, auditor }` -- `GET /api/v3-alpha/products/{id}/locations` → `{ location: {id, name, type}, status }` +- `GET /api/v3-alpha/assets/{id}/locations` → `{ location: {id, name, type}, status }` ### Import in one call `POST /api/v3-alpha/import` (multipart) covers all three flows. `mode=auto` resolves an existing -target via product/engagement names; `import` and `reimport` are explicit. **Destructive flags are -never implied by mode** — if you omit `close_old_findings`, the importer default applies and the -response echoes the effective value: +target via `asset_name` + `engagement_name` (+ optional `organization_name`); `import` and `reimport` +are explicit. **Destructive flags are never implied by mode** — if you omit `close_old_findings`, the +importer default applies and the response echoes the effective value: ```jsonc { "mode_resolved": "reimport", "test": { "id": 9, "name": "ZAP Scan" }, @@ -164,7 +194,7 @@ response echoes the effective value: Where v2 accreted per-resource actions and mechanisms, v3 has **one generic sub-resource** for each, attached to every resource whose model stores it (notes/files: finding, engagement, test; tags: -those plus product): +those plus asset): ``` GET / POST /api/v3-alpha//{id}/notes { "entry": "...", "private": false } diff --git a/dojo/api_v3/api.py b/dojo/api_v3/api.py index c353f2ae574..28cb37ee11b 100644 --- a/dojo/api_v3/api.py +++ b/dojo/api_v3/api.py @@ -59,24 +59,24 @@ def build_api() -> NinjaAPI: from dojo.engagement.api_v3.routes import build_engagements_router # noqa: PLC0415 from dojo.finding.api_v3.routes import build_findings_router # noqa: PLC0415 from dojo.location.api_v3.routes import ( # noqa: PLC0415 + build_asset_locations_router, build_finding_locations_router, build_locations_router, - build_product_locations_router, ) - from dojo.product.api_v3.routes import build_products_router # noqa: PLC0415 - from dojo.product_type.api_v3.routes import build_product_types_router # noqa: PLC0415 + from dojo.product.api_v3.routes import build_assets_router # noqa: PLC0415 + from dojo.product_type.api_v3.routes import build_organizations_router # noqa: PLC0415 from dojo.test.api_v3.routes import build_tests_router # noqa: PLC0415 from dojo.user.api_v3.routes import build_users_router # noqa: PLC0415 api.add_router("", build_findings_router()) - api.add_router("", build_product_types_router()) - api.add_router("", build_products_router()) + api.add_router("", build_organizations_router()) + api.add_router("", build_assets_router()) api.add_router("", build_engagements_router()) api.add_router("", build_tests_router()) api.add_router("", build_users_router()) api.add_router("", build_locations_router()) api.add_router("", build_finding_locations_router()) - api.add_router("", build_product_locations_router()) + api.add_router("", build_asset_locations_router()) api.add_router("", build_import_router()) _mount_subresources(api) @@ -89,11 +89,12 @@ def _mount_subresources(api: NinjaAPI) -> None: *models* actually store them (storage support matrix, see .claude/os5-report.md / §12): - notes + files: finding, engagement, test (each has a ``Notes``/``FileUpload`` M2M). - - tags: finding, engagement, test, product (each has a ``TagField`` and a writable v3 - resource). product_type/user have no such fields; location has a ``TagField`` - but is a read-only, superuser-only resource with no v2 tag-mutation endpoint - and already surfaces ``tags[]`` on its read shape -- so no tag sub-resource is - attached to it (§12). + - tags: finding, engagement, test, asset (each has a ``TagField`` and a writable v3 + resource; per D11 the Product resource is exposed as ``/assets``). organization + (product_type)/user have no such fields; location has a ``TagField`` but is a + read-only, superuser-only resource with no v2 tag-mutation endpoint and already + surfaces ``tags[]`` on its read shape -- so no tag sub-resource is attached to + it (§12). Deferred here (not at module top) so the kernel ``subresources.py`` stays resource-agnostic; this mount is the composition root, alongside the router-factory imports above. @@ -112,7 +113,7 @@ def _mount_subresources(api: NinjaAPI) -> None: from dojo.test.queries import get_authorized_tests # noqa: PLC0415 from dojo.test.services import process_note_added as test_process_note_added # noqa: PLC0415 - # Parent authorized-view queryset resolvers. finding/product take an explicit user; engagement/ + # Parent authorized-view queryset resolvers. finding/asset take an explicit user; engagement/ # test read the current user from crum (their signatures take no user kwarg) -- matching how the # v2 viewsets and the OS3 route factories call them. def findings_qs(request): @@ -124,7 +125,7 @@ def engagements_qs(request): def tests_qs(request): return get_authorized_tests(Permissions.Test_View) - def products_qs(request): + def assets_qs(request): return get_authorized_products(Permissions.Product_View, user=request.user) file_view = Permissions.Product_Tracking_Files_View @@ -156,7 +157,7 @@ def products_qs(request): tagged = ( *notes_and_files, - ("products", "Product", products_qs), + ("assets", "Asset", assets_qs), ) for resource, label, qs in tagged: api.add_router("", build_tags_router(resource=resource, parent_label=label, get_parent_queryset=qs)) diff --git a/dojo/api_v3/import_routes.py b/dojo/api_v3/import_routes.py index f2aa496b49c..2905cdd2af4 100644 --- a/dojo/api_v3/import_routes.py +++ b/dojo/api_v3/import_routes.py @@ -7,6 +7,11 @@ to ``dojo/importers/services.py`` (I6). Destructive flags are never implied by mode -- when omitted, importer defaults apply and the response echoes the effective values. +Per D11 the auto-create form fields speak the new domain language on the wire: ``asset_name`` and +``organization_name`` (mapped internally to the ``AutoCreateContextManager``'s ``product_name`` / +``product_type_name`` context keys, and to the ``dojo/importers/services.py`` facade's kwargs of the +same internal names -- those are not part of the v3 wire surface). See §12. + Background processing is reserved (grammar only): ``background=true`` returns 400 in alpha. """ from __future__ import annotations @@ -40,9 +45,9 @@ class ImportForm(Schema): mode: str = "auto" engagement: int | None = None test: int | None = None - product_name: str | None = None + asset_name: str | None = None engagement_name: str | None = None - product_type_name: str | None = None + organization_name: str | None = None test_title: str | None = None auto_create_context: bool = False close_old_findings: bool | None = None @@ -80,20 +85,20 @@ def _resolve_engagement_for_import(request: HttpRequest, payload: ImportForm) -> return engagement if not payload.auto_create_context: raise validation_problem( - {"engagement": ["Need engagement or product_name + engagement_name to perform import"]}, + {"engagement": ["Need engagement or asset_name + engagement_name to perform import"]}, ) _require_permission( allowed=check_auto_create_permission( - request.user, None, payload.product_name, None, payload.engagement_name, None, - payload.product_type_name, - "Need engagement or product_name + engagement_name to perform import", + request.user, None, payload.asset_name, None, payload.engagement_name, None, + payload.organization_name, + "Need engagement or asset_name + engagement_name to perform import", ), ) auto = AutoCreateContextManager() context = { - "product_name": payload.product_name, + "product_name": payload.asset_name, "engagement_name": payload.engagement_name, - "product_type_name": payload.product_type_name, + "product_type_name": payload.organization_name, "auto_create_context": payload.auto_create_context, } auto.process_import_meta_data_from_dict(context) @@ -114,9 +119,9 @@ def _check_auto_permission(request: HttpRequest, payload: ImportForm) -> None: auto = AutoCreateContextManager() context = { "scan_type": payload.scan_type, - "product_name": payload.product_name, + "product_name": payload.asset_name, "engagement_name": payload.engagement_name, - "product_type_name": payload.product_type_name, + "product_type_name": payload.organization_name, "test_title": payload.test_title, "auto_create_context": payload.auto_create_context, } @@ -129,13 +134,13 @@ def _check_auto_permission(request: HttpRequest, payload: ImportForm) -> None: return if not payload.auto_create_context: raise validation_problem( - {"test": ["Need test or product_name + engagement_name + scan_type to perform reimport"]}, + {"test": ["Need test or asset_name + engagement_name + scan_type to perform reimport"]}, ) _require_permission( allowed=check_auto_create_permission( - request.user, context.get("product"), payload.product_name, context.get("engagement"), - payload.engagement_name, None, payload.product_type_name, - "Need test or product_name + engagement_name + scan_type to perform reimport", + request.user, context.get("product"), payload.asset_name, context.get("engagement"), + payload.engagement_name, None, payload.organization_name, + "Need test or asset_name + engagement_name + scan_type to perform reimport", ), ) @@ -195,9 +200,9 @@ def import_endpoint( _check_auto_permission(request, payload) result = auto_import_scan( engagement=get_object_or_none(Engagement, pk=payload.engagement) if payload.engagement else None, - product_name=payload.product_name, + product_name=payload.asset_name, engagement_name=payload.engagement_name, - product_type_name=payload.product_type_name, + product_type_name=payload.organization_name, close_old_findings=payload.close_old_findings, **common, ) diff --git a/dojo/engagement/api_v3/routes.py b/dojo/engagement/api_v3/routes.py index 1576f5c50d2..e0ae11de2f9 100644 --- a/dojo/engagement/api_v3/routes.py +++ b/dojo/engagement/api_v3/routes.py @@ -1,22 +1,24 @@ """ Engagement CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3b). -``build_engagements_router()`` is a router factory (I5), same shape as ``build_products_router``. +``build_engagements_router()`` is a router factory (I5), same shape as ``build_assets_router``. Routes are thin (I6): authorize -> filter -> plan queryset -> serialize -> shape. RBAC flows only through ``get_authorized_engagements`` for reads (I8) and the v2 ``user_has_permission`` semantics -for writes, mirroring the v2 ``UserHasEngagementPermission`` permission class exactly: +for writes, mirroring the v2 ``UserHasEngagementPermission`` permission class exactly. Per D11 the +target ``Product`` is exposed on the wire as ``asset`` (write FK -> model ``product``); the model / +ORM paths / permission enums are not renamed (§12): -- create (POST): ``add`` permission on the target ``product`` referenced in the payload - (404 if the product doesn't exist, 403 if unauthorized -- mirrors +- create (POST): ``add`` permission on the target asset referenced in the payload + (404 if the asset doesn't exist, 403 if unauthorized -- mirrors ``check_post_permission(request, Product, "product", "add")``) -- update (PATCH): object ``edit`` permission, plus ``add`` on a *reassigned* ``product`` +- update (PATCH): object ``edit`` permission, plus ``add`` on a *reassigned* asset (mirrors ``check_update_permission(request, obj, "add", "product")``) - delete (DELETE): object ``delete`` permission (staff-only for non-staff members, legacy model) - read: object ``view`` via the authorized queryset (404 for unknown-or-unauthorized) Deletion mirrors the v2 ``EngagementViewSet.destroy`` exactly: async delete when ``ASYNC_OBJECT_DELETE`` is set, else a plain synchronous ``instance.delete()`` -- note there is **no** -``Endpoint.allow_endpoint_init()`` wrapper here (unlike product/product_type destroy), mirroring v2 +``Endpoint.allow_endpoint_init()`` wrapper here (unlike asset/organization destroy), mirroring v2 (§12). Relations are referenced by integer id (§4.11). """ from __future__ import annotations @@ -65,9 +67,9 @@ filters={ "id__in": filter_field("id", "in", "number"), "name__icontains": filter_field("name", "icontains", "char"), - "product": filter_field("product", "exact", "number"), - "product__in": filter_field("product", "in", "number"), - "product_type": filter_field("product__prod_type", "exact", "number"), + "asset": filter_field("product", "exact", "number"), + "asset__in": filter_field("product", "in", "number"), + "organization": filter_field("product__prod_type", "exact", "number"), "lead": filter_field("lead", "exact", "number"), "status": filter_field("status", "exact", "char"), "engagement_type": filter_field("engagement_type", "exact", "char"), @@ -198,12 +200,12 @@ def get_engagement(request: HttpRequest, engagement_id: int): def create_engagement(request: HttpRequest, payload: EngagementWrite): data = payload.dict() tags = data.pop("tags") - product_id = data.pop("product") + product_id = data.pop("asset") # Mirror check_post_permission(request, Product, "product", "add"): 404 if the target - # product doesn't exist, 403 if the user can't add engagements to it. + # asset doesn't exist, 403 if the user can't add engagements to it. product = get_object_or_none(Product, pk=product_id) if product is None: - msg = f"Product {product_id} not found" + msg = f"Asset {product_id} not found" raise not_found_problem(msg) if not user_has_permission(request.user, product, Permissions.Engagement_Add): raise PermissionDenied @@ -231,13 +233,13 @@ def update_engagement(request: HttpRequest, engagement_id: int, payload: Engagem data = payload.dict(exclude_unset=True) tags = data.pop("tags", _UNSET) - if "product" in data: - new_product_id = data.pop("product") + if "asset" in data: + new_product_id = data.pop("asset") # Mirror check_update_permission: only re-check `add` when the FK actually changes. if new_product_id is not None and new_product_id != instance.product_id: new_product = get_object_or_none(Product, pk=new_product_id) if new_product is None: - msg = f"Product {new_product_id} not found" + msg = f"Asset {new_product_id} not found" raise not_found_problem(msg) if not user_has_permission(request.user, new_product, Permissions.Engagement_Add): raise PermissionDenied diff --git a/dojo/engagement/api_v3/schemas.py b/dojo/engagement/api_v3/schemas.py index 5d1c92c3b21..d70bae09590 100644 --- a/dojo/engagement/api_v3/schemas.py +++ b/dojo/engagement/api_v3/schemas.py @@ -5,11 +5,14 @@ (where OS1 first defined it so finding ``?expand=`` had a target); the finding module now re-exports this copy so there is exactly one class per model (verified is-identity in the tests, mirroring the OS3a relocation pattern -- see §12). ``EngagementDetail`` adds the documented heavier read fields -(§4.5). ``product``/``product_type``/``lead`` are expandable relations (§4.6). +(§4.5). ``asset``/``organization``/``lead`` are expandable relations (§4.6). Per D11 the +Product/Product_Type models are exposed on the wire as ``asset``/``organization`` (the ref keys and +the ``asset`` write FK -> model ``product``); the models/DB/module paths are not renamed (§12). Write schemas mirror the v2 ``EngagementSerializer`` (a ``ModelSerializer`` excluding -``inherited_tags``): the model requires ``target_start``, ``target_end`` and ``product``; everything -else is optional. Relations are referenced by integer id (§4.11); ``editable=False`` / +``inherited_tags``): the model requires ``target_start``, ``target_end`` and ``product`` (exposed on +the wire as ``asset``); everything else is optional. Relations are referenced by integer id (§4.11); +``editable=False`` / server-managed fields (``active``, ``notes``, ``files``, ``progress``, ``risk_acceptance``, ``done_testing``, ``id``, ``created``, ``updated``) are never writable; unknown fields are rejected (``extra="forbid"``). @@ -24,8 +27,8 @@ from dojo.api_v3.expand import ExpandRel from dojo.api_v3.refs import Ref, to_ref from dojo.models import Engagement -from dojo.product.api_v3.schemas import ProductSlim -from dojo.product_type.api_v3.schemas import ProductTypeSlim +from dojo.product.api_v3.schemas import AssetSlim +from dojo.product_type.api_v3.schemas import OrganizationSlim from dojo.user.api_v3.schemas import UserSlim @@ -37,8 +40,8 @@ class EngagementSlim(Schema): id: int name: str | None - product: Ref - product_type: Ref + asset: Ref + organization: Ref lead: Ref | None status: str | None engagement_type: str | None @@ -50,11 +53,11 @@ class EngagementSlim(Schema): updated: datetime | None @staticmethod - def resolve_product(obj) -> dict | None: + def resolve_asset(obj) -> dict | None: return to_ref(obj.product) @staticmethod - def resolve_product_type(obj) -> dict | None: + def resolve_organization(obj) -> dict | None: return to_ref(obj.product.prod_type) @staticmethod @@ -67,8 +70,8 @@ def resolve_tags(obj) -> list[str]: EngagementSlim.EXPANDABLE = { - "product": ExpandRel(attr="product", path="product", schema=ProductSlim), - "product_type": ExpandRel(attr="product.prod_type", path="product__prod_type", schema=ProductTypeSlim), + "asset": ExpandRel(attr="product", path="product", schema=AssetSlim), + "organization": ExpandRel(attr="product.prod_type", path="product__prod_type", schema=OrganizationSlim), "lead": ExpandRel(attr="lead", path="lead", schema=UserSlim), } @@ -96,11 +99,11 @@ class EngagementDetail(EngagementSlim): class EngagementWrite(Schema): - """Create payload (POST). ``product``/``target_start``/``target_end`` required (mirrors v2).""" + """Create payload (POST). ``asset``/``target_start``/``target_end`` required (mirrors v2).""" model_config = {"extra": "forbid"} - product: int + asset: int target_start: date target_end: date name: str | None = None @@ -131,7 +134,7 @@ class EngagementUpdate(Schema): model_config = {"extra": "forbid"} - product: int | None = None + asset: int | None = None target_start: date | None = None target_end: date | None = None name: str | None = None diff --git a/dojo/finding/api_v3/routes.py b/dojo/finding/api_v3/routes.py index a1c2be0c60c..e3601f50636 100644 --- a/dojo/finding/api_v3/routes.py +++ b/dojo/finding/api_v3/routes.py @@ -74,9 +74,9 @@ "date__lte": filter_field("date", "lte", "date"), "cwe": filter_field("cwe", "exact", "number"), "cwe__in": filter_field("cwe", "in", "number"), - "product": filter_field("test__engagement__product", "exact", "number"), - "product__in": filter_field("test__engagement__product", "in", "number"), - "product_type": filter_field("test__engagement__product__prod_type", "exact", "number"), + "asset": filter_field("test__engagement__product", "exact", "number"), + "asset__in": filter_field("test__engagement__product", "in", "number"), + "organization": filter_field("test__engagement__product__prod_type", "exact", "number"), "engagement": filter_field("test__engagement", "exact", "number"), "test": filter_field("test", "exact", "number"), "reporter": filter_field("reporter", "exact", "number"), diff --git a/dojo/finding/api_v3/schemas.py b/dojo/finding/api_v3/schemas.py index d93a6513601..ae82eda56fe 100644 --- a/dojo/finding/api_v3/schemas.py +++ b/dojo/finding/api_v3/schemas.py @@ -10,11 +10,12 @@ expand planner can keep the query count constant, - ``EXPANDABLE`` -- its expandable relations (§4.6). -The parent slims (``EngagementSlim``/``TestSlim``/``TestTypeSlim``/``EnvironmentSlim``/``ProductSlim`` -/``ProductTypeSlim``/``UserSlim``) live in their own resource modules; this module **re-exports** +The parent slims (``EngagementSlim``/``TestSlim``/``TestTypeSlim``/``EnvironmentSlim``/``AssetSlim`` +/``OrganizationSlim``/``UserSlim``) live in their own resource modules; this module **re-exports** them (OS3a relocated product/product_type/user; OS3b relocated engagement/test) so there is exactly one canonical class per model shared by the resource endpoints and the finding ``?expand=`` targets -(I4). See §12. +(I4). Per D11 the Product/Product_Type models are exposed on the wire as ``asset``/``organization`` +(the ref keys ``finding.asset``/``finding.organization`` and expand keys below). See §12. Write schemas (``FindingWrite`` create, ``FindingUpdate`` PATCH) are the editable subset of the detail fields; required-vs-optional mirrors the v2 ``FindingCreateSerializer`` / ``FindingSerializer``. @@ -37,20 +38,20 @@ # expand targets (below) and the resource endpoints serialize through the same schema (I4, §12). from dojo.engagement.api_v3.schemas import EngagementSlim from dojo.models import Finding -from dojo.product.api_v3.schemas import ProductSlim -from dojo.product_type.api_v3.schemas import ProductTypeSlim +from dojo.product.api_v3.schemas import AssetSlim +from dojo.product_type.api_v3.schemas import OrganizationSlim from dojo.test.api_v3.schemas import EnvironmentSlim, TestSlim, TestTypeSlim from dojo.user.api_v3.schemas import UserSlim __all__ = [ + "AssetSlim", "EngagementSlim", "EnvironmentSlim", "FindingDetail", "FindingSlim", "FindingUpdate", "FindingWrite", - "ProductSlim", - "ProductTypeSlim", + "OrganizationSlim", "TestSlim", "TestTypeSlim", "UserSlim", @@ -78,8 +79,8 @@ class FindingSlim(Schema): cwe: int | None test: Ref engagement: Ref - product: Ref - product_type: Ref + asset: Ref + organization: Ref reporter: Ref | None locations_count: int tags: list[str] @@ -95,11 +96,11 @@ def resolve_engagement(obj) -> dict | None: return to_ref(obj.test.engagement) @staticmethod - def resolve_product(obj) -> dict | None: + def resolve_asset(obj) -> dict | None: return to_ref(obj.test.engagement.product) @staticmethod - def resolve_product_type(obj) -> dict | None: + def resolve_organization(obj) -> dict | None: return to_ref(obj.test.engagement.product.prod_type) @staticmethod @@ -139,11 +140,11 @@ def _finding_location_edges(finding) -> list[dict]: "test": ExpandRel(attr="test", path="test", schema=TestSlim), "reporter": ExpandRel(attr="reporter", path="reporter", schema=UserSlim), "engagement": ExpandRel(attr="test.engagement", path="test__engagement", schema=EngagementSlim), - "product": ExpandRel(attr="test.engagement.product", path="test__engagement__product", schema=ProductSlim), - "product_type": ExpandRel( + "asset": ExpandRel(attr="test.engagement.product", path="test__engagement__product", schema=AssetSlim), + "organization": ExpandRel( attr="test.engagement.product.prod_type", path="test__engagement__product__prod_type", - schema=ProductTypeSlim, + schema=OrganizationSlim, ), "locations": ExpandRel( attr="locations", diff --git a/dojo/location/api_v3/routes.py b/dojo/location/api_v3/routes.py index 0984acc22a9..108e7cb86c3 100644 --- a/dojo/location/api_v3/routes.py +++ b/dojo/location/api_v3/routes.py @@ -1,11 +1,13 @@ """ -Location read routes + finding/product location sub-resources for API v3 (§4.14, OS4). +Location read routes + finding/asset location sub-resources for API v3 (§4.14, OS4). -Three router factories (I5), all **read-only** (location lifecycle is import-driven, §4.14): +Three router factories (I5), all **read-only** (location lifecycle is import-driven, §4.14). Per D11 +the product location sub-resource is exposed on the wire as ``/assets/{id}/locations`` (the model / +``get_authorized_products`` / ORM paths are not renamed -- §12): -- ``build_locations_router()`` -> ``GET /locations`` + ``GET /locations/{id}`` -- ``build_finding_locations_router()`` -> ``GET /findings/{id}/locations`` (edge rows) -- ``build_product_locations_router()`` -> ``GET /products/{id}/locations`` (edge rows) +- ``build_locations_router()`` -> ``GET /locations`` + ``GET /locations/{id}`` +- ``build_finding_locations_router()``-> ``GET /findings/{id}/locations`` (edge rows) +- ``build_asset_locations_router()`` -> ``GET /assets/{id}/locations`` (edge rows) Routes are thin (I6): authorize -> filter/plan -> serialize -> shape. RBAC: @@ -14,13 +16,13 @@ see §12). v3 gates the whole resource behind ``request.user.is_superuser`` (403 otherwise) and draws rows from ``get_authorized_locations`` (I8, forward-compatible: a downstream distribution can scope the queryset without a route change). -- the sub-resources use **parent-inherited authorization**: the parent finding/product is resolved +- the sub-resources use **parent-inherited authorization**: the parent finding/asset is resolved through ``get_authorized_findings``/``get_authorized_products`` (I8); an unknown *or unauthorized* parent is a 404 (never leak existence, §4.10). The edge rows are then drawn from the parent's own reverse manager, so a caller who can see the parent sees its edges. -These live in the location module (not the finding/product route factories) so all location code -stays together and the finding/product factories are untouched (§12). +These live in the location module (not the finding/asset route factories) so all location code +stays together and the finding/asset factories are untouched (§12). """ from __future__ import annotations @@ -37,13 +39,13 @@ from dojo.authorization.roles_permissions import Permissions from dojo.finding.queries import get_authorized_findings from dojo.location.api_v3.schemas import ( + AssetLocationListResponse, FindingLocationListResponse, LocationDetail, LocationListResponse, LocationSlim, - ProductLocationListResponse, + asset_location_edge, finding_location_edge, - product_location_edge, ) from dojo.location.models import Location, LocationFindingReference, LocationProductReference from dojo.location.queries import get_authorized_locations @@ -62,9 +64,9 @@ filters={ "type": filter_field("location_type", "exact", "char"), "name__icontains": filter_field("location_value", "icontains", "char"), - # A location relates to a product via LocationProductReference (mirrors the v2 LocationFilter + # A location relates to an asset via LocationProductReference (mirrors the v2 LocationFilter # `product` filter, field_name="products__product"). Distinct: the join can duplicate rows. - "product": filter_field("products__product", "exact", "number", distinct=True), + "asset": filter_field("products__product", "exact", "number", distinct=True), }, orderings={ "id": "id", @@ -156,31 +158,31 @@ def list_finding_locations(request: HttpRequest, finding_id: int): return router -def build_product_locations_router(*, queryset_hook: Callable | None = None, auth=NOT_SET) -> Router: +def build_asset_locations_router(*, queryset_hook: Callable | None = None, auth=NOT_SET) -> Router: """ - Build the ``GET /products/{id}/locations`` sub-resource router (I5). Edge rows carry the location + Build the ``GET /assets/{id}/locations`` sub-resource router (I5). Edge rows carry the location ref + edge ``status`` only -- ``LocationProductReference`` has no audit columns (§12). ``select_related("location")`` keeps the query count constant. """ - router = Router(tags=["products"], auth=auth) + router = Router(tags=["assets"], auth=auth) @router.get( - "/products/{int:product_id}/locations", - response=ProductLocationListResponse, - url_name="product_locations_list", + "/assets/{int:asset_id}/locations", + response=AssetLocationListResponse, + url_name="asset_locations_list", ) - def list_product_locations(request: HttpRequest, product_id: int): - products = get_authorized_products(Permissions.Product_View, user=request.user) + def list_asset_locations(request: HttpRequest, asset_id: int): + assets = get_authorized_products(Permissions.Product_View, user=request.user) if queryset_hook is not None: - products = queryset_hook(products, request) - product = products.filter(pk=product_id).first() - if product is None: - msg = f"Product {product_id} not found" + assets = queryset_hook(assets, request) + asset = assets.filter(pk=asset_id).first() + if asset is None: + msg = f"Asset {asset_id} not found" raise not_found_problem(msg) - edges = LocationProductReference.objects.filter(product=product).order_by("id") + edges = LocationProductReference.objects.filter(product=asset).order_by("id") page_qs = edges.select_related("location") - envelope = paginate(request, count_qs=edges, page_qs=page_qs, serialize=product_location_edge) + envelope = paginate(request, count_qs=edges, page_qs=page_qs, serialize=asset_location_edge) return json_response(envelope) return router diff --git a/dojo/location/api_v3/schemas.py b/dojo/location/api_v3/schemas.py index 26642c3d761..32a101ff24c 100644 --- a/dojo/location/api_v3/schemas.py +++ b/dojo/location/api_v3/schemas.py @@ -1,5 +1,5 @@ """ -Location response schemas + the finding/product location edge schemas for API v3 (§4.5, §4.14, OS4). +Location response schemas + the finding/asset location edge schemas for API v3 (§4.5, §4.14, OS4). ``LocationSlim`` (list) and ``LocationDetail`` (retrieve). Every schema is a named, importable, subclassable ninja Schema (I4) and declares (as ``ClassVar`` so pydantic does not treat them as @@ -11,10 +11,11 @@ (``protocol/host/port/path/query/fragment``) pulled from the ``url`` reverse one-to-one; for a non-URL location (none exist in alpha) those fields render ``null``. -``FindingLocationEdge`` / ``ProductLocationEdge`` document the edge-row shape of the -``/findings/{id}/locations`` and ``/products/{id}/locations`` sub-resources for OpenAPI (I1/I4); +``FindingLocationEdge`` / ``AssetLocationEdge`` document the edge-row shape of the +``/findings/{id}/locations`` and ``/assets/{id}/locations`` sub-resources for OpenAPI (I1/I4); their runtime serialization is manual dicts (like the list envelopes) so ``LocationRef`` is emitted -with its ``type`` field. +with its ``type`` field. (Per D11 the product location sub-resource is exposed on the wire as +``/assets/{id}/locations``; the model/module paths are not renamed -- §12.) """ from __future__ import annotations @@ -35,15 +36,15 @@ from dojo.api_v3.expand import ExpandRel __all__ = [ + "AssetLocationEdge", + "AssetLocationListResponse", "FindingLocationEdge", "FindingLocationListResponse", "LocationDetail", "LocationListResponse", "LocationSlim", - "ProductLocationEdge", - "ProductLocationListResponse", + "asset_location_edge", "finding_location_edge", - "product_location_edge", ] @@ -142,11 +143,11 @@ class FindingLocationEdge(Schema): auditor: Ref | None -class ProductLocationEdge(Schema): +class AssetLocationEdge(Schema): """ - One row of ``GET /products/{id}/locations`` (§4.14): location ref + edge status. - ``LocationProductReference`` has no ``audit_time``/``auditor`` columns, so the product edge + One row of ``GET /assets/{id}/locations`` (§4.14): location ref + edge status. + ``LocationProductReference`` has no ``audit_time``/``auditor`` columns, so the asset edge carries only ``status`` (§12). """ @@ -176,14 +177,14 @@ class FindingLocationListResponse(Schema): meta: dict | None = None -class ProductLocationListResponse(Schema): +class AssetLocationListResponse(Schema): - """OpenAPI documentation of the ``/products/{id}/locations`` envelope (I1).""" + """OpenAPI documentation of the ``/assets/{id}/locations`` envelope (I1).""" count: int next: str | None previous: str | None - results: list[ProductLocationEdge] + results: list[AssetLocationEdge] meta: dict | None = None @@ -199,7 +200,7 @@ def finding_location_edge(ref) -> dict: } -def product_location_edge(ref) -> dict: +def asset_location_edge(ref) -> dict: """Serialize a ``LocationProductReference`` edge row (§4.14): location ref + status (no audit).""" return { "location": to_location_ref(ref.location), diff --git a/dojo/product/api_v3/routes.py b/dojo/product/api_v3/routes.py index 068c046a748..d67a90661ea 100644 --- a/dojo/product/api_v3/routes.py +++ b/dojo/product/api_v3/routes.py @@ -1,13 +1,22 @@ """ -Product CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3a). - -``build_products_router()`` is a router factory (I5), same signature style as -``build_findings_router``. Routes are thin (I6): authorize -> filter -> plan queryset -> serialize --> shape. RBAC flows only through ``get_authorized_products`` for reads (I8) and the v2 -``user_has_permission`` semantics for writes, mirroring the v2 ``UserHasProductPermission`` class: - -- create (POST): ``add`` permission on the target ``prod_type`` referenced in the payload -- update (PATCH): object ``edit`` permission, plus ``add`` on a *reassigned* ``prod_type`` +Asset CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3a; renamed per D11). + +**D11 wire rename:** the ``Product`` model is exposed on the wire as ``asset`` and its parent +``Product_Type`` FK as ``organization`` -- paths (``/assets``), the OpenAPI tag, the filter registry +name, the schema classes and the write FK field (``organization`` -> model ``prod_type``) all use the +new domain language. The Django model / DB table / ``dojo/product/`` module path are **not** renamed +(see §12); ORM field paths, ``get_authorized_products`` and the ``Product_*`` permission enums keep +their names (they point at the real model). The wire field ``asset_manager`` maps to the model's +``product_manager`` FK (relabel term "Asset Manager"); ``technical_contact``/``team_manager`` are +unchanged (no product token). See §12. + +``build_assets_router()`` is a router factory (I5), same signature style as ``build_findings_router``. +Routes are thin (I6): authorize -> filter -> plan queryset -> serialize -> shape. RBAC flows only +through ``get_authorized_products`` for reads (I8) and the v2 ``user_has_permission`` semantics for +writes, mirroring the v2 ``UserHasProductPermission`` class: + +- create (POST): ``add`` permission on the target organization (``prod_type``) in the payload +- update (PATCH): object ``edit`` permission, plus ``add`` on a *reassigned* organization (mirrors ``check_update_permission(request, obj, "add", "prod_type")``) - delete (DELETE): object ``delete`` permission (staff-only for non-staff members, legacy model) - read: object ``view`` via the authorized queryset (404 for unknown-or-unauthorized) @@ -41,10 +50,10 @@ from dojo.authorization.roles_permissions import Permissions from dojo.models import Dojo_User, Endpoint, Product, Product_Type, SLA_Configuration from dojo.product.api_v3.schemas import ( - ProductDetail, - ProductSlim, - ProductUpdate, - ProductWrite, + AssetDetail, + AssetSlim, + AssetUpdate, + AssetWrite, ) from dojo.product.queries import get_authorized_products from dojo.utils import async_delete, get_object_or_none, get_setting @@ -55,15 +64,15 @@ from django.db.models import QuerySet from django.http import HttpRequest -# --- Product filter vocabulary (§4.9, minimal set) -------------------------------------------- +# --- Asset filter vocabulary (§4.9, minimal set) ---------------------------------------------- -PRODUCT_FILTER_SPEC = register_filter_spec("product", FilterSpec( +ASSET_FILTER_SPEC = register_filter_spec("asset", FilterSpec( model=Product, filters={ "id__in": filter_field("id", "in", "number"), "name__icontains": filter_field("name", "icontains", "char"), - "product_type": filter_field("prod_type", "exact", "number"), - "product_type__in": filter_field("prod_type", "in", "number"), + "organization": filter_field("prod_type", "exact", "number"), + "organization__in": filter_field("prod_type", "in", "number"), "created__gte": filter_field("created", "gte", "datetime"), "created__lte": filter_field("created", "lte", "datetime"), "updated__gte": filter_field("updated", "gte", "datetime"), @@ -78,20 +87,26 @@ search_fields=["name", "description"], )) -_USER_FK_FIELDS = ("product_manager", "technical_contact", "team_manager") +# Wire write-field -> model attribute for the user-role FKs. ``asset_manager`` is the D11 wire name +# for the model's ``product_manager`` FK; the other two are unchanged (no product token). +_USER_FK_FIELDS = { + "asset_manager": "product_manager", + "technical_contact": "technical_contact", + "team_manager": "team_manager", +} # Sentinel distinguishing "tags omitted" from "tags set to null/empty" on PATCH. _UNSET = object() -class ProductListResponse(Schema): +class AssetListResponse(Schema): """OpenAPI documentation of the list envelope (I1); runtime serialization is manual.""" count: int next: str | None previous: str | None - results: list[ProductSlim] + results: list[AssetSlim] meta: dict | None = None @@ -126,10 +141,10 @@ def _destroy(instance: Product) -> None: def _apply_optional_relations_and_scalars(instance: Product, data: dict) -> None: """Apply the user-FK, SLA and scalar fields present in ``data`` (create + update share this).""" - for field in _USER_FK_FIELDS: - if field in data: - pk = data.pop(field) - setattr(instance, field, _resolve_user_fk(field, pk) if pk is not None else None) + for wire_field, model_attr in _USER_FK_FIELDS.items(): + if wire_field in data: + pk = data.pop(wire_field) + setattr(instance, model_attr, _resolve_user_fk(wire_field, pk) if pk is not None else None) if "sla_configuration" in data: pk = data.pop("sla_configuration") if pk is not None: @@ -141,19 +156,19 @@ def _apply_optional_relations_and_scalars(instance: Product, data: dict) -> None setattr(instance, key, value) -def build_products_router( +def build_assets_router( *, - schema: type = ProductSlim, - detail_schema: type = ProductDetail, - filter_spec: FilterSpec = PRODUCT_FILTER_SPEC, + schema: type = AssetSlim, + detail_schema: type = AssetDetail, + filter_spec: FilterSpec = ASSET_FILTER_SPEC, queryset_hook: Callable | None = None, auth=NOT_SET, ) -> Router: - """Build the products router (I5).""" - router = Router(tags=["products"], auth=auth) + """Build the assets router (I5).""" + router = Router(tags=["assets"], auth=auth) - @router.get("/products", response=ProductListResponse, url_name="products_list") - def list_products(request: HttpRequest): + @router.get("/assets", response=AssetListResponse, url_name="assets_list") + def list_assets(request: HttpRequest): filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) @@ -171,28 +186,28 @@ def serialize_row(obj: object) -> dict: envelope.setdefault("meta", {}).update(include_meta) return json_response(envelope) - @router.get("/products/{int:product_id}", response=detail_schema, url_name="products_detail") - def get_product(request: HttpRequest, product_id: int): + @router.get("/assets/{int:asset_id}", response=detail_schema, url_name="assets_detail") + def get_asset(request: HttpRequest, asset_id: int): expand_tree, select_related, prefetch = plan(detail_schema, request.GET.get("expand")) qs = _base_queryset(request, queryset_hook).select_related(*detail_schema.SELECT_RELATED).prefetch_related(*detail_schema.PREFETCH_RELATED) qs = plan_queryset(qs, select_related, prefetch) - obj = qs.filter(pk=product_id).first() + obj = qs.filter(pk=asset_id).first() if obj is None: - msg = f"Product {product_id} not found" + msg = f"Asset {asset_id} not found" raise not_found_problem(msg) fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) - @router.post("/products", response=detail_schema, url_name="products_create") - def create_product(request: HttpRequest, payload: ProductWrite): + @router.post("/assets", response=detail_schema, url_name="assets_create") + def create_asset(request: HttpRequest, payload: AssetWrite): data = payload.dict() tags = data.pop("tags") - prod_type_id = data.pop("prod_type") - # Mirror check_post_permission(request, Product_Type, "prod_type", "add"): 404 if the - # target product type doesn't exist, 403 if the user can't add products to it. - prod_type = get_object_or_none(Product_Type, pk=prod_type_id) + organization_id = data.pop("organization") + # Mirror check_post_permission(request, Product_Type, "prod_type", "add"): 404 if the target + # organization doesn't exist, 403 if the user can't add assets to it. + prod_type = get_object_or_none(Product_Type, pk=organization_id) if prod_type is None: - msg = f"Product_Type {prod_type_id} not found" + msg = f"Organization {organization_id} not found" raise not_found_problem(msg) if not user_has_permission(request.user, prod_type, Permissions.Product_Type_Add_Product): raise PermissionDenied @@ -207,24 +222,24 @@ def create_product(request: HttpRequest, payload: ProductWrite): raise _validation_from_django(exc) from exc return json_response(serialize(instance, detail_schema, {}), status=201) - @router.patch("/products/{int:product_id}", response=detail_schema, url_name="products_update") - def update_product(request: HttpRequest, product_id: int, payload: ProductUpdate): - instance = _base_queryset(request, queryset_hook).filter(pk=product_id).first() + @router.patch("/assets/{int:asset_id}", response=detail_schema, url_name="assets_update") + def update_asset(request: HttpRequest, asset_id: int, payload: AssetUpdate): + instance = _base_queryset(request, queryset_hook).filter(pk=asset_id).first() if instance is None: - msg = f"Product {product_id} not found" + msg = f"Asset {asset_id} not found" raise not_found_problem(msg) # 404: unknown or unauthorized-to-view if not user_has_permission(request.user, instance, Permissions.Product_Edit): raise PermissionDenied # 403: visible but not editable data = payload.dict(exclude_unset=True) tags = data.pop("tags", _UNSET) - if "prod_type" in data: - new_pt_id = data.pop("prod_type") + if "organization" in data: + new_org_id = data.pop("organization") # Mirror check_update_permission: only re-check when the FK actually changes. - if new_pt_id is not None and new_pt_id != instance.prod_type_id: - new_pt = get_object_or_none(Product_Type, pk=new_pt_id) + if new_org_id is not None and new_org_id != instance.prod_type_id: + new_pt = get_object_or_none(Product_Type, pk=new_org_id) if new_pt is None: - msg = f"Product_Type {new_pt_id} not found" + msg = f"Organization {new_org_id} not found" raise not_found_problem(msg) if not user_has_permission(request.user, new_pt, Permissions.Product_Type_Add_Product): raise PermissionDenied @@ -239,11 +254,11 @@ def update_product(request: HttpRequest, product_id: int, payload: ProductUpdate raise _validation_from_django(exc) from exc return json_response(serialize(instance, detail_schema, {})) - @router.delete("/products/{int:product_id}", url_name="products_delete") - def delete_product(request: HttpRequest, product_id: int): - instance = _base_queryset(request, queryset_hook).filter(pk=product_id).first() + @router.delete("/assets/{int:asset_id}", url_name="assets_delete") + def delete_asset(request: HttpRequest, asset_id: int): + instance = _base_queryset(request, queryset_hook).filter(pk=asset_id).first() if instance is None: - msg = f"Product {product_id} not found" + msg = f"Asset {asset_id} not found" raise not_found_problem(msg) if not user_has_permission(request.user, instance, Permissions.Product_Delete): raise PermissionDenied diff --git a/dojo/product/api_v3/schemas.py b/dojo/product/api_v3/schemas.py index e344fceca0d..ff49d274924 100644 --- a/dojo/product/api_v3/schemas.py +++ b/dojo/product/api_v3/schemas.py @@ -1,14 +1,23 @@ """ -Product response + write schemas for API v3 (§4.5, §4.11, OS3a). +Asset response + write schemas for API v3 (§4.5, §4.11, OS3a; renamed per D11). -``ProductSlim`` is the canonical parent slim (relocated here from ``dojo/finding/api_v3`` where OS1 -first defined it -- see §12; the finding module now re-exports this copy). ``ProductDetail`` adds the -documented heavier read fields (§4.5). ``product_type`` is an expandable relation (§4.6). +**D11 wire rename:** v3 speaks the new domain language -- the ``Product`` model is exposed on the +wire as ``asset`` and its parent ``Product_Type`` FK as ``organization``. The schema classes are +``Asset*``; the Django model (``Product``) / DB table / module path are deliberately **not** renamed +(the DTO layer is what decouples wire names from models). See §12. + +``AssetSlim`` is the canonical parent slim (relocated here from ``dojo/finding/api_v3`` where OS1 +first defined it -- see §12; the finding module now re-exports this copy). ``AssetDetail`` adds the +documented heavier read fields (§4.5). ``organization`` is an expandable relation (§4.6). Write schemas mirror the v2 ``ProductSerializer`` required/optional split: the model requires -``name``, ``description`` and ``prod_type``; everything else is optional (``sla_configuration`` -defaults to the model default when omitted). Relations are referenced by integer id (§4.11); -server-managed fields are never writable; unknown fields are rejected (``extra="forbid"``). +``name``, ``description`` and ``prod_type`` (exposed on the wire as ``organization``); everything +else is optional (``sla_configuration`` defaults to the model default when omitted). Relations are +referenced by integer id (§4.11); server-managed fields are never writable; unknown fields are +rejected (``extra="forbid"``). The user-role ref ``asset_manager`` is the wire name for the model's +``product_manager`` FK (the UI relabel's canonical term is "Asset Manager"); ``critical_product`` / +``key_product`` (on ``organization``) keep their model-column names because the relabel itself +retains them (``org.critical_product_label``). See §12. """ from __future__ import annotations @@ -20,10 +29,10 @@ from dojo.api_v3.expand import ExpandRel from dojo.api_v3.refs import Ref, to_ref from dojo.models import Product -from dojo.product_type.api_v3.schemas import ProductTypeSlim +from dojo.product_type.api_v3.schemas import OrganizationSlim -class ProductSlim(Schema): +class AssetSlim(Schema): django_model: ClassVar = Product SELECT_RELATED: ClassVar[tuple] = ("prod_type",) PREFETCH_RELATED: ClassVar[tuple] = ("tags",) @@ -32,14 +41,14 @@ class ProductSlim(Schema): id: int name: str description: str | None - product_type: Ref + organization: Ref lifecycle: str | None tags: list[str] created: datetime | None updated: datetime | None @staticmethod - def resolve_product_type(obj) -> dict | None: + def resolve_organization(obj) -> dict | None: return to_ref(obj.prod_type) @staticmethod @@ -47,12 +56,12 @@ def resolve_tags(obj) -> list[str]: return [t.name for t in obj.tags.all()] -ProductSlim.EXPANDABLE = { - "product_type": ExpandRel(attr="prod_type", path="prod_type", schema=ProductTypeSlim), +AssetSlim.EXPANDABLE = { + "organization": ExpandRel(attr="prod_type", path="prod_type", schema=OrganizationSlim), } -class ProductDetail(ProductSlim): +class AssetDetail(AssetSlim): """Slim + the documented heavier read fields (§4.5). Retrieve returns detail; list returns slim.""" @@ -64,12 +73,12 @@ class ProductDetail(ProductSlim): origin: str | None external_audience: bool | None internet_accessible: bool | None - product_manager: Ref | None + asset_manager: Ref | None technical_contact: Ref | None team_manager: Ref | None @staticmethod - def resolve_product_manager(obj) -> dict | None: + def resolve_asset_manager(obj) -> dict | None: return to_ref(obj.product_manager) @staticmethod @@ -81,20 +90,20 @@ def resolve_team_manager(obj) -> dict | None: return to_ref(obj.team_manager) -class ProductWrite(Schema): +class AssetWrite(Schema): - """Create payload (POST). ``name``/``description``/``prod_type`` required (§6 OS3, mirrors v2).""" + """Create payload (POST). ``name``/``description``/``organization`` required (§6 OS3, mirrors v2).""" model_config = {"extra": "forbid"} name: str description: str - prod_type: int + organization: int business_criticality: str | None = None platform: str | None = None lifecycle: str | None = None origin: str | None = None - product_manager: int | None = None + asset_manager: int | None = None technical_contact: int | None = None team_manager: int | None = None sla_configuration: int | None = None @@ -103,7 +112,7 @@ class ProductWrite(Schema): tags: list[str] | None = None -class ProductUpdate(Schema): +class AssetUpdate(Schema): """Partial update payload (PATCH). Every field optional; only provided keys are applied.""" @@ -111,12 +120,12 @@ class ProductUpdate(Schema): name: str | None = None description: str | None = None - prod_type: int | None = None + organization: int | None = None business_criticality: str | None = None platform: str | None = None lifecycle: str | None = None origin: str | None = None - product_manager: int | None = None + asset_manager: int | None = None technical_contact: int | None = None team_manager: int | None = None sla_configuration: int | None = None diff --git a/dojo/product_type/api_v3/routes.py b/dojo/product_type/api_v3/routes.py index 6aee797340e..bf27b041812 100644 --- a/dojo/product_type/api_v3/routes.py +++ b/dojo/product_type/api_v3/routes.py @@ -1,7 +1,13 @@ """ -Product_Type CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3a). +Organization CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3a; renamed per D11). -``build_product_types_router()`` is a router factory (I5), same signature style as +**D11 wire rename:** the ``Product_Type`` model is exposed on the wire as ``organization`` -- paths +(``/organizations``), the OpenAPI tag, the filter registry name and the schema classes all use the +new domain language. The Django model / DB table / ``dojo/product_type/`` module path are **not** +renamed (see §12); ORM field paths, ``get_authorized_product_types`` and the ``Product_Type_*`` +permission enums keep their names (they point at the real model). + +``build_organizations_router()`` is a router factory (I5), same signature style as ``build_findings_router``. Routes are thin (I6): authorize -> filter -> plan queryset -> serialize -> shape. RBAC flows only through ``get_authorized_product_types`` for reads (I8) and the v2 ``user_has_permission``/``user_has_global_permission`` semantics for writes, mirroring the v2 @@ -41,10 +47,10 @@ from dojo.authorization.roles_permissions import Permissions from dojo.models import Endpoint, Product_Type from dojo.product_type.api_v3.schemas import ( - ProductTypeDetail, - ProductTypeSlim, - ProductTypeUpdate, - ProductTypeWrite, + OrganizationDetail, + OrganizationSlim, + OrganizationUpdate, + OrganizationWrite, ) from dojo.product_type.queries import get_authorized_product_types from dojo.utils import async_delete, get_setting @@ -55,9 +61,9 @@ from django.db.models import QuerySet from django.http import HttpRequest -# --- Product_Type filter vocabulary (§4.9, minimal set) --------------------------------------- +# --- Organization filter vocabulary (§4.9, minimal set) --------------------------------------- -PRODUCT_TYPE_FILTER_SPEC = register_filter_spec("product_type", FilterSpec( +ORGANIZATION_FILTER_SPEC = register_filter_spec("organization", FilterSpec( model=Product_Type, filters={ "id__in": filter_field("id", "in", "number"), @@ -77,14 +83,14 @@ )) -class ProductTypeListResponse(Schema): +class OrganizationListResponse(Schema): """OpenAPI documentation of the list envelope (I1); runtime serialization is manual.""" count: int next: str | None previous: str | None - results: list[ProductTypeSlim] + results: list[OrganizationSlim] meta: dict | None = None @@ -113,19 +119,19 @@ def _destroy(instance: Product_Type) -> None: instance.delete() -def build_product_types_router( +def build_organizations_router( *, - schema: type = ProductTypeSlim, - detail_schema: type = ProductTypeDetail, - filter_spec: FilterSpec = PRODUCT_TYPE_FILTER_SPEC, + schema: type = OrganizationSlim, + detail_schema: type = OrganizationDetail, + filter_spec: FilterSpec = ORGANIZATION_FILTER_SPEC, queryset_hook: Callable | None = None, auth=NOT_SET, ) -> Router: - """Build the product_types router (I5).""" - router = Router(tags=["product_types"], auth=auth) + """Build the organizations router (I5).""" + router = Router(tags=["organizations"], auth=auth) - @router.get("/product_types", response=ProductTypeListResponse, url_name="product_types_list") - def list_product_types(request: HttpRequest): + @router.get("/organizations", response=OrganizationListResponse, url_name="organizations_list") + def list_organizations(request: HttpRequest): filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) @@ -143,20 +149,20 @@ def serialize_row(obj: object) -> dict: envelope.setdefault("meta", {}).update(include_meta) return json_response(envelope) - @router.get("/product_types/{int:product_type_id}", response=detail_schema, url_name="product_types_detail") - def get_product_type(request: HttpRequest, product_type_id: int): + @router.get("/organizations/{int:organization_id}", response=detail_schema, url_name="organizations_detail") + def get_organization(request: HttpRequest, organization_id: int): expand_tree, select_related, prefetch = plan(detail_schema, request.GET.get("expand")) qs = _base_queryset(request, queryset_hook).select_related(*detail_schema.SELECT_RELATED).prefetch_related(*detail_schema.PREFETCH_RELATED) qs = plan_queryset(qs, select_related, prefetch) - obj = qs.filter(pk=product_type_id).first() + obj = qs.filter(pk=organization_id).first() if obj is None: - msg = f"Product_Type {product_type_id} not found" + msg = f"Organization {organization_id} not found" raise not_found_problem(msg) fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) return json_response(apply_fields(serialize(obj, detail_schema, expand_tree), fields)) - @router.post("/product_types", response=detail_schema, url_name="product_types_create") - def create_product_type(request: HttpRequest, payload: ProductTypeWrite): + @router.post("/organizations", response=detail_schema, url_name="organizations_create") + def create_organization(request: HttpRequest, payload: OrganizationWrite): # Mirror UserHasProductTypePermission: POST requires global "add". if not user_has_global_permission(request.user, "add"): raise PermissionDenied @@ -167,11 +173,11 @@ def create_product_type(request: HttpRequest, payload: ProductTypeWrite): raise _validation_from_django(exc) from exc return json_response(serialize(instance, detail_schema, {}), status=201) - @router.patch("/product_types/{int:product_type_id}", response=detail_schema, url_name="product_types_update") - def update_product_type(request: HttpRequest, product_type_id: int, payload: ProductTypeUpdate): - instance = _base_queryset(request, queryset_hook).filter(pk=product_type_id).first() + @router.patch("/organizations/{int:organization_id}", response=detail_schema, url_name="organizations_update") + def update_organization(request: HttpRequest, organization_id: int, payload: OrganizationUpdate): + instance = _base_queryset(request, queryset_hook).filter(pk=organization_id).first() if instance is None: - msg = f"Product_Type {product_type_id} not found" + msg = f"Organization {organization_id} not found" raise not_found_problem(msg) # 404: unknown or unauthorized-to-view if not user_has_permission(request.user, instance, Permissions.Product_Type_Edit): raise PermissionDenied # 403: visible but not editable @@ -183,11 +189,11 @@ def update_product_type(request: HttpRequest, product_type_id: int, payload: Pro raise _validation_from_django(exc) from exc return json_response(serialize(instance, detail_schema, {})) - @router.delete("/product_types/{int:product_type_id}", url_name="product_types_delete") - def delete_product_type(request: HttpRequest, product_type_id: int): - instance = _base_queryset(request, queryset_hook).filter(pk=product_type_id).first() + @router.delete("/organizations/{int:organization_id}", url_name="organizations_delete") + def delete_organization(request: HttpRequest, organization_id: int): + instance = _base_queryset(request, queryset_hook).filter(pk=organization_id).first() if instance is None: - msg = f"Product_Type {product_type_id} not found" + msg = f"Organization {organization_id} not found" raise not_found_problem(msg) if not user_has_permission(request.user, instance, Permissions.Product_Type_Delete): raise PermissionDenied diff --git a/dojo/product_type/api_v3/schemas.py b/dojo/product_type/api_v3/schemas.py index ea7a2e0e7ee..cac52b3e0a8 100644 --- a/dojo/product_type/api_v3/schemas.py +++ b/dojo/product_type/api_v3/schemas.py @@ -1,17 +1,24 @@ """ -Product_Type response + write schemas for API v3 (§4.5, §4.11, OS3a). +Organization response + write schemas for API v3 (§4.5, §4.11, OS3a; renamed per D11). -``ProductTypeSlim`` is the canonical parent slim (relocated here from ``dojo/finding/api_v3`` where +**D11 wire rename:** v3 speaks the new domain language -- the ``Product_Type`` model is exposed on +the wire as ``organization``. The schema classes are ``Organization*`` and the Django model +(``Product_Type``) / DB table / module path are deliberately **not** renamed (the DTO layer is what +decouples wire names from models). See §12. + +``OrganizationSlim`` is the canonical parent slim (relocated here from ``dojo/finding/api_v3`` where OS1 first defined it -- see §12; the finding module now re-exports this copy so there is one class, not two, keeping expand targets and this resource in lock-step). Every schema is a named, importable, subclassable ninja ``Schema`` (I4) and declares the ``ClassVar`` machinery the expand planner reads (``django_model``/``SELECT_RELATED``/``PREFETCH_RELATED``/``EXPANDABLE``). -Write schemas (``ProductTypeWrite`` create, ``ProductTypeUpdate`` PATCH) are the editable subset of +Write schemas (``OrganizationWrite`` create, ``OrganizationUpdate`` PATCH) are the editable subset of the detail fields; required-vs-optional mirrors the v2 ``ProductTypeSerializer`` (a ``ModelSerializer`` over the model -- ``name`` required, the rest defaulted). Server-managed fields (``id``/``created``/ ``updated``) are never writable, and unknown fields are rejected (``extra="forbid"``) so write -validation is consistent with the kernel's strict query contract (§12). +validation is consistent with the kernel's strict query contract (§12). ``critical_product``/ +``key_product`` keep their model-column names (D11 excludes DB columns; the UI relabel itself retains +them, e.g. ``org.critical_product_label`` -- see §12). """ from __future__ import annotations @@ -26,7 +33,7 @@ from dojo.api_v3.expand import ExpandRel -class ProductTypeSlim(Schema): +class OrganizationSlim(Schema): django_model: ClassVar = Product_Type SELECT_RELATED: ClassVar[tuple] = () PREFETCH_RELATED: ClassVar[tuple] = () @@ -41,12 +48,12 @@ class ProductTypeSlim(Schema): updated: datetime | None -class ProductTypeDetail(ProductTypeSlim): +class OrganizationDetail(OrganizationSlim): - """Detail (retrieve) shape. Product_Type has no heavier read fields today, so it equals slim.""" + """Detail (retrieve) shape. Organization has no heavier read fields today, so it equals slim.""" -class ProductTypeWrite(Schema): +class OrganizationWrite(Schema): """Create payload (POST). ``name`` is required; the rest mirror the model defaults (§6 OS3).""" @@ -58,7 +65,7 @@ class ProductTypeWrite(Schema): key_product: bool = False -class ProductTypeUpdate(Schema): +class OrganizationUpdate(Schema): """Partial update payload (PATCH). Every field optional; only provided keys are applied.""" diff --git a/dojo/test/api_v3/routes.py b/dojo/test/api_v3/routes.py index 5fcaf205a96..a1c1185c39c 100644 --- a/dojo/test/api_v3/routes.py +++ b/dojo/test/api_v3/routes.py @@ -1,7 +1,7 @@ """ Test CRUD routes for API v3 (§4.5, §4.9, §4.11, OS3b). -``build_tests_router()`` is a router factory (I5), same shape as ``build_products_router``. Routes +``build_tests_router()`` is a router factory (I5), same shape as ``build_assets_router``. Routes are thin (I6): authorize -> filter -> plan queryset -> serialize -> shape. RBAC flows only through ``get_authorized_tests`` for reads (I8) and the v2 ``user_has_permission`` semantics for writes, mirroring the v2 ``UserHasTestPermission`` permission class exactly: @@ -77,9 +77,9 @@ "title__icontains": filter_field("title", "icontains", "char"), "engagement": filter_field("engagement", "exact", "number"), "engagement__in": filter_field("engagement", "in", "number"), - "product": filter_field("engagement__product", "exact", "number"), - "product__in": filter_field("engagement__product", "in", "number"), - "product_type": filter_field("engagement__product__prod_type", "exact", "number"), + "asset": filter_field("engagement__product", "exact", "number"), + "asset__in": filter_field("engagement__product", "in", "number"), + "organization": filter_field("engagement__product__prod_type", "exact", "number"), "test_type": filter_field("test_type", "exact", "number"), "environment": filter_field("environment", "exact", "number"), "lead": filter_field("lead", "exact", "number"), diff --git a/dojo/test/api_v3/schemas.py b/dojo/test/api_v3/schemas.py index 9a3fcb8e07e..e7279f9d01d 100644 --- a/dojo/test/api_v3/schemas.py +++ b/dojo/test/api_v3/schemas.py @@ -5,8 +5,9 @@ here from ``dojo/finding/api_v3`` (where OS1 first defined it for finding ``?expand=``); the finding module now re-exports these copies so there is exactly one class per model (verified is-identity in the tests -- see §12). ``TestDetail`` adds the documented heavier read fields (§4.5). -``test_type``/``engagement``/``product``/``product_type``/``environment``/``lead`` are expandable -relations (§4.6). +``test_type``/``engagement``/``asset``/``organization``/``environment``/``lead`` are expandable +relations (§4.6). Per D11 the Product/Product_Type models are exposed on the wire as +``asset``/``organization`` (ref keys + expand keys); the models/DB/module paths are not renamed (§12). Write schemas mirror the v2 ``TestCreateSerializer`` (create) and ``TestSerializer`` (update): the model requires ``engagement``, ``test_type``, ``target_start`` and ``target_end``. ``engagement`` is @@ -26,8 +27,8 @@ from dojo.api_v3.refs import Ref, to_ref from dojo.engagement.api_v3.schemas import EngagementSlim from dojo.models import Development_Environment, Test, Test_Type -from dojo.product.api_v3.schemas import ProductSlim -from dojo.product_type.api_v3.schemas import ProductTypeSlim +from dojo.product.api_v3.schemas import AssetSlim +from dojo.product_type.api_v3.schemas import OrganizationSlim from dojo.user.api_v3.schemas import UserSlim @@ -62,8 +63,8 @@ class TestSlim(Schema): name: str | None test_type: Ref engagement: Ref - product: Ref - product_type: Ref + asset: Ref + organization: Ref environment: Ref | None lead: Ref | None target_start: datetime | None @@ -86,11 +87,11 @@ def resolve_engagement(obj) -> dict | None: return to_ref(obj.engagement) @staticmethod - def resolve_product(obj) -> dict | None: + def resolve_asset(obj) -> dict | None: return to_ref(obj.engagement.product) @staticmethod - def resolve_product_type(obj) -> dict | None: + def resolve_organization(obj) -> dict | None: return to_ref(obj.engagement.product.prod_type) @staticmethod @@ -109,8 +110,8 @@ def resolve_tags(obj) -> list[str]: TestSlim.EXPANDABLE = { "test_type": ExpandRel(attr="test_type", path="test_type", schema=TestTypeSlim), "engagement": ExpandRel(attr="engagement", path="engagement", schema=EngagementSlim), - "product": ExpandRel(attr="engagement.product", path="engagement__product", schema=ProductSlim), - "product_type": ExpandRel(attr="engagement.product.prod_type", path="engagement__product__prod_type", schema=ProductTypeSlim), + "asset": ExpandRel(attr="engagement.product", path="engagement__product", schema=AssetSlim), + "organization": ExpandRel(attr="engagement.product.prod_type", path="engagement__product__prod_type", schema=OrganizationSlim), "lead": ExpandRel(attr="lead", path="lead", schema=UserSlim), "environment": ExpandRel(attr="environment", path="environment", schema=EnvironmentSlim), } diff --git a/unittests/api_v3/snapshots/filters.json b/unittests/api_v3/snapshots/filters.json index ac8cfbe6f78..b8f050113ef 100644 --- a/unittests/api_v3/snapshots/filters.json +++ b/unittests/api_v3/snapshots/filters.json @@ -1,4 +1,27 @@ { + "asset": { + "model": "Product", + "orderings": [ + "created", + "id", + "name", + "updated" + ], + "params": [ + "created__gte", + "created__lte", + "id__in", + "name__icontains", + "organization", + "organization__in", + "updated__gte", + "updated__lte" + ], + "search_fields": [ + "description", + "name" + ] + }, "engagement": { "model": "Engagement", "orderings": [ @@ -9,15 +32,15 @@ "updated" ], "params": [ + "asset", + "asset__in", "created__gte", "created__lte", "engagement_type", "id__in", "lead", "name__icontains", - "product", - "product__in", - "product_type", + "organization", "status", "target_end__gte", "target_end__lte", @@ -43,6 +66,8 @@ ], "params": [ "active", + "asset", + "asset__in", "created__gte", "created__lte", "cwe", @@ -54,10 +79,8 @@ "false_p", "id__in", "is_mitigated", + "organization", "out_of_scope", - "product", - "product__in", - "product_type", "reporter", "risk_accepted", "severity", @@ -81,38 +104,15 @@ "name" ], "params": [ + "asset", "name__icontains", - "product", "type" ], "search_fields": [ "location_value" ] }, - "product": { - "model": "Product", - "orderings": [ - "created", - "id", - "name", - "updated" - ], - "params": [ - "created__gte", - "created__lte", - "id__in", - "name__icontains", - "product_type", - "product_type__in", - "updated__gte", - "updated__lte" - ], - "search_fields": [ - "description", - "name" - ] - }, - "product_type": { + "organization": { "model": "Product_Type", "orderings": [ "created", @@ -143,6 +143,8 @@ "updated" ], "params": [ + "asset", + "asset__in", "created__gte", "created__lte", "engagement", @@ -150,9 +152,7 @@ "environment", "id__in", "lead", - "product", - "product__in", - "product_type", + "organization", "target_end__gte", "target_end__lte", "target_start__gte", diff --git a/unittests/api_v3/test_apiv3_products.py b/unittests/api_v3/test_apiv3_assets.py similarity index 57% rename from unittests/api_v3/test_apiv3_products.py rename to unittests/api_v3/test_apiv3_assets.py index 3e5dc41374f..1645d248308 100644 --- a/unittests/api_v3/test_apiv3_products.py +++ b/unittests/api_v3/test_apiv3_assets.py @@ -1,4 +1,4 @@ -"""Product CRUD + RBAC + contract tests for API v3 (OS3a).""" +"""Asset CRUD + RBAC + contract tests for API v3 (OS3a; D11 wire rename product -> asset).""" from __future__ import annotations from django.db import connection @@ -8,176 +8,176 @@ from .base import ApiV3TestCase -_SLIM_KEYS = {"id", "name", "description", "product_type", "lifecycle", "tags", "created", "updated"} +_SLIM_KEYS = {"id", "name", "description", "organization", "lifecycle", "tags", "created", "updated"} -class TestApiV3ProductsRead(ApiV3TestCase): +class TestApiV3AssetsRead(ApiV3TestCase): def test_list_envelope_and_slim_shape(self): - body = self.get_json("products") + body = self.get_json("assets") self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) self.assertGreater(body["count"], 0) row = body["results"][0] self.assertEqual(_SLIM_KEYS, set(row)) - self.assertEqual({"id", "name"}, set(row["product_type"])) + self.assertEqual({"id", "name"}, set(row["organization"])) self.assertIsInstance(row["tags"], list) def test_detail_adds_heavy_fields(self): product = Product.objects.first() - detail = self.get_json(f"products/{product.id}") - for key in ("business_criticality", "platform", "origin", "product_manager", "technical_contact", "team_manager"): + detail = self.get_json(f"assets/{product.id}") + for key in ("business_criticality", "platform", "origin", "asset_manager", "technical_contact", "team_manager"): self.assertIn(key, detail) def test_detail_unknown_is_404_problem(self): - response = self.client.get(self.v3_url("products/99999999")) + response = self.client.get(self.v3_url("assets/99999999")) self.assertEqual(404, response.status_code) self.assertEqual("application/problem+json", response["Content-Type"]) - def test_expand_product_type_inlines_slim(self): - row = self.get_json("products", data={"expand": "product_type"})["results"][0] - # ref swapped for the product_type slim (carries description, timestamps, not just id/name). - self.assertIn("description", row["product_type"]) - self.assertIn("critical_product", row["product_type"]) + def test_expand_organization_inlines_slim(self): + row = self.get_json("assets", data={"expand": "organization"})["results"][0] + # ref swapped for the organization slim (carries description, timestamps, not just id/name). + self.assertIn("description", row["organization"]) + self.assertIn("critical_product", row["organization"]) def test_expand_unknown_relation_is_400(self): - self.get_json("products", data={"expand": "not_a_relation"}, expected=400) + self.get_json("assets", data={"expand": "not_a_relation"}, expected=400) -class TestApiV3ProductsFilters(ApiV3TestCase): +class TestApiV3AssetsFilters(ApiV3TestCase): - def test_filter_product_type(self): + def test_filter_organization(self): pt_id = Product.objects.first().prod_type_id - body = self.get_json("products", data={"product_type": pt_id, "limit": 250}) + body = self.get_json("assets", data={"organization": pt_id, "limit": 250}) self.assertGreater(body["count"], 0) for row in body["results"]: - self.assertEqual(pt_id, row["product_type"]["id"]) + self.assertEqual(pt_id, row["organization"]["id"]) def test_filter_name_icontains(self): name = Product.objects.first().name - body = self.get_json("products", data={"name__icontains": name[:5]}) + body = self.get_json("assets", data={"name__icontains": name[:5]}) self.assertGreater(body["count"], 0) def test_ordering_by_name(self): - names = [r["name"] for r in self.get_json("products", data={"o": "name", "limit": 250})["results"]] + names = [r["name"] for r in self.get_json("assets", data={"o": "name", "limit": 250})["results"]] self.assertEqual(names, sorted(names)) def test_unknown_filter_param_is_400(self): - self.get_json("products", data={"not_a_filter": "x"}, expected=400) + self.get_json("assets", data={"not_a_filter": "x"}, expected=400) -class TestApiV3ProductsPagination(ApiV3TestCase): +class TestApiV3AssetsPagination(ApiV3TestCase): def test_limit_next_previous(self): - body = self.get_json("products", data={"limit": 2, "offset": 2}) + body = self.get_json("assets", data={"limit": 2, "offset": 2}) self.assertLessEqual(len(body["results"]), 2) self.assertIsNotNone(body["previous"]) -class TestApiV3ProductsQueryCount(ApiV3TestCase): +class TestApiV3AssetsQueryCount(ApiV3TestCase): def _bulk(self, count: int, start: int) -> None: pt = Product_Type.objects.first() Product.objects.bulk_create([ - Product(name=f"qcount product {start + i}", description="x", prod_type=pt, sla_configuration_id=1) + Product(name=f"qcount asset {start + i}", description="x", prod_type=pt, sla_configuration_id=1) for i in range(count) ]) def _query_count(self, params: dict) -> int: with CaptureQueriesContext(connection) as ctx: - response = self.client.get(self.v3_url("products"), params) + response = self.client.get(self.v3_url("assets"), params) self.assertEqual(200, response.status_code, response.content[:500]) return len(ctx.captured_queries) def test_query_count_is_independent_of_row_count(self): self._bulk(10, 0) first = self._query_count({"limit": 250}) - first_expand = self._query_count({"limit": 250, "expand": "product_type"}) + first_expand = self._query_count({"limit": 250, "expand": "organization"}) self._bulk(90, 100) second = self._query_count({"limit": 250}) - second_expand = self._query_count({"limit": 250, "expand": "product_type"}) + second_expand = self._query_count({"limit": 250, "expand": "organization"}) self.assertEqual(first, second, f"query count grew (no expand): {first} -> {second}") self.assertEqual(first_expand, second_expand, f"query count grew (expand): {first_expand} -> {second_expand}") -class TestApiV3ProductsWrite(ApiV3TestCase): +class TestApiV3AssetsWrite(ApiV3TestCase): def test_create_happy_path(self): pt = Product_Type.objects.first() response = self.client.post( - self.v3_url("products"), - {"name": "v3 created product", "description": "made by v3", "prod_type": pt.id, + self.v3_url("assets"), + {"name": "v3 created asset", "description": "made by v3", "organization": pt.id, "lifecycle": "production", "tags": ["pci", "v3"]}, format="json", ) self.assertEqual(201, response.status_code, response.content[:500]) body = response.json() - self.assertEqual("v3 created product", body["name"]) - self.assertEqual(pt.id, body["product_type"]["id"]) - created = Product.objects.get(name="v3 created product") + self.assertEqual("v3 created asset", body["name"]) + self.assertEqual(pt.id, body["organization"]["id"]) + created = Product.objects.get(name="v3 created asset") self.assertEqual("production", created.lifecycle) self.assertEqual({"pci", "v3"}, {t.name for t in created.tags.all()}) def test_create_missing_required_is_400(self): - response = self.client.post(self.v3_url("products"), {"name": "no prodtype"}, format="json") + response = self.client.post(self.v3_url("assets"), {"name": "no organization"}, format="json") self.assertEqual(400, response.status_code) self.assertEqual("application/problem+json", response["Content-Type"]) def test_create_unknown_field_is_400(self): pt = Product_Type.objects.first() response = self.client.post( - self.v3_url("products"), - {"name": "x", "description": "y", "prod_type": pt.id, "bogus": 1}, + self.v3_url("assets"), + {"name": "x", "description": "y", "organization": pt.id, "bogus": 1}, format="json", ) self.assertEqual(400, response.status_code) - def test_create_nonexistent_prod_type_is_404(self): + def test_create_nonexistent_organization_is_404(self): response = self.client.post( - self.v3_url("products"), - {"name": "orphan", "description": "y", "prod_type": 99999999}, + self.v3_url("assets"), + {"name": "orphan", "description": "y", "organization": 99999999}, format="json", ) self.assertEqual(404, response.status_code) def test_patch_partial_update(self): pt = Product_Type.objects.first() - product = Product.objects.create(name="v3 patch product", description="old", prod_type=pt, sla_configuration_id=1) + product = Product.objects.create(name="v3 patch asset", description="old", prod_type=pt, sla_configuration_id=1) response = self.client.patch( - self.v3_url(f"products/{product.id}"), {"description": "new"}, format="json", + self.v3_url(f"assets/{product.id}"), {"description": "new"}, format="json", ) self.assertEqual(200, response.status_code, response.content[:500]) product.refresh_from_db() self.assertEqual("new", product.description) - self.assertEqual("v3 patch product", product.name) + self.assertEqual("v3 patch asset", product.name) def test_delete(self): pt = Product_Type.objects.first() - product = Product.objects.create(name="v3 delete product", description="d", prod_type=pt, sla_configuration_id=1) - response = self.client.delete(self.v3_url(f"products/{product.id}")) + product = Product.objects.create(name="v3 delete asset", description="d", prod_type=pt, sla_configuration_id=1) + response = self.client.delete(self.v3_url(f"assets/{product.id}")) self.assertEqual(204, response.status_code) self.assertFalse(Product.objects.filter(pk=product.id).exists()) -class TestApiV3ProductsRbac(ApiV3TestCase): +class TestApiV3AssetsRbac(ApiV3TestCase): def setUp(self): super().setUp() - self.limited = User.objects.create_user(username="v3_prod_limited", password="x") # noqa: S106 + self.limited = User.objects.create_user(username="v3_asset_limited", password="x") # noqa: S106 # authorized_users M2M targets Dojo_User (the proxy), so the member must be a Dojo_User. - self.member = Dojo_User.objects.create_user(username="v3_prod_member", password="x") # noqa: S106 + self.member = Dojo_User.objects.create_user(username="v3_asset_member", password="x") # noqa: S106 self.product = Product.objects.first() self.product.authorized_users.add(self.member) def test_unauthorized_read_is_404(self): client = self.token_client(user=self.limited) - self.assertEqual(0, self.get_json("products", client=client)["count"]) - self.get_json(f"products/{self.product.id}", client=client, expected=404) + self.assertEqual(0, self.get_json("assets", client=client)["count"]) + self.get_json(f"assets/{self.product.id}", client=client, expected=404) - def test_create_without_prod_type_add_is_403(self): + def test_create_without_organization_add_is_403(self): client = self.token_client(user=self.limited) response = client.post( - self.v3_url("products"), - {"name": "v3 rbac nope", "description": "d", "prod_type": self.product.prod_type_id}, + self.v3_url("assets"), + {"name": "v3 rbac nope", "description": "d", "organization": self.product.prod_type_id}, format="json", ) self.assertEqual(403, response.status_code, response.content[:300]) @@ -185,6 +185,6 @@ def test_create_without_prod_type_add_is_403(self): def test_member_can_view_but_delete_is_403(self): client = self.token_client(user=self.member) - self.get_json(f"products/{self.product.id}", client=client) - response = client.delete(self.v3_url(f"products/{self.product.id}")) + self.get_json(f"assets/{self.product.id}", client=client) + response = client.delete(self.v3_url(f"assets/{self.product.id}")) self.assertEqual(403, response.status_code, response.content[:300]) diff --git a/unittests/api_v3/test_apiv3_engagements.py b/unittests/api_v3/test_apiv3_engagements.py index 056f2ec1b4d..14c515bde5b 100644 --- a/unittests/api_v3/test_apiv3_engagements.py +++ b/unittests/api_v3/test_apiv3_engagements.py @@ -13,7 +13,7 @@ from .base import ApiV3TestCase _SLIM_KEYS = { - "id", "name", "product", "product_type", "lead", "status", "engagement_type", + "id", "name", "asset", "organization", "lead", "status", "engagement_type", "target_start", "target_end", "active", "tags", "created", "updated", } @@ -34,8 +34,8 @@ def test_list_envelope_and_slim_shape(self): self.assertGreater(body["count"], 0) row = body["results"][0] self.assertEqual(_SLIM_KEYS, set(row)) - self.assertEqual({"id", "name"}, set(row["product"])) - self.assertEqual({"id", "name"}, set(row["product_type"])) + self.assertEqual({"id", "name"}, set(row["asset"])) + self.assertEqual({"id", "name"}, set(row["organization"])) self.assertIsInstance(row["tags"], list) def test_detail_adds_heavy_fields(self): @@ -49,20 +49,20 @@ def test_detail_unknown_is_404_problem(self): self.assertEqual(404, response.status_code) self.assertEqual("application/problem+json", response["Content-Type"]) - def test_expand_product_inlines_slim(self): - row = self.get_json("engagements", data={"expand": "product"})["results"][0] - self.assertIn("description", row["product"]) - self.assertIn("lifecycle", row["product"]) + def test_expand_asset_inlines_slim(self): + row = self.get_json("engagements", data={"expand": "asset"})["results"][0] + self.assertIn("description", row["asset"]) + self.assertIn("lifecycle", row["asset"]) - def test_expand_lead_and_product_type(self): + def test_expand_lead_and_organization(self): eng = Engagement.objects.exclude(lead__isnull=True).first() if eng is None: eng = Engagement.objects.first() eng.lead = self.admin eng.save() - row = self.get_json("engagements", data={"expand": "lead,product_type", "id__in": eng.id})["results"][0] + row = self.get_json("engagements", data={"expand": "lead,organization", "id__in": eng.id})["results"][0] self.assertIn("username", row["lead"]) - self.assertIn("critical_product", row["product_type"]) + self.assertIn("critical_product", row["organization"]) def test_expand_unknown_relation_is_400(self): self.get_json("engagements", data={"expand": "not_a_relation"}, expected=400) @@ -70,12 +70,12 @@ def test_expand_unknown_relation_is_400(self): class TestApiV3EngagementsFilters(ApiV3TestCase): - def test_filter_product(self): + def test_filter_asset(self): product_id = Engagement.objects.first().product_id - body = self.get_json("engagements", data={"product": product_id, "limit": 250}) + body = self.get_json("engagements", data={"asset": product_id, "limit": 250}) self.assertGreater(body["count"], 0) for row in body["results"]: - self.assertEqual(product_id, row["product"]["id"]) + self.assertEqual(product_id, row["asset"]["id"]) def test_filter_status(self): eng = Engagement.objects.first() @@ -118,10 +118,10 @@ def _query_count(self, params: dict) -> int: def test_query_count_is_independent_of_row_count(self): self._bulk(10, 0) first = self._query_count({"limit": 250}) - first_expand = self._query_count({"limit": 250, "expand": "product.product_type"}) + first_expand = self._query_count({"limit": 250, "expand": "asset.organization"}) self._bulk(90, 100) second = self._query_count({"limit": 250}) - second_expand = self._query_count({"limit": 250, "expand": "product.product_type"}) + second_expand = self._query_count({"limit": 250, "expand": "asset.organization"}) self.assertEqual(first, second, f"query count grew (no expand): {first} -> {second}") self.assertEqual(first_expand, second_expand, f"query count grew (expand): {first_expand} -> {second_expand}") @@ -132,7 +132,7 @@ def test_create_happy_path(self): product = Product.objects.first() response = self.client.post( self.v3_url("engagements"), - {"name": "v3 created engagement", "product": product.id, + {"name": "v3 created engagement", "asset": product.id, "target_start": "2024-01-01", "target_end": "2024-02-01", "status": "In Progress", "tags": ["v3"]}, format="json", @@ -140,7 +140,7 @@ def test_create_happy_path(self): self.assertEqual(201, response.status_code, response.content[:500]) body = response.json() self.assertEqual("v3 created engagement", body["name"]) - self.assertEqual(product.id, body["product"]["id"]) + self.assertEqual(product.id, body["asset"]["id"]) created = Engagement.objects.get(name="v3 created engagement") self.assertEqual({"v3"}, {t.name for t in created.tags.all()}) @@ -148,7 +148,7 @@ def test_create_target_start_after_end_is_400(self): product = Product.objects.first() response = self.client.post( self.v3_url("engagements"), - {"name": "bad dates", "product": product.id, + {"name": "bad dates", "asset": product.id, "target_start": "2024-02-01", "target_end": "2024-01-01"}, format="json", ) @@ -156,22 +156,22 @@ def test_create_target_start_after_end_is_400(self): self.assertEqual("application/problem+json", response["Content-Type"]) def test_create_missing_required_is_400(self): - response = self.client.post(self.v3_url("engagements"), {"name": "no product"}, format="json") + response = self.client.post(self.v3_url("engagements"), {"name": "no asset"}, format="json") self.assertEqual(400, response.status_code) def test_create_unknown_field_is_400(self): product = Product.objects.first() response = self.client.post( self.v3_url("engagements"), - {"product": product.id, "target_start": "2024-01-01", "target_end": "2024-02-01", "bogus": 1}, + {"asset": product.id, "target_start": "2024-01-01", "target_end": "2024-02-01", "bogus": 1}, format="json", ) self.assertEqual(400, response.status_code) - def test_create_nonexistent_product_is_404(self): + def test_create_nonexistent_asset_is_404(self): response = self.client.post( self.v3_url("engagements"), - {"product": 99999999, "target_start": "2024-01-01", "target_end": "2024-02-01"}, + {"asset": 99999999, "target_start": "2024-01-01", "target_end": "2024-02-01"}, format="json", ) self.assertEqual(404, response.status_code) @@ -215,11 +215,11 @@ def test_unauthorized_read_is_404(self): self.assertEqual(0, self.get_json("engagements", client=client)["count"]) self.get_json(f"engagements/{self.engagement.id}", client=client, expected=404) - def test_create_without_product_add_is_403(self): + def test_create_without_asset_add_is_403(self): client = self.token_client(user=self.limited) response = client.post( self.v3_url("engagements"), - {"product": self.product.id, "target_start": "2024-01-01", "target_end": "2024-02-01"}, + {"asset": self.product.id, "target_start": "2024-01-01", "target_end": "2024-02-01"}, format="json", ) self.assertEqual(403, response.status_code, response.content[:300]) diff --git a/unittests/api_v3/test_apiv3_examples.py b/unittests/api_v3/test_apiv3_examples.py index b71faca4c00..b6d1b938994 100644 --- a/unittests/api_v3/test_apiv3_examples.py +++ b/unittests/api_v3/test_apiv3_examples.py @@ -103,7 +103,7 @@ def test_capture_examples(self): ]) self._capture_findings(finding) - self._capture_products() + self._capture_assets() self._write(_OUT) # --- findings (the complex entity) ---------------------------------------------------------- @@ -113,7 +113,7 @@ def _capture_findings(self, finding: Finding) -> None: self._record( title="Finding — GET detail (slim + detail fields)", intro="Retrieve a single finding. Relations render as closed `{id, name}` refs (§4.4); the " - "parent chain (`test`/`engagement`/`product`/`product_type`) is denormalized onto the " + "parent chain (`test`/`engagement`/`asset`/`organization`) is denormalized onto the " "finding. `locations_count` is an annotation; the full list is a sub-resource (§4.14).", method="GET", url=self.v3_url(f"findings/{fid}"), response=self.client.get(self.v3_url(f"findings/{fid}")), @@ -149,7 +149,7 @@ def _capture_findings(self, finding: Finding) -> None: ) # notes sub-resource: POST then GET - note_body = {"entry": "Reviewed with the product team; scheduled for the next sprint.", "private": False} + note_body = {"entry": "Reviewed with the security team; scheduled for the next sprint.", "private": False} self._record( title="Finding — POST a note (sub-resource)", intro="Notes are one generic sub-resource across resources (§4.12). Authorization is " @@ -202,7 +202,7 @@ def _capture_import(self) -> None: "multipart/form-data fields:\n" " mode=import # auto | import | reimport (default auto)\n" " scan_type=ZAP Scan\n" - " engagement=4 # or test= / product_name+engagement_name+auto_create_context\n" + " engagement=4 # or test= / asset_name+engagement_name+auto_create_context\n" " file=@0_zap_sample.xml\n" " active=true\n" " verified=true" @@ -221,32 +221,32 @@ def _capture_import(self) -> None: req_body=import_desc, response=response, ) - # --- products (the simple contrast) --------------------------------------------------------- - def _capture_products(self) -> None: + # --- assets (the simple contrast) ----------------------------------------------------------- + def _capture_assets(self) -> None: product = Product.objects.first() self._record( - title="Product — GET detail", - intro="A simple entity for contrast with findings: identity, `product_type` ref, and the " + title="Asset — GET detail", + intro="A simple entity for contrast with findings: identity, `organization` ref, and the " "documented heavier detail fields.", - method="GET", url=self.v3_url(f"products/{product.id}"), - response=self.client.get(self.v3_url(f"products/{product.id}")), + method="GET", url=self.v3_url(f"assets/{product.id}"), + response=self.client.get(self.v3_url(f"assets/{product.id}")), ) self._record( - title="Product — GET list", + title="Asset — GET list", intro="Same envelope and grammar as findings; slim rows only on list (§4.5).", - method="GET", url=self.v3_url("products?limit=2"), - response=self.client.get(self.v3_url("products?limit=2")), truncate=True, + method="GET", url=self.v3_url("assets?limit=2"), + response=self.client.get(self.v3_url("assets?limit=2")), truncate=True, ) pt = Product_Type.objects.first() - create_body = {"name": "Example v3 Product", "description": "Created via the v3 API examples harness", - "prod_type": pt.id, "lifecycle": "production", "tags": ["pci", "example"]} - create_resp = self.client.post(self.v3_url("products"), create_body, format="json") + create_body = {"name": "Example v3 Asset", "description": "Created via the v3 API examples harness", + "organization": pt.id, "lifecycle": "production", "tags": ["pci", "example"]} + create_resp = self.client.post(self.v3_url("assets"), create_body, format="json") self._record( - title="Product — POST (create)", - intro="Create a product. Relations are referenced by integer id (§4.11); unknown fields " + title="Asset — POST (create)", + intro="Create an asset. Relations are referenced by integer id (§4.11); unknown fields " "are rejected (400). Response is the created detail shape (`201`).", - method="POST", url=self.v3_url("products"), + method="POST", url=self.v3_url("assets"), req_headers=[f"Authorization: Token {_TOKEN_PLACEHOLDER}", "Content-Type: application/json"], req_body=_pretty(create_body), response=create_resp, ) @@ -254,12 +254,12 @@ def _capture_products(self) -> None: new_id = create_resp.json().get("id") if create_resp.status_code == 201 else product.id patch_body = {"description": "Updated description via PATCH"} self._record( - title="Product — PATCH (partial update)", + title="Asset — PATCH (partial update)", intro="Partial update; only the changed field is sent.", - method="PATCH", url=self.v3_url(f"products/{new_id}"), + method="PATCH", url=self.v3_url(f"assets/{new_id}"), req_headers=[f"Authorization: Token {_TOKEN_PLACEHOLDER}", "Content-Type: application/json"], req_body=_pretty(patch_body), - response=self.client.patch(self.v3_url(f"products/{new_id}"), patch_body, format="json"), + response=self.client.patch(self.v3_url(f"assets/{new_id}"), patch_body, format="json"), ) # --- output --------------------------------------------------------------------------------- diff --git a/unittests/api_v3/test_apiv3_expand_rbac.py b/unittests/api_v3/test_apiv3_expand_rbac.py index 8684d01cafe..03d84c71f0b 100644 --- a/unittests/api_v3/test_apiv3_expand_rbac.py +++ b/unittests/api_v3/test_apiv3_expand_rbac.py @@ -17,7 +17,7 @@ data never leaks through any v3 projection. Setup mirrors the OSS authorization model used by the other v3 RBAC tests -(``test_apiv3_products.py`` / ``test_apiv3_subresources.py``): membership via the legacy +(``test_apiv3_assets.py`` / ``test_apiv3_subresources.py``): membership via the legacy ``product.authorized_users`` M2M, which the auth-filter plugin resolves into the finding queryset. """ from __future__ import annotations @@ -109,18 +109,18 @@ def test_authorized_member_can_expand_test_engagement(self): # test ref swapped for the test slim, engagement inlined inside it, all belonging to product A. self.assertIn("engagement", detail["test"]) self.assertEqual(self.test_a.engagement.id, detail["test"]["engagement"]["id"]) - self.assertEqual(self.product_a.id, detail["test"]["engagement"]["product"]["id"]) + self.assertEqual(self.product_a.id, detail["test"]["engagement"]["asset"]["id"]) def test_authorized_member_list_expands_only_own_product(self): body = self.get_json( "findings", client=self.member_client(), - data={"expand": "test.engagement,product,product_type", "limit": 250}, + data={"expand": "test.engagement,asset,organization", "limit": 250}, ) self.assertEqual(len(self.findings_a), body["count"]) for row in body["results"]: # Denormalized parent refs AND expanded objects must all be product A. - self.assertEqual(self.product_a.id, row["product"]["id"]) - self.assertEqual(self.product_a.id, row["test"]["engagement"]["product"]["id"]) + self.assertEqual(self.product_a.id, row["asset"]["id"]) + self.assertEqual(self.product_a.id, row["test"]["engagement"]["asset"]["id"]) def test_authorized_member_include_counts_reflects_own_product(self): body = self.get_json("findings", client=self.member_client(), data={"include": "counts"}) @@ -136,7 +136,7 @@ class TestApiV3ExpandRbacCrossProduct(_TwoProductWorld): def test_list_never_includes_other_product_rows(self): body = self.get_json("findings", client=self.member_client(), data={"limit": 250}) - seen_products = {row["product"]["id"] for row in body["results"]} + seen_products = {row["asset"]["id"] for row in body["results"]} self.assertEqual({self.product_a.id}, seen_products) self.assertNotIn(self.product_b.id, seen_products) @@ -149,7 +149,7 @@ def test_detail_of_other_product_finding_is_404_no_expansion(self): def test_filter_by_other_product_returns_empty(self): # A filter naming product B must still be intersected with the authorized queryset -> empty. - body = self.get_json("findings", client=self.member_client(), data={"product": self.product_b.id}) + body = self.get_json("findings", client=self.member_client(), data={"asset": self.product_b.id}) self.assertEqual(0, body["count"]) self.assertEqual([], body["results"]) diff --git a/unittests/api_v3/test_apiv3_findings.py b/unittests/api_v3/test_apiv3_findings.py index 974f8313f87..38d31601a76 100644 --- a/unittests/api_v3/test_apiv3_findings.py +++ b/unittests/api_v3/test_apiv3_findings.py @@ -12,8 +12,8 @@ _SLIM_KEYS = { "id", "title", "severity", "active", "verified", "false_p", "duplicate", "risk_accepted", - "out_of_scope", "is_mitigated", "date", "cwe", "test", "engagement", "product", - "product_type", "reporter", "locations_count", "tags", "created", "updated", + "out_of_scope", "is_mitigated", "date", "cwe", "test", "engagement", "asset", + "organization", "reporter", "locations_count", "tags", "created", "updated", } @@ -30,7 +30,7 @@ def test_slim_shape_and_denormalized_parent_refs(self): row = self.get_json("findings")["results"][0] self.assertEqual(_SLIM_KEYS, set(row)) # Refs are closed {id, name}; parent chain is denormalized onto the finding. - for key in ("test", "engagement", "product", "product_type"): + for key in ("test", "engagement", "asset", "organization"): self.assertEqual({"id", "name"}, set(row[key]), key) self.assertIsInstance(row["locations_count"], int) self.assertIsInstance(row["tags"], list) @@ -59,7 +59,7 @@ def test_expand_test_engagement_inlines_slim(self): self.assertIn("engagement", row["test"]) # engagement nested inside test is itself a slim (carries name, not just id). self.assertIn("name", row["test"]["engagement"]) - self.assertIn("product", row["test"]["engagement"]) + self.assertIn("asset", row["test"]["engagement"]) def test_expand_reporter(self): row = self.get_json("findings", data={"expand": "reporter"})["results"][0] diff --git a/unittests/api_v3/test_apiv3_import.py b/unittests/api_v3/test_apiv3_import.py index 7c40ee89067..42e2904eb64 100644 --- a/unittests/api_v3/test_apiv3_import.py +++ b/unittests/api_v3/test_apiv3_import.py @@ -103,8 +103,8 @@ def test_reimport_default_close_old_findings_is_true(self): # --- auto mode -------------------------------------------------------------------------- def test_auto_mode_creates_then_reuses(self): created = self._v3_import( - mode="auto", product_name="v3 Auto Product", engagement_name="v3 Auto Eng", - product_type_name="v3 Auto PT", auto_create_context="true", + mode="auto", asset_name="v3 Auto Product", engagement_name="v3 Auto Eng", + organization_name="v3 Auto PT", auto_create_context="true", ) self.assertEqual("import", created["mode_resolved"]) first_test = created["test"]["id"] @@ -112,8 +112,8 @@ def test_auto_mode_creates_then_reuses(self): # Auto again with the same identifiers resolves the existing test -> reimport. reused = self._v3_import( - mode="auto", product_name="v3 Auto Product", engagement_name="v3 Auto Eng", - product_type_name="v3 Auto PT", auto_create_context="true", + mode="auto", asset_name="v3 Auto Product", engagement_name="v3 Auto Eng", + organization_name="v3 Auto PT", auto_create_context="true", ) self.assertEqual("reimport", reused["mode_resolved"]) self.assertEqual(first_test, reused["test"]["id"]) diff --git a/unittests/api_v3/test_apiv3_locations.py b/unittests/api_v3/test_apiv3_locations.py index a3f3132b8cf..a2475cbcd5e 100644 --- a/unittests/api_v3/test_apiv3_locations.py +++ b/unittests/api_v3/test_apiv3_locations.py @@ -1,9 +1,9 @@ """ -Locations resource + finding/product location sub-resources for API v3 (§4.14, OS4). +Locations resource + finding/asset location sub-resources for API v3 (§4.14, OS4). Covers: the read-only ``/locations`` resource (slim/detail shapes incl. URL-subtype fields, filters, orderings, pagination, the v2 superuser-only RBAC mirror, constant query count); the -``/findings/{id}/locations`` and ``/products/{id}/locations`` edge sub-resources (edge shapes, +``/findings/{id}/locations`` and ``/assets/{id}/locations`` edge sub-resources (edge shapes, auditor ref, parent-inherited authorization 404, pagination, constant query count); the ``?fields=`` / ``?expand=`` interplay; and flag-off behaviour (whole /api/v3-alpha/ tree absent). """ @@ -26,7 +26,7 @@ _SLIM_KEYS = {"id", "name", "type", "tags"} _DETAIL_KEYS = _SLIM_KEYS | {"protocol", "host", "port", "path", "query", "fragment"} _FINDING_EDGE_KEYS = {"location", "status", "audit_time", "auditor"} -_PRODUCT_EDGE_KEYS = {"location", "status"} +_ASSET_EDGE_KEYS = {"location", "status"} _LOCATION_REF_KEYS = {"id", "name", "type"} @@ -75,9 +75,9 @@ def test_filter_name_icontains(self): for row in body["results"]: self.assertIn("bar.foo", row["name"]) - def test_filter_product(self): - # Fixture: LocationProductReference links product 1 -> location 6. - body = self.get_json("locations", data={"product": 1, "limit": 250}) + def test_filter_asset(self): + # Fixture: LocationProductReference links asset (product) 1 -> location 6. + body = self.get_json("locations", data={"asset": 1, "limit": 250}) ids = {row["id"] for row in body["results"]} self.assertIn(6, ids) @@ -221,34 +221,34 @@ def query_count() -> int: self.assertEqual(first, second, f"sub-resource query count grew: {first} -> {second}") -class TestApiV3ProductLocationsSubResource(ApiV3TestCase): +class TestApiV3AssetLocationsSubResource(ApiV3TestCase): - """GET /products/{id}/locations edge rows: location ref + status only (no audit columns, §12).""" + """GET /assets/{id}/locations edge rows: location ref + status only (no audit columns, §12).""" def _attach(self, product, value, status="Active"): location = Location.objects.create(location_type="url", location_value=value) return LocationProductReference.objects.create(location=location, product=product, status=status) def test_edge_shape_and_location_ref(self): - # Fixture: product 1 -> location 6 (Active). - body = self.get_json("products/1/locations") + # Fixture: asset (product) 1 -> location 6 (Active). + body = self.get_json("assets/1/locations") self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) self.assertGreaterEqual(body["count"], 1) row = next(r for r in body["results"] if r["location"]["id"] == 6) - self.assertEqual(_PRODUCT_EDGE_KEYS, set(row)) + self.assertEqual(_ASSET_EDGE_KEYS, set(row)) self.assertEqual(_LOCATION_REF_KEYS, set(row["location"])) self.assertEqual("url", row["location"]["type"]) self.assertEqual("Active", row["status"]) def test_unknown_parent_is_404(self): - response = self.client.get(self.v3_url("products/99999999/locations")) + response = self.client.get(self.v3_url("assets/99999999/locations")) self.assertEqual(404, response.status_code) self.assertEqual("application/problem+json", response["Content-Type"]) def test_unauthorized_parent_is_404(self): - limited = User.objects.create_user(username="v3_loc_prodlimited", password="x") # noqa: S106 + limited = User.objects.create_user(username="v3_loc_assetlimited", password="x") # noqa: S106 client = self.token_client(user=limited) - response = client.get(self.v3_url("products/1/locations")) + response = client.get(self.v3_url("assets/1/locations")) self.assertEqual(404, response.status_code, response.content[:300]) def test_query_count_is_independent_of_edge_count(self): @@ -256,7 +256,7 @@ def test_query_count_is_independent_of_edge_count(self): def query_count() -> int: with CaptureQueriesContext(connection) as ctx: - response = self.client.get(self.v3_url(f"products/{product.id}/locations"), {"limit": 250}) + response = self.client.get(self.v3_url(f"assets/{product.id}/locations"), {"limit": 250}) self.assertEqual(200, response.status_code, response.content[:500]) return len(ctx.captured_queries) @@ -312,7 +312,7 @@ def test_flag_off_unmounts_entire_v3_tree(self): clear_url_caches() importlib.reload(dojo.urls) # Nothing under the v3 prefix resolves once the flag is off -- the mount is gone. - for path in ("locations", "findings", "products", "import"): + for path in ("locations", "findings", "assets", "import"): with self.assertRaises(Resolver404): resolve(self.v3_url(path), urlconf=dojo.urls) finally: diff --git a/unittests/api_v3/test_apiv3_openapi.py b/unittests/api_v3/test_apiv3_openapi.py index a3ac4837d2f..719e4cfce52 100644 --- a/unittests/api_v3/test_apiv3_openapi.py +++ b/unittests/api_v3/test_apiv3_openapi.py @@ -26,17 +26,31 @@ def test_info_version_and_alpha_banner(self): def test_expected_paths_present(self): # Paths carry the mount prefix (/api/v3-alpha/...); assert on the operation suffix. + # Per D11 the wire paths are /organizations and /assets (not /product_types, /products). paths = set(self.schema["paths"]) - for suffix in ("/findings", "/findings/{finding_id}", "/import"): + for suffix in ( + "/findings", "/findings/{finding_id}", "/import", + "/organizations", "/organizations/{organization_id}", + "/assets", "/assets/{asset_id}", "/assets/{asset_id}/locations", + ): self.assertTrue( any(p.endswith(suffix) for p in paths), f"missing a path ending in {suffix}; have {sorted(paths)}", ) + # D11 all-or-nothing guard: no legacy product/product_type token in any wire path segment. + offenders = [p for p in paths if "product" in p.lower()] + self.assertEqual([], offenders, f"legacy product token in wire paths: {offenders}") def test_expected_components_present(self): + # Per D11 the schema classes are Asset*/Organization* (not Product*/ProductType*). components = set(self.schema["components"]["schemas"]) - for expected in ("Ref", "FindingSlim", "FindingDetail"): + for expected in ("Ref", "FindingSlim", "FindingDetail", "AssetSlim", "OrganizationSlim"): self.assertIn(expected, components, f"missing component {expected}") + # D11 guard: no legacy product token in component *schema names*. (The scalar model-column + # properties critical_product/key_product are the deliberate documented residual -- D11 + # excludes DB columns -- so component *property* names are intentionally not checked; §12.) + name_offenders = [c for c in components if "product" in c.lower()] + self.assertEqual([], name_offenders, f"legacy product token in component names: {name_offenders}") def test_ref_component_is_closed_shape(self): ref = self.schema["components"]["schemas"]["Ref"] @@ -49,4 +63,9 @@ def test_per_resource_tags(self): for operation in path.values() for tag in operation.get("tags", []) } - self.assertLessEqual({"findings", "import"}, tags, f"tags found: {sorted(tags)}") + self.assertLessEqual( + {"findings", "import", "organizations", "assets"}, tags, f"tags found: {sorted(tags)}", + ) + # D11 guard: no legacy product/product_type token in any OpenAPI tag. + tag_offenders = [t for t in tags if "product" in t.lower()] + self.assertEqual([], tag_offenders, f"legacy product token in tags: {tag_offenders}") diff --git a/unittests/api_v3/test_apiv3_product_types.py b/unittests/api_v3/test_apiv3_organizations.py similarity index 73% rename from unittests/api_v3/test_apiv3_product_types.py rename to unittests/api_v3/test_apiv3_organizations.py index c0e42b11ee8..7a32891e55f 100644 --- a/unittests/api_v3/test_apiv3_product_types.py +++ b/unittests/api_v3/test_apiv3_organizations.py @@ -1,4 +1,4 @@ -"""Product_Type CRUD + RBAC + contract tests for API v3 (OS3a).""" +"""Organization CRUD + RBAC + contract tests for API v3 (OS3a; D11 wire rename product_type -> organization).""" from __future__ import annotations from django.db import connection @@ -11,69 +11,69 @@ _SLIM_KEYS = {"id", "name", "description", "critical_product", "key_product", "created", "updated"} -class TestApiV3ProductTypesRead(ApiV3TestCase): +class TestApiV3OrganizationsRead(ApiV3TestCase): def test_list_envelope_and_slim_shape(self): - body = self.get_json("product_types") + body = self.get_json("organizations") self.assertEqual({"count", "next", "previous", "results"}, set(body) - {"meta"}) self.assertGreater(body["count"], 0) self.assertEqual(_SLIM_KEYS, set(body["results"][0])) def test_detail_shape(self): pt = Product_Type.objects.first() - detail = self.get_json(f"product_types/{pt.id}") + detail = self.get_json(f"organizations/{pt.id}") self.assertEqual(pt.id, detail["id"]) self.assertEqual(pt.name, detail["name"]) def test_detail_unknown_is_404_problem(self): - response = self.client.get(self.v3_url("product_types/99999999")) + response = self.client.get(self.v3_url("organizations/99999999")) self.assertEqual(404, response.status_code) self.assertEqual("application/problem+json", response["Content-Type"]) def test_fields_projection(self): - row = self.get_json("product_types", data={"fields": "id,name"})["results"][0] + row = self.get_json("organizations", data={"fields": "id,name"})["results"][0] self.assertEqual({"id", "name"}, set(row)) def test_unknown_field_is_400(self): - self.get_json("product_types", data={"fields": "id,nope"}, expected=400) + self.get_json("organizations", data={"fields": "id,nope"}, expected=400) -class TestApiV3ProductTypesFilters(ApiV3TestCase): +class TestApiV3OrganizationsFilters(ApiV3TestCase): def test_filter_name_icontains(self): pt = Product_Type.objects.first() - body = self.get_json("product_types", data={"name__icontains": pt.name[:4]}) + body = self.get_json("organizations", data={"name__icontains": pt.name[:4]}) self.assertGreater(body["count"], 0) def test_ordering_by_name(self): Product_Type.objects.create(name="ZZZ v3 last type") Product_Type.objects.create(name="AAA v3 first type") - names = [r["name"] for r in self.get_json("product_types", data={"o": "name", "limit": 250})["results"]] + names = [r["name"] for r in self.get_json("organizations", data={"o": "name", "limit": 250})["results"]] self.assertEqual(names, sorted(names)) def test_unknown_filter_param_is_400(self): - self.get_json("product_types", data={"not_a_filter": "x"}, expected=400) + self.get_json("organizations", data={"not_a_filter": "x"}, expected=400) def test_unknown_ordering_is_400(self): - self.get_json("product_types", data={"o": "nope"}, expected=400) + self.get_json("organizations", data={"o": "nope"}, expected=400) -class TestApiV3ProductTypesPagination(ApiV3TestCase): +class TestApiV3OrganizationsPagination(ApiV3TestCase): def test_limit_and_next(self): for i in range(4): Product_Type.objects.create(name=f"v3 page pt {i}") - body = self.get_json("product_types", data={"limit": 2}) + body = self.get_json("organizations", data={"limit": 2}) self.assertLessEqual(len(body["results"]), 2) self.assertIsNotNone(body["next"]) self.assertIsNone(body["previous"]) -class TestApiV3ProductTypesQueryCount(ApiV3TestCase): +class TestApiV3OrganizationsQueryCount(ApiV3TestCase): def _query_count(self, params: dict) -> int: with CaptureQueriesContext(connection) as ctx: - response = self.client.get(self.v3_url("product_types"), params) + response = self.client.get(self.v3_url("organizations"), params) self.assertEqual(200, response.status_code, response.content[:500]) return len(ctx.captured_queries) @@ -85,11 +85,11 @@ def test_query_count_is_independent_of_row_count(self): self.assertEqual(first, second, f"query count grew with rows: {first} -> {second}") -class TestApiV3ProductTypesWrite(ApiV3TestCase): +class TestApiV3OrganizationsWrite(ApiV3TestCase): def test_create_happy_path(self): response = self.client.post( - self.v3_url("product_types"), + self.v3_url("organizations"), {"name": "v3 created type", "description": "made by v3", "critical_product": True}, format="json", ) @@ -100,20 +100,20 @@ def test_create_happy_path(self): self.assertTrue(Product_Type.objects.filter(name="v3 created type").exists()) def test_create_missing_required_name_is_400(self): - response = self.client.post(self.v3_url("product_types"), {"description": "no name"}, format="json") + response = self.client.post(self.v3_url("organizations"), {"description": "no name"}, format="json") self.assertEqual(400, response.status_code) self.assertEqual("application/problem+json", response["Content-Type"]) def test_create_unknown_field_is_400(self): response = self.client.post( - self.v3_url("product_types"), {"name": "x", "bogus_field": 1}, format="json", + self.v3_url("organizations"), {"name": "x", "bogus_field": 1}, format="json", ) self.assertEqual(400, response.status_code) def test_patch_partial_update(self): pt = Product_Type.objects.create(name="v3 patch me", description="old") response = self.client.patch( - self.v3_url(f"product_types/{pt.id}"), {"description": "new"}, format="json", + self.v3_url(f"organizations/{pt.id}"), {"description": "new"}, format="json", ) self.assertEqual(200, response.status_code, response.content[:500]) self.assertEqual("new", response.json()["description"]) @@ -123,37 +123,37 @@ def test_patch_partial_update(self): def test_delete(self): pt = Product_Type.objects.create(name="v3 delete me") - response = self.client.delete(self.v3_url(f"product_types/{pt.id}")) + response = self.client.delete(self.v3_url(f"organizations/{pt.id}")) self.assertEqual(204, response.status_code) self.assertFalse(Product_Type.objects.filter(pk=pt.id).exists()) -class TestApiV3ProductTypesRbac(ApiV3TestCase): +class TestApiV3OrganizationsRbac(ApiV3TestCase): def setUp(self): super().setUp() - self.limited = User.objects.create_user(username="v3_pt_limited", password="x") # noqa: S106 + self.limited = User.objects.create_user(username="v3_org_limited", password="x") # noqa: S106 # authorized_users M2M targets Dojo_User (the proxy), so the member must be a Dojo_User. - self.member = Dojo_User.objects.create_user(username="v3_pt_member", password="x") # noqa: S106 + self.member = Dojo_User.objects.create_user(username="v3_org_member", password="x") # noqa: S106 self.pt = Product_Type.objects.first() self.pt.authorized_users.add(self.member) def test_unauthorized_read_is_404(self): client = self.token_client(user=self.limited) - # Limited user has no product-type membership -> empty list, detail 404. - self.assertEqual(0, self.get_json("product_types", client=client)["count"]) - self.get_json(f"product_types/{self.pt.id}", client=client, expected=404) + # Limited user has no organization membership -> empty list, detail 404. + self.assertEqual(0, self.get_json("organizations", client=client)["count"]) + self.get_json(f"organizations/{self.pt.id}", client=client, expected=404) def test_create_without_global_add_is_403(self): client = self.token_client(user=self.limited) - response = client.post(self.v3_url("product_types"), {"name": "v3 nope"}, format="json") + response = client.post(self.v3_url("organizations"), {"name": "v3 nope"}, format="json") self.assertEqual(403, response.status_code, response.content[:300]) self.assertEqual("application/problem+json", response["Content-Type"]) def test_member_can_view_but_delete_is_403(self): client = self.token_client(user=self.member) # Member can view (200) ... - self.get_json(f"product_types/{self.pt.id}", client=client) + self.get_json(f"organizations/{self.pt.id}", client=client) # ... but delete is staff-only for non-staff members (legacy model) -> 403 (not 404). - response = client.delete(self.v3_url(f"product_types/{self.pt.id}")) + response = client.delete(self.v3_url(f"organizations/{self.pt.id}")) self.assertEqual(403, response.status_code, response.content[:300]) diff --git a/unittests/api_v3/test_apiv3_query_report.py b/unittests/api_v3/test_apiv3_query_report.py index 18f4fe9635b..9ff0c05d505 100644 --- a/unittests/api_v3/test_apiv3_query_report.py +++ b/unittests/api_v3/test_apiv3_query_report.py @@ -108,10 +108,10 @@ def _representative_requests(self) -> dict[str, str]: return { "/findings": self.v3_url(f"findings?{limit}&expand=test.engagement,locations&include=counts"), "/findings/{finding_id}": self.v3_url(f"findings/{finding_id}?expand=test.engagement"), # noqa: RUF027 - "/products": self.v3_url(f"products?{limit}&expand=product_type"), - "/products/{product_id}": self.v3_url(f"products/{product_id}"), # noqa: RUF027 - "/product_types": self.v3_url(f"product_types?{limit}"), - "/product_types/{product_type_id}": self.v3_url(f"product_types/{product_type_id}"), # noqa: RUF027 + "/assets": self.v3_url(f"assets?{limit}&expand=organization"), + "/assets/{asset_id}": self.v3_url(f"assets/{product_id}"), + "/organizations": self.v3_url(f"organizations?{limit}"), + "/organizations/{organization_id}": self.v3_url(f"organizations/{product_type_id}"), "/users": self.v3_url(f"users?{limit}"), "/users/{user_id}": self.v3_url(f"users/{user_id}"), # noqa: RUF027 "/engagements": self.v3_url(f"engagements?{limit}"), @@ -121,8 +121,8 @@ def _representative_requests(self) -> dict[str, str]: "/locations": self.v3_url(f"locations?{limit}"), "/locations/{location_id}": self.v3_url(f"locations/{Location.objects.order_by('pk').first().pk}"), "/findings/{finding_id}/locations": self.v3_url(f"findings/{self._loc_finding.pk}/locations?{limit}"), # noqa: RUF027 - "/products/{product_id}/locations": self.v3_url(f"products/{self._loc_product.pk}/locations?{limit}"), # noqa: RUF027 - # OS5 notes / files / tags sub-resources (finding/engagement/test; tags also on product). + "/assets/{asset_id}/locations": self.v3_url(f"assets/{self._loc_product.pk}/locations?{limit}"), + # OS5 notes / files / tags sub-resources (finding/engagement/test; tags also on asset). "/findings/{parent_id}/notes": self.v3_url(f"findings/{self._sub_finding.pk}/notes?{limit}"), "/engagements/{parent_id}/notes": self.v3_url(f"engagements/{self._sub_engagement.pk}/notes?{limit}"), "/tests/{parent_id}/notes": self.v3_url(f"tests/{self._sub_test.pk}/notes?{limit}"), @@ -135,7 +135,7 @@ def _representative_requests(self) -> dict[str, str]: "/findings/{parent_id}/tags": self.v3_url(f"findings/{self._sub_finding.pk}/tags"), "/engagements/{parent_id}/tags": self.v3_url(f"engagements/{self._sub_engagement.pk}/tags"), "/tests/{parent_id}/tags": self.v3_url(f"tests/{self._sub_test.pk}/tags"), - "/products/{parent_id}/tags": self.v3_url(f"products/{self._loc_product.pk}/tags"), + "/assets/{parent_id}/tags": self.v3_url(f"assets/{self._loc_product.pk}/tags"), } def _openapi_get_paths(self) -> set[str]: diff --git a/unittests/api_v3/test_apiv3_subresources.py b/unittests/api_v3/test_apiv3_subresources.py index 291c699cfc5..cd2288880b2 100644 --- a/unittests/api_v3/test_apiv3_subresources.py +++ b/unittests/api_v3/test_apiv3_subresources.py @@ -1,7 +1,7 @@ """ Generic notes / tags / files sub-resource tests for API v3 (§4.12, OS5). -Covers the storage support matrix (notes/files: finding/engagement/test; tags: those + product), +Covers the storage support matrix (notes/files: finding/engagement/test; tags: those + asset), note privacy (v2 parity: private notes are returned, not per-user filtered), parent-authorization inheritance (404 unknown-or-unauthorized parent, 403 write), multipart upload + streamed download roundtrip, tag replace/append/delete semantics + normalization, the pagination envelope on the @@ -42,7 +42,7 @@ def setUp(self): ("engagements", self.engagement), ("tests", self.test), ] - self.tag_parents = [*self.note_file_parents, ("products", self.product)] + self.tag_parents = [*self.note_file_parents, ("assets", self.product)] class TestApiV3SubresourcesNotes(_SubResourceBase): @@ -180,8 +180,8 @@ def test_tags_get_shape_all_resources(self): self.assertIsInstance(body["tags"], list) def test_tags_replace_append_delete_semantics(self): - # Product tags have clean semantics (no inheritance-sticky re-add); inheritance is off by - # default anyway, but products are the crispest surface for the exact-set assertions. + # Asset tags have clean semantics (no inheritance-sticky re-add); inheritance is off by + # default anyway, but assets are the crispest surface for the exact-set assertions. for resource, parent in self.tag_parents: with self.subTest(resource=resource): base = self.v3_url(f"{resource}/{parent.pk}/tags") @@ -219,10 +219,10 @@ class TestApiV3SubresourcesSupportMatrix(_SubResourceBase): def test_unsupported_notes_and_files_are_404(self): pt_id = self.product.prod_type_id for path in ( - f"product_types/{pt_id}/notes", - f"product_types/{pt_id}/files", - f"products/{self.product.pk}/notes", # product has tags but no notes/files - f"products/{self.product.pk}/files", + f"organizations/{pt_id}/notes", + f"organizations/{pt_id}/files", + f"assets/{self.product.pk}/notes", # asset has tags but no notes/files + f"assets/{self.product.pk}/files", f"users/{self.admin.pk}/notes", f"users/{self.admin.pk}/files", ): @@ -230,13 +230,13 @@ def test_unsupported_notes_and_files_are_404(self): self.assertEqual(404, self.client.get(self.v3_url(path)).status_code) def test_unsupported_tags_are_404(self): - # product_type / user have no TagField; location has one but is read-only + superuser-only - # with no v2 tag-mutation endpoint, so no tag sub-resource is attached (tags[] is on its - # read shape instead). + # organization (product_type) / user have no TagField; location has one but is read-only + + # superuser-only with no v2 tag-mutation endpoint, so no tag sub-resource is attached (tags[] + # is on its read shape instead). from dojo.location.models import Location # noqa: PLC0415 location = Location.objects.first() for path in ( - f"product_types/{self.product.prod_type_id}/tags", + f"organizations/{self.product.prod_type_id}/tags", f"users/{self.admin.pk}/tags", *([f"locations/{location.pk}/tags"] if location else []), ): @@ -253,7 +253,7 @@ def test_unknown_parent_is_404(self): "findings/9999999/notes", "engagements/9999999/files", "tests/9999999/tags", - "products/9999999/tags", + "assets/9999999/tags", ): with self.subTest(path=path): self.assertEqual(404, self.client.get(self.v3_url(path)).status_code) diff --git a/unittests/api_v3/test_apiv3_tests.py b/unittests/api_v3/test_apiv3_tests.py index 6f7f73d9c5e..609fd4cdc65 100644 --- a/unittests/api_v3/test_apiv3_tests.py +++ b/unittests/api_v3/test_apiv3_tests.py @@ -13,7 +13,7 @@ from .base import ApiV3TestCase _SLIM_KEYS = { - "id", "name", "test_type", "engagement", "product", "product_type", "environment", + "id", "name", "test_type", "engagement", "asset", "organization", "environment", "lead", "target_start", "target_end", "percent_complete", "tags", "created", "updated", } @@ -111,10 +111,10 @@ def _query_count(self, params: dict) -> int: def test_query_count_is_independent_of_row_count(self): self._bulk(10, 0) first = self._query_count({"limit": 250}) - first_expand = self._query_count({"limit": 250, "expand": "engagement.product,test_type"}) + first_expand = self._query_count({"limit": 250, "expand": "engagement.asset,test_type"}) self._bulk(90, 100) second = self._query_count({"limit": 250}) - second_expand = self._query_count({"limit": 250, "expand": "engagement.product,test_type"}) + second_expand = self._query_count({"limit": 250, "expand": "engagement.asset,test_type"}) self.assertEqual(first, second, f"query count grew (no expand): {first} -> {second}") self.assertEqual(first_expand, second_expand, f"query count grew (expand): {first_expand} -> {second_expand}") From 7ae9fbb292c1e79bad00e2ff4468659cea9e0c6b Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 21:29:11 +0200 Subject: [PATCH 13/37] docs(api-v3): explain DRF boundary adapter + hand-declared DTO rationale - errors.py: comment on why DRF constants exist in a ninja API (v3 reuses v2 helpers/services that raise DRF exceptions; the adapter maps them onto the closed problem+json contract) - plan D3: record why schemas are hand-declared DTOs, never ModelSchema-derived (auto-derivation recreates v2's heavy-by-default disease); Slim/Detail/Write/Update roles; idiomatic ninja/FastAPI - plan backlog: port v2 endpoint-level test corpora via dual-endpoint adapter (import/reimport scenarios first, then JIRA push flows) --- dojo/api_v3/errors.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dojo/api_v3/errors.py b/dojo/api_v3/errors.py index c89e7ae52c9..40723f7f984 100644 --- a/dojo/api_v3/errors.py +++ b/dojo/api_v3/errors.py @@ -195,6 +195,13 @@ def _handle_not_found(request: HttpRequest, exc: Http404) -> JsonResponse: ) +# Why DRF constants in a ninja API: v3 deliberately REUSES v2's battle-tested internals -- the +# import permission helpers, check_auto_create_permission, and dojo/finding/services.py all raise +# rest_framework exceptions (the finding services do so intentionally: it is the exact exception +# type the reference v2 serializer raises, D7). Those are DRF APIException subclasses; without the +# adapter below they would surface as 500s instead of problem+json. These two maps translate a DRF +# exception's status code onto the closed v3 error contract (I9). DRF itself stays installed +# regardless -- all of v2 runs on it and v3's tokens live in rest_framework.authtoken. _DRF_ERROR_TYPES = {400: "validation", 401: "unauthorized", 403: "forbidden", 404: "not-found"} _DRF_ERROR_TITLES = { 400: "Validation failed", From 8caef117acd23aee11c6d9b1587439a25dadf558 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 19 Jul 2026 22:00:44 +0200 Subject: [PATCH 14/37] test(api-v3): deny-by-default authz sweep + structural tripwire Two guards ensuring a v3 endpoint can never ship without authorization (enforces invariant I8 structurally): - test_apiv3_authz_sweep.py: probes all 66 OpenAPI operations as anonymous (must 401) and as a zero-permission user (must never receive data: empty RBAC-scoped lists, 404/403 on details+writes). Write probes carry minimal valid payloads from a completeness-gated registry so they genuinely reach the authz gate - a validation 400 counts as sweep failure. Any future endpoint fails the completeness gate until probed. Sanity test proves empty lists are RBAC-empty, not fixture-empty. - test_apiv3_authz_static.py: source-scan over the route layer - bans direct .objects. access outside a justified 5-entry allowlist (which fails on stale entries) and AST-verifies every route operation references an authz primitive (one module-local helper deep). Audit result: ZERO authorization gaps found - all 66 operations already deny by default. Also: filter-contract test docstring now explains why the vocabulary is a tested contract (silent-typo'd-filter failure mode, D6 one-vocabulary-many-projections, DD_API_V3_UPDATE_SNAPSHOTS workflow). 284 tests green (+2 CI-excluded). --- unittests/api_v3/test_apiv3_authz_static.py | 273 +++++++++++++ unittests/api_v3/test_apiv3_authz_sweep.py | 377 ++++++++++++++++++ .../api_v3/test_apiv3_filter_contract.py | 33 +- 3 files changed, 681 insertions(+), 2 deletions(-) create mode 100644 unittests/api_v3/test_apiv3_authz_static.py create mode 100644 unittests/api_v3/test_apiv3_authz_sweep.py diff --git a/unittests/api_v3/test_apiv3_authz_static.py b/unittests/api_v3/test_apiv3_authz_static.py new file mode 100644 index 00000000000..b8c70bc0ad4 --- /dev/null +++ b/unittests/api_v3/test_apiv3_authz_static.py @@ -0,0 +1,273 @@ +""" +Structural authorization tripwire for API v3 (§5 I8, §10). + +A **source-scan** (no DB, no HTTP) over the v3 route layer that converts "someone forgot the RBAC +check" from a silent runtime hole into a compile-time-ish failure with a named location. It +complements the behavioural sweep (``test_apiv3_authz_sweep.py``): the sweep proves the *current* +routes deny by default; this test proves every route is *structurally wired* to an authorization +primitive, so a new route that skips the check fails CI even before anyone writes a request for it. + +Scanned files: ``dojo/*/api_v3/routes.py`` (every resource) + ``dojo/api_v3/import_routes.py`` + +``dojo/api_v3/subresources.py``. + +Two rules: + +**Rule A -- ban direct model-manager access.** Any source line containing ``.objects.`` fails unless +its ``Model.objects.method`` pattern is in ``OBJECTS_ALLOWLIST`` for that file, each entry carrying a +one-line justification. Reads MUST flow through the ``get_authorized_*`` querysets (I8); the only +legitimate ``.objects.`` uses are (a) edge reads from an already-authorized parent, (b) writes / +uniqueness pre-checks that run *after* an authorization gate, and (c) the users self-scope queryset +which *is* the RBAC mechanism. The allowlist is audited, never blanket -- and stale entries (allowing +a pattern that no longer occurs) fail too, so it cannot rot into a rubber stamp. + +**Rule B -- require an authorization reference.** (1) Every scanned file must reference at least one +authorization primitive. (2) Every function *registered as a route operation* (decorated with +``@router.`` or registered via ``router.(path, ...)(fn)``) must reference an authorization +primitive **in its own body, or in a helper it calls within the same module** (one level of +indirection -- ``_base_queryset`` / ``_detail_object`` / ``_require`` / the import ``_resolve_*`` / +``_check_auto_permission`` helpers count). Failures name ``file::function``. + +The authorization primitives (``AUTHZ_TOKENS``) are this codebase's real RBAC vocabulary: the +``get_authorized_*`` queryset helpers (``dojo//queries.py``), the ``user_has_*`` permission +helpers (``dojo/authorization/authorization.py`` -- object / global / configuration), the route-local +``_require*`` gate helpers, and the ``is_superuser`` gate that faithfully mirrors v2's ``IsSuperUser`` +class (§12 OS4). +""" +from __future__ import annotations + +import ast +from pathlib import Path + +from django.test import SimpleTestCase + +# --- what to scan ----------------------------------------------------------------------------- +# Repo root: unittests/api_v3/ -> parents[2]. +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _scanned_files() -> list[Path]: + """Every v3 route module (resources are globbed so a new resource is auto-included).""" + files = sorted((_REPO_ROOT / "dojo").glob("*/api_v3/routes.py")) + files.append(_REPO_ROOT / "dojo" / "api_v3" / "import_routes.py") + files.append(_REPO_ROOT / "dojo" / "api_v3" / "subresources.py") + return files + + +# --- Rule A: the audited .objects. allowlist -------------------------------------------------- +# Keys are (repo-relative posix path, "Model.objects.method"). Every entry was verified by reading +# the code: each such call either reads from an ALREADY-authorized parent, or runs a write / +# uniqueness check AFTER an authorization gate, or IS the RBAC scoping itself. Nothing here is an +# unauthorized object read. A ".objects." line whose pattern is absent here fails the test. +OBJECTS_ALLOWLIST: dict[tuple[str, str], str] = { + ("dojo/location/api_v3/routes.py", "LocationFindingReference.objects.filter"): + "edge rows read from a parent finding ALREADY resolved via get_authorized_findings " + "(parent-inherited authz); mirrors the v2 LocationFindingReference viewset.", + ("dojo/location/api_v3/routes.py", "LocationProductReference.objects.filter"): + "edge rows read from a parent asset ALREADY resolved via get_authorized_products " + "(parent-inherited authz); mirrors the v2 LocationProductReference viewset.", + ("dojo/api_v3/subresources.py", "NoteHistory.objects.create"): + "writes the NoteHistory row for a note just created on a parent that was authorized and " + "permission-checked; a write of an owned sub-object, not an object read.", + ("dojo/api_v3/subresources.py", "FileUpload.objects.filter"): + "global title-uniqueness pre-check (mirrors DRF UniqueValidator -> 400) AFTER the parent " + "authz + add-permission gate; not object disclosure.", + ("dojo/user/api_v3/routes.py", "Dojo_User.objects.filter"): + "the RBAC scoping itself: the self-only fallback queryset for users lacking auth.view_user " + "(line 100) and the username-uniqueness check AFTER the auth.add_user gate (line 181).", +} + +# --- Rule B: authorization primitives --------------------------------------------------------- +# Substrings that mark an authorization reference. `user_has_` covers the object / global / +# configuration permission helpers; `get_authorized_` the RBAC querysets; `_require` the route-local +# gate helpers (_require / _require_permission / _require_config_permission); `is_superuser` the +# faithful v2 IsSuperUser mirror (§12 OS4). +AUTHZ_TOKENS = ("get_authorized_", "user_has_", "_require", "is_superuser") + +_ROUTE_VERBS = {"get", "post", "put", "patch", "delete"} + + +def _rel(path: Path) -> str: + return path.relative_to(_REPO_ROOT).as_posix() + + +def _is_router_verb_attr(node: ast.AST) -> bool: + """True for an attribute access ``router.`` (get/post/put/patch/delete).""" + return ( + isinstance(node, ast.Attribute) + and node.attr in _ROUTE_VERBS + and isinstance(node.value, ast.Name) + and node.value.id == "router" + ) + + +def _is_router_decorator(dec: ast.AST) -> bool: + """True for ``@router.(...)``.""" + return isinstance(dec, ast.Call) and _is_router_verb_attr(dec.func) + + +def _own_nodes(func: ast.FunctionDef): + """ + Yield the descendant nodes of ``func``'s body WITHOUT descending into nested function/lambda + definitions -- so a helper is credited only for authorization references in its *own* logic, + never for tokens that live in a closure it merely encloses. + """ + stack = list(func.body) + while stack: + node = stack.pop() + yield node + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + continue + stack.append(child) + + +def _own_identifiers(func: ast.FunctionDef) -> set[str]: + """All ``Name``/``Attribute`` identifiers used directly in ``func``'s own body.""" + idents: set[str] = set() + for node in _own_nodes(func): + if isinstance(node, ast.Name): + idents.add(node.id) + elif isinstance(node, ast.Attribute): + idents.add(node.attr) + return idents + + +def _own_call_targets(func: ast.FunctionDef) -> set[str]: + """Names of same-module functions ``func`` calls directly (for one-level indirection).""" + targets: set[str] = set() + for node in _own_nodes(func): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + targets.add(node.func.id) + return targets + + +def _has_authz_token(idents: set[str]) -> bool: + return any(any(tok in ident for tok in AUTHZ_TOKENS) for ident in idents) + + +def _manually_registered_names(tree: ast.AST) -> set[str]: + """ + Function names registered via ``router.(path, ...)(fn)`` (subresources.py registers its + handlers manually so each gets a unique __name__ / operationId). The outer node is a Call whose + ``func`` is itself the ``router.(...)`` Call; its positional Name args are the handlers. + """ + names: set[str] = set() + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Call) + and _is_router_verb_attr(node.func.func) + ): + names.update(arg.id for arg in node.args if isinstance(arg, ast.Name)) + return names + + +def _route_operations(tree: ast.AST) -> list[ast.FunctionDef]: + """Every function registered as a route operation (decorated OR manually registered).""" + registered = _manually_registered_names(tree) + ops: list[ast.FunctionDef] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + if any(_is_router_decorator(dec) for dec in node.decorator_list) or node.name in registered: + ops.append(node) + return ops + + +def _authz_helper_names(tree: ast.AST) -> set[str]: + """Same-module functions whose OWN body references an authorization primitive.""" + return { + node.name + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and _has_authz_token(_own_identifiers(node)) + } + + +class TestApiV3AuthzStatic(SimpleTestCase): + + """Source-scan tripwire: no raw manager access; every route operation is authz-wired.""" + + def test_scanned_files_exist(self): + """Guard the glob: the resource route modules must actually be found and scanned.""" + files = _scanned_files() + rels = {_rel(f) for f in files} + self.assertTrue(all(f.exists() for f in files), f"missing scanned file(s): {sorted(rels)}") + # A representative subset must be present (catches a broken glob / moved module). + for expected in ( + "dojo/finding/api_v3/routes.py", + "dojo/location/api_v3/routes.py", + "dojo/api_v3/import_routes.py", + "dojo/api_v3/subresources.py", + ): + self.assertIn(expected, rels, f"{expected} not in the scan set -- fix _scanned_files()") + + def test_no_unjustified_objects_manager_access(self): + """Rule A: every ``.objects.`` line must be in the audited allowlist (with a justification).""" + failures: list[str] = [] + seen_allowlist_keys: set[tuple[str, str]] = set() + for path in _scanned_files(): + rel = _rel(path) + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if ".objects." not in line: + continue + pattern = self._objects_pattern(line) + key = (rel, pattern) + if key in OBJECTS_ALLOWLIST: + seen_allowlist_keys.add(key) + continue + failures.append( + f"{rel}:{lineno}: direct model-manager access `{pattern}` -- reads must go " + f"through get_authorized_* (I8). If this is a genuine exception, add " + f"('{rel}', '{pattern}') to OBJECTS_ALLOWLIST with a one-line justification.", + ) + self.assertFalse(failures, "unjustified .objects. access:\n" + "\n".join(failures)) + + # Keep the allowlist honest: no entry may allow a pattern that no longer occurs. + stale = set(OBJECTS_ALLOWLIST) - seen_allowlist_keys + self.assertFalse( + stale, + f"stale OBJECTS_ALLOWLIST entr(y/ies) that match nothing anymore -- remove: {sorted(stale)}", + ) + + def test_every_file_references_an_authz_primitive(self): + """Rule B(1): each scanned file references at least one authorization primitive.""" + failures: list[str] = [] + for path in _scanned_files(): + source = path.read_text(encoding="utf-8") + if not any(tok in source for tok in AUTHZ_TOKENS): + failures.append(f"{_rel(path)}: no authorization primitive {AUTHZ_TOKENS} anywhere in file") + self.assertFalse(failures, "file with no authorization reference:\n" + "\n".join(failures)) + + def test_every_route_operation_is_authz_wired(self): + """Rule B(2): every route operation references an authz primitive directly or one helper deep.""" + failures: list[str] = [] + for path in _scanned_files(): + rel = _rel(path) + tree = ast.parse(path.read_text(encoding="utf-8")) + authz_helpers = _authz_helper_names(tree) + operations = _route_operations(tree) + # Sanity: the heuristic must find at least one route operation per file, otherwise a + # broken detector would vacuously "pass" the whole file. + self.assertTrue( + operations, + f"{rel}: no route operation detected -- the @router. / router.(...)(fn) " + f"heuristic may be broken (or the file no longer defines routes).", + ) + for op in operations: + direct = _has_authz_token(_own_identifiers(op)) + indirect = bool(_own_call_targets(op) & authz_helpers) + if not (direct or indirect): + failures.append( + f"{rel}::{op.name} (line {op.lineno}): route operation has no authorization " + f"reference in its body or in a same-module helper it calls -- it must flow " + f"through get_authorized_* / user_has_* / _require* / is_superuser (I8).", + ) + self.assertFalse(failures, "route operation missing an authorization check:\n" + "\n".join(failures)) + + @staticmethod + def _objects_pattern(line: str) -> str: + """Extract the ``Model.objects.method`` token from a line (fallback: the stripped line).""" + import re # noqa: PLC0415 -- localized to this helper + + match = re.search(r"(\w+)\.objects\.(\w+)", line) + return f"{match.group(1)}.objects.{match.group(2)}" if match else line.strip() diff --git a/unittests/api_v3/test_apiv3_authz_sweep.py b/unittests/api_v3/test_apiv3_authz_sweep.py new file mode 100644 index 00000000000..95c0201cadb --- /dev/null +++ b/unittests/api_v3/test_apiv3_authz_sweep.py @@ -0,0 +1,377 @@ +""" +Deny-by-default authorization sweep for API v3 (§5 I8, §10). + +Enforces the RBAC invariant (I8) *structurally* across the **whole mounted surface**: every +operation in ``api_v3.get_openapi_schema()`` -- all methods, all paths -- is probed twice, and a +completeness gate (identical in spirit to ``test_apiv3_query_report.py``) fails the moment an +operation exists in the schema without a registered probe, so the sweep can never silently fall +behind a new endpoint. + +Two probes per operation: + +1. **Anonymous** -> must be **401**. Ninja runs authentication in ``Operation._run_checks`` *before* + it parses path/query/body (``_get_values``), so an anonymous request never reaches request + validation and needs no payload (confirmed by ``test_apiv3_auth``: anonymous GET -> 401 problem+ + json, v3 is in ``LOGIN_EXEMPT_URLS`` so there is no login redirect). + +2. **Zero-permission user** -- a freshly created, authenticated user with **no** memberships, roles + or configuration permissions -- must **NEVER receive data**: + + * ``GET`` list -> 200 with ``count == 0`` / ``results == []`` (RBAC-scoped empty); + ``/findings`` additionally checked with ``?include=counts`` (all totals must be 0); + ``/locations`` is the superuser-gated exception -> **403**; + ``/users`` is the documented self-visibility exception -> 200 with **exactly the caller's own + record** (§12 OS3a: a user always sees themselves and nobody else -- not a leak). + * ``GET`` detail / sub-resource ``GET`` -> **404** (unknown-or-unauthorized, never leak + existence, §4.10); ``/locations/{id}`` -> **403** (superuser gate). + * writes (POST/PATCH/PUT/DELETE) -> **403 or 404**. Each write probe carries a *minimal valid + payload* referencing fixture ids that exist but are NOT authorized for the zero-perm user, so + it gets past ninja request-validation and genuinely reaches the authorization gate. **A + 400/422 on a write probe is a sweep FAILURE** -- it means validation, not authz, produced the + denial (the gate was never exercised). The registry below reaches the gate for every write, so + there are zero validation-blocked probes. + +Because the whole thing is registry-driven and gated for completeness, any future endpoint fails +``test_sweep_covers_every_operation`` until a probe (request + expected outcome) is added for it. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile + +from dojo.api_v3.api import api_v3 +from dojo.models import ( + Dojo_User, + Engagement, + Finding, + Product, + Product_Type, + Test, + Test_Type, +) + +from .base import ApiV3TestCase + +if TYPE_CHECKING: + from rest_framework.test import APIClient + +_HTTP_METHODS = {"get", "post", "put", "patch", "delete"} + + +@dataclass +class Probe: + + """One operation's representative request + its expected deny outcomes.""" + + method: str # HTTP verb, upper-case + path: str # OpenAPI mount-relative template, e.g. "/findings/{finding_id}" + url: str # concrete request URL (fixture ids substituted) + zero_status: int # expected status for the zero-permission user + payload: object = None # request body for the zero-perm write probe (dict) or None + fmt: str = "json" # "json" | "multipart" + list_kind: str | None = None # "empty" | "findings" | "users_self" -> zero-perm list assertion + note: str = "" # documentation / justification for the chosen outcome + + @property + def key(self) -> tuple[str, str]: + return (self.method, self.path) + + +class TestApiV3AuthzSweep(ApiV3TestCase): + + """Whole-surface deny-by-default sweep: anonymous -> 401, zero-perm -> never any data.""" + + def setUp(self): + super().setUp() + # A brand-new authenticated user with zero authorization: no product/type membership, no + # role, no configuration permission. Mirrors the "limited"/"member-before-grant" users the + # other v3 RBAC tests rely on (test_apiv3_finding_writes / test_apiv3_expand_rbac), which + # confirm a freshly created user sees nothing. + self.zero = Dojo_User.objects.create_user(username="v3_authz_zero", password="x") # noqa: S106 + self.zero_client: APIClient = self.token_client(user=self.zero) + + # Fixture objects the zero-perm user is NOT authorized for. They exist (so the write probes + # reach the authorization gate rather than 404-ing on a non-existent id where the outcome + # would be ambiguous) but are invisible to the zero-perm user's authorized querysets. + self.finding = Finding.objects.first() + self.test = Test.objects.first() + self.engagement = Engagement.objects.first() + self.asset = Product.objects.first() + self.organization = Product_Type.objects.first() + self.test_type = Test_Type.objects.first() + + # --- schema introspection ----------------------------------------------------------------- + def _schema_operations(self) -> set[tuple[str, str]]: + """Every (METHOD, mount-relative path) in the live OpenAPI schema (the completeness set).""" + schema = api_v3.get_openapi_schema() + ops: set[tuple[str, str]] = set() + for path, item in schema["paths"].items(): + # Schema paths carry the mount prefix (/api/v3-alpha/...); compare mount-relative, + # exactly as test_apiv3_query_report does. + rel = "/" + path.split(settings.API_V3_URL_PREFIX, 1)[-1].lstrip("/") + for method in item: + if method.lower() in _HTTP_METHODS: + ops.add((method.upper(), rel)) + return ops + + # --- probe registry ----------------------------------------------------------------------- + def _file_payload(self) -> dict: + """Fresh multipart body for a file/import write probe (a tiny scan file).""" + return { + "title": "authz-sweep-file", + "file": SimpleUploadedFile("authz-sweep.txt", b"authz sweep body", content_type="text/plain"), + } + + def _sub_probes(self, resource: str, parent_id: int, *, notes: bool, files: bool, tags: bool) -> list[Probe]: + """ + Sub-resource probes (§4.12). Every one is parent-inherited: the zero-perm user cannot see + the parent, so the parent resolves to None through its authorized-view queryset -> 404, + before any note/file/tag is touched (never leak existence, never mutate). + """ + base = f"/{resource}/{{parent_id}}" + probes: list[Probe] = [] + if notes: + probes += [ + Probe("GET", f"{base}/notes", self.v3_url(f"{resource}/{parent_id}/notes"), 404), + Probe("POST", f"{base}/notes", self.v3_url(f"{resource}/{parent_id}/notes"), 404, + payload={"entry": "authz sweep"}), + ] + if files: + probes += [ + Probe("GET", f"{base}/files", self.v3_url(f"{resource}/{parent_id}/files"), 404), + Probe("POST", f"{base}/files", self.v3_url(f"{resource}/{parent_id}/files"), 404, + payload=self._file_payload(), fmt="multipart"), + # file_id is irrelevant: parent-view 404 fires before the file lookup. + Probe("GET", f"{base}/files/{{file_id}}/download", + self.v3_url(f"{resource}/{parent_id}/files/1/download"), 404), + ] + if tags: + probes += [ + Probe("GET", f"{base}/tags", self.v3_url(f"{resource}/{parent_id}/tags"), 404), + Probe("PUT", f"{base}/tags", self.v3_url(f"{resource}/{parent_id}/tags"), 404, + payload={"tags": ["authz"]}), + Probe("POST", f"{base}/tags", self.v3_url(f"{resource}/{parent_id}/tags"), 404, + payload={"tags": ["authz"]}), + Probe("DELETE", f"{base}/tags/{{tag}}", + self.v3_url(f"{resource}/{parent_id}/tags/authz"), 404), + ] + return probes + + def _probes(self) -> list[Probe]: + f, e, t = self.finding.id, self.engagement.id, self.test.id + a, o, tt = self.asset.id, self.organization.id, self.test_type.id + admin_id = self.admin.id + p: list[Probe] = [] + + # ---- findings (list supports include=counts) ----------------------------------------- + p += [ + Probe("GET", "/findings", self.v3_url("findings"), 200, list_kind="findings"), + Probe("GET", "/findings/{finding_id}", self.v3_url(f"findings/{f}"), 404), + Probe("POST", "/findings", self.v3_url("findings"), 403, + payload={"test": t, "title": "authz sweep", "severity": "High", + "description": "x", "active": True, "verified": False}, + note="existing but unauthorized test -> Finding_Add gate -> 403"), + Probe("PATCH", "/findings/{finding_id}", self.v3_url(f"findings/{f}"), 404, + payload={"severity": "Low"}, note="finding invisible to zero-perm queryset -> 404"), + Probe("DELETE", "/findings/{finding_id}", self.v3_url(f"findings/{f}"), 404), + Probe("GET", "/findings/{finding_id}/locations", self.v3_url(f"findings/{f}/locations"), 404), + ] + p += self._sub_probes("findings", f, notes=True, files=True, tags=True) + + # ---- organizations (product_type) ---------------------------------------------------- + p += [ + Probe("GET", "/organizations", self.v3_url("organizations"), 200, list_kind="empty"), + Probe("GET", "/organizations/{organization_id}", self.v3_url(f"organizations/{o}"), 404), + Probe("POST", "/organizations", self.v3_url("organizations"), 403, + payload={"name": "authz sweep org"}, + note="no global add permission -> 403"), + Probe("PATCH", "/organizations/{organization_id}", self.v3_url(f"organizations/{o}"), 404, + payload={"name": "x"}), + Probe("DELETE", "/organizations/{organization_id}", self.v3_url(f"organizations/{o}"), 404), + ] + + # ---- assets (product) ---------------------------------------------------------------- + p += [ + Probe("GET", "/assets", self.v3_url("assets"), 200, list_kind="empty"), + Probe("GET", "/assets/{asset_id}", self.v3_url(f"assets/{a}"), 404), + Probe("POST", "/assets", self.v3_url("assets"), 403, + payload={"name": "authz sweep asset", "description": "x", "organization": o}, + note="existing but unauthorized organization -> Product_Type_Add_Product gate -> 403"), + Probe("PATCH", "/assets/{asset_id}", self.v3_url(f"assets/{a}"), 404, payload={"name": "x"}), + Probe("DELETE", "/assets/{asset_id}", self.v3_url(f"assets/{a}"), 404), + Probe("GET", "/assets/{asset_id}/locations", self.v3_url(f"assets/{a}/locations"), 404), + ] + p += self._sub_probes("assets", a, notes=False, files=False, tags=True) + + # ---- engagements --------------------------------------------------------------------- + p += [ + Probe("GET", "/engagements", self.v3_url("engagements"), 200, list_kind="empty"), + Probe("GET", "/engagements/{engagement_id}", self.v3_url(f"engagements/{e}"), 404), + Probe("POST", "/engagements", self.v3_url("engagements"), 403, + payload={"asset": a, "target_start": "2026-01-01", "target_end": "2026-01-02"}, + note="existing but unauthorized asset -> Engagement_Add gate -> 403"), + Probe("PATCH", "/engagements/{engagement_id}", self.v3_url(f"engagements/{e}"), 404, + payload={"name": "x"}), + Probe("DELETE", "/engagements/{engagement_id}", self.v3_url(f"engagements/{e}"), 404), + ] + p += self._sub_probes("engagements", e, notes=True, files=True, tags=True) + + # ---- tests --------------------------------------------------------------------------- + p += [ + Probe("GET", "/tests", self.v3_url("tests"), 200, list_kind="empty"), + Probe("GET", "/tests/{test_id}", self.v3_url(f"tests/{t}"), 404), + Probe("POST", "/tests", self.v3_url("tests"), 403, + payload={"engagement": e, "test_type": tt, + "target_start": "2026-01-01T00:00:00Z", "target_end": "2026-01-02T00:00:00Z"}, + note="existing but unauthorized engagement -> Test_Add gate -> 403"), + Probe("PATCH", "/tests/{test_id}", self.v3_url(f"tests/{t}"), 404, payload={"title": "x"}), + Probe("DELETE", "/tests/{test_id}", self.v3_url(f"tests/{t}"), 404), + ] + p += self._sub_probes("tests", t, notes=True, files=True, tags=True) + + # ---- users (self-visibility exception on list/detail; writes admin-only) ------------- + p += [ + Probe("GET", "/users", self.v3_url("users"), 200, list_kind="users_self", + note="a user always sees exactly their own record and no other (§12 OS3a) -- not a leak"), + # Target the admin's id: invisible to the zero-perm user's self-only queryset -> 404. + Probe("GET", "/users/{user_id}", self.v3_url(f"users/{admin_id}"), 404), + Probe("POST", "/users", self.v3_url("users"), 403, + payload={"username": "authz_sweep_new", "email": "authz@example.com"}, + note="no auth.add_user configuration permission -> 403"), + Probe("PATCH", "/users/{user_id}", self.v3_url(f"users/{admin_id}"), 404, + payload={"first_name": "x"}, note="admin invisible to zero-perm self-scope -> 404"), + Probe("DELETE", "/users/{user_id}", self.v3_url(f"users/{admin_id}"), 404), + ] + + # ---- locations (superuser-gated: 403 before any object lookup) ----------------------- + p += [ + Probe("GET", "/locations", self.v3_url("locations"), 403, + note="v2 LocationViewSet is IsSuperUser -> mirrored 403 for non-superusers (§12 OS4)"), + Probe("GET", "/locations/{location_id}", self.v3_url("locations/1"), 403, + note="superuser gate fires before the id lookup"), + ] + + # ---- consolidated import ------------------------------------------------------------- + import_payload = self._file_payload() + import_payload.update({"scan_type": "ZAP Scan", "mode": "import", "engagement": e}) + p += [ + Probe("POST", "/import", self.v3_url("import"), 403, + payload=import_payload, fmt="multipart", + note="mode=import + existing but unauthorized engagement -> UserHasImportPermission -> 403"), + ] + return p + + # --- dispatch ----------------------------------------------------------------------------- + def _dispatch(self, client: APIClient, probe: Probe, *, with_payload: bool): + fn = getattr(client, probe.method.lower()) + if with_payload and probe.payload is not None: + return fn(probe.url, probe.payload, format=probe.fmt) + return fn(probe.url) + + def _assert_zero_list(self, probe: Probe, response) -> None: + body = response.json() + label = f"{probe.method} {probe.path}" + if probe.list_kind == "users_self": + # The one documented deviation: a zero-perm user sees exactly their own record and no + # other user's data. Anything else (count 0, or a foreign row) would be a regression. + self.assertEqual(1, body["count"], f"{label}: users list must return exactly the caller's own record") + self.assertEqual( + [self.zero.id], [row["id"] for row in body["results"]], + f"{label}: users list leaked a record other than the caller's own", + ) + return + # Every other list must be RBAC-scoped empty for a zero-permission user. + self.assertEqual(0, body["count"], f"{label}: expected an empty (RBAC-scoped) list, got count={body['count']}") + self.assertEqual([], body["results"], f"{label}: expected results == [] for a zero-permission user") + if probe.list_kind == "findings": + # include=counts must aggregate over the (empty) authorized queryset -> all zero. + counts = self.get_json( + "findings", client=self.zero_client, data={"include": "counts"}, + )["meta"]["counts"] + self.assertEqual(0, counts["total"], f"{label}: include=counts total must be 0 for a zero-perm user") + for sev, n in counts["severity"].items(): + self.assertEqual(0, n, f"{label}: include=counts severity[{sev}] must be 0, got {n}") + + # --- tests -------------------------------------------------------------------------------- + def test_sweep_covers_every_operation(self): + """Completeness gate: every schema operation must have exactly one registered probe.""" + registered = {probe.key for probe in self._probes()} + schema_ops = self._schema_operations() + + missing = schema_ops - registered + self.assertFalse( + missing, + f"operation(s) {sorted(missing)} have no entry in the authorization sweep -- add a " + f"representative request + expected outcome to _probes() (deliberate: every new " + f"endpoint must be authorization-probed before it can ship).", + ) + extra = registered - schema_ops + self.assertFalse( + extra, + f"authorization sweep probes {sorted(extra)} no longer match any schema operation -- " + f"remove or fix the stale probe(s).", + ) + # Guard against accidental duplicate probes silently masking a gap. + keys = [probe.key for probe in self._probes()] + self.assertEqual(len(keys), len(set(keys)), "duplicate probe keys in _probes()") + + def test_anonymous_is_401_on_every_operation(self): + """Probe (a): an unauthenticated request is 401 on every operation (auth before body parse).""" + failures = [] + for probe in self._probes(): + response = self._dispatch(self.anonymous_client(), probe, with_payload=False) + if response.status_code != 401: + failures.append(f"{probe.method} {probe.path} -> {response.status_code} (expected 401)") + self.assertFalse(failures, "anonymous requests must be 401:\n" + "\n".join(failures)) + + def test_zero_permission_user_never_receives_data(self): + """Probe (b): a fully authenticated but zero-permission user never receives/mutates data.""" + failures = [] + for probe in self._probes(): + response = self._dispatch(self.zero_client, probe, with_payload=True) + label = f"{probe.method} {probe.path}" + # A write probe that 400/422s never reached the authz gate -- that is a sweep failure. + if probe.method != "GET" and response.status_code in {400, 422}: + failures.append( + f"{label} -> {response.status_code}: write probe blocked at request VALIDATION, " + f"not authorization -- fix the probe payload so it reaches the authz gate " + f"(body: {response.content[:200]!r})", + ) + continue + if response.status_code != probe.zero_status: + failures.append( + f"{label} -> {response.status_code} (expected {probe.zero_status})" + f"{' [' + probe.note + ']' if probe.note else ''} " + f"body: {response.content[:200]!r}", + ) + continue + if response.status_code == 200 and probe.list_kind is not None: + self._assert_zero_list(probe, response) + self.assertFalse( + failures, + "zero-permission user received an unexpected outcome (potential authorization gap):\n" + + "\n".join(failures), + ) + + def test_zero_perm_lists_empty_but_admin_sees_data(self): + """ + Sanity contrast proving the zero-perm empties are *authorization*, not an empty DB: the + admin (superuser) sees rows on the same lists where the zero-perm user sees none. + """ + for resource in ("findings", "organizations", "assets", "engagements", "tests"): + admin_body = self.get_json(resource) + self.assertGreater( + admin_body["count"], 0, + f"fixture has no {resource} for the admin -- the empty-list contrast is meaningless", + ) + zero_body = self.get_json(resource, client=self.zero_client) + self.assertEqual(0, zero_body["count"], f"zero-perm user unexpectedly sees {resource}") + # Locations: admin (superuser) 200, zero-perm 403 (superuser gate). + self.get_json("locations") + self.get_json("locations", client=self.zero_client, expected=403) + # Users: admin sees many; zero-perm sees only itself. + self.assertGreater(self.get_json("users")["count"], 1) + self.assertEqual(1, self.get_json("users", client=self.zero_client)["count"]) diff --git a/unittests/api_v3/test_apiv3_filter_contract.py b/unittests/api_v3/test_apiv3_filter_contract.py index 3f86820aa62..337a6d3e7d7 100644 --- a/unittests/api_v3/test_apiv3_filter_contract.py +++ b/unittests/api_v3/test_apiv3_filter_contract.py @@ -6,6 +6,28 @@ fails on any drift, so a contract change can never land silently -- it must be an intentional snapshot update. +WHY the vocabulary is a locked, tested contract (not just a convenience test): + +1. **It closes a silent-failure mode.** v2 (django-filter / DRF) *ignores* query params it does not + recognise: ``GET /findings?severty=Critical`` (a typo for ``severity``) silently drops the filter + and returns EVERY finding, while the caller believes they are looking at criticals only -- a + dangerous illusion for a security product (you think you triaged all criticals; you triaged + nothing). v3 rejects any unknown param with a 400 ``filter`` problem+json (§12 OS2), which is only + safe if the accepted vocabulary is a *closed, reviewed set*. This snapshot is what keeps it closed: + the vocabulary cannot grow (or a typo-prone alias sneak in) without a visible, reviewed change. + +2. **One vocabulary drives many projections (D6).** The exact same params drive the list, the + ``?include=counts`` aggregate over the filtered/authorized queryset, and -- by design -- future + aggregation/chart endpoints, CSV/export, and the reserved ``POST //query`` saved-view + substrate. A param that silently changes meaning (or disappears) therefore doesn't break one + endpoint; it breaks persisted saved filters and dashboards months later, far from the change that + caused it. Pinning the vocabulary in one snapshot makes every consumer's contract move together. + +3. **The workflow makes drift reviewable.** Unintended drift fails CI here. A *deliberate* contract + change is made by regenerating the snapshot, so the change shows up as a reviewable diff in + ``snapshots/filters.json`` in the same PR -- the vocabulary change is reviewed as data, not buried + in a route diff. + Regenerate the snapshot deliberately with:: DD_API_V3_UPDATE_SNAPSHOTS=1 ./run-unittest.sh --test-case \\ @@ -51,8 +73,15 @@ def test_filter_contract_matches_snapshot(self): self.assertEqual( saved, current, - "contract drift -- update snapshot deliberately " - f"(set DD_API_V3_UPDATE_SNAPSHOTS=1 to regenerate {SNAPSHOT.name}).\n" + "API v3 filter-contract drift detected. The per-object filter vocabulary is a LOCKED, " + "tested contract (see this module's docstring): unknown params 400 in v3, so the accepted " + "set must stay closed, and the SAME params drive lists / include=counts / future " + "aggregations / saved views -- silent drift breaks persisted filters far from the change.\n" + " * If this change is UNINTENDED: revert it -- you have altered the filter/ordering " + "vocabulary.\n" + " * If this change is DELIBERATE: regenerate the snapshot so the vocabulary change lands " + f"as a reviewable diff in {SNAPSHOT.name}, by re-running with " + "DD_API_V3_UPDATE_SNAPSHOTS=1.\n" f"expected: {json.dumps(saved, sort_keys=True)}\n" f"actual: {json.dumps(current, sort_keys=True)}", ) From d0d53748411c0bfbdfdc42896e60af237bfbfe62 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 20 Jul 2026 21:31:43 +0200 Subject: [PATCH 15/37] feat(api-v3): ?fields= opts lists up into detail fields + defer optimization Part A - lists accept any Detail field via ?fields= (Jira-style): - GET /findings?fields=id,title,impact,references works on a list; default stays byte-identical slim (pinned by tests); unknown -> 400; applied uniformly to all seven resources - detail-only relation refs (mitigated_by, asset user roles, URL subtype) declare DETAIL_SELECT_RELATED - fixed join, never per-row Part B - defer() on list queries (architect-suggested): - default lists no longer fetch heavy detail columns from the DB at all (finding drops 9 columns incl. description/impact/references; engagement 15); a field named in ?fields= un-defers exactly that column - defer sets computed at runtime from model._meta.concrete_fields (never properties/relations/annotations) - defer over only(): only() requires enumerating every ref column across select_related paths and silently drops joins; defer composes safely (rationale in plan \$12) - serialize_list_row splices only requested detail fields onto the slim base, so unrequested deferred columns are never read - deferred-load N+1 structurally impossible - defer-proof tests assert deferred columns absent from the list SQL by default and present when requested; constant-query assertions in both modes; zero un-defer exceptions needed 296 tests green (+2 CI-excluded). --- api_v3_examples.md | 74 +++++++++++---- .../automation/api/api-v3-alpha-docs.md | 12 ++- dojo/api_v3/expand.py | 95 +++++++++++++++++++ dojo/engagement/api_v3/routes.py | 23 ++++- dojo/finding/api_v3/routes.py | 24 +++-- dojo/finding/api_v3/schemas.py | 5 + dojo/location/api_v3/routes.py | 24 ++++- dojo/location/api_v3/schemas.py | 6 ++ dojo/product/api_v3/routes.py | 23 ++++- dojo/product/api_v3/schemas.py | 8 ++ dojo/product_type/api_v3/routes.py | 24 ++++- dojo/test/api_v3/routes.py | 23 ++++- dojo/user/api_v3/routes.py | 25 +++-- unittests/api_v3/test_apiv3_engagements.py | 21 ++++ unittests/api_v3/test_apiv3_examples.py | 19 +++- unittests/api_v3/test_apiv3_findings.py | 82 ++++++++++++++++ .../api_v3/test_apiv3_findings_queries.py | 38 ++++++++ 17 files changed, 466 insertions(+), 60 deletions(-) diff --git a/api_v3_examples.md b/api_v3_examples.md index a0e215d11e5..ea6e406065f 100644 --- a/api_v3_examples.md +++ b/api_v3_examples.md @@ -2,7 +2,7 @@ > **Auto-generated, do not hand-edit.** Every request/response below was captured by `unittests/api_v3/test_apiv3_examples.py` (`DD_API_V3_EXAMPLES=1`, CI-excluded) making **real** in-process requests against the test fixture. Tokens are redacted; long lists are truncated to ~3 rows. Regenerate with the command in that file's docstring. -Captured: 2026-07-19T19:08:11.540226+00:00 +Captured: 2026-07-20T19:26:55.505452+00:00 ## Conventions (see `API_V3_PLAN.md` §4) @@ -11,7 +11,7 @@ Captured: 2026-07-19T19:08:11.540226+00:00 - **Envelope (§4.3):** every list is `{count, next, previous, results, meta?}` and nothing else (I1). `next`/`previous` are opaque URLs; default `limit=25`, max `250`. - **Refs (§4.4):** relations render as `{id, name}` (locations add `type`). Write payloads reference relations by integer id — the asymmetry is intentional (§4.11). - **`?expand=` (§4.6):** dotted paths swap refs for slim objects inline and drive the queryset (the real N+1 fix). Budget-guarded. -- **`?fields=` (§4.7):** comma-separated allowlist; `id` is always included. +- **`?fields=` (§4.7):** comma-separated allowlist; `id` is always included. On a list it may also request any detail field (a wider SELECT on one query, never per-row). - **`?include=counts` (§4.8):** adds aggregate totals to `meta` over the filtered, authorized queryset. - **Errors (§4.10):** RFC 9457 `application/problem+json` with a `fields` extension for validation errors. @@ -69,7 +69,7 @@ Authorization: Token "updated": null, "description": "test finding", "mitigation": "test mitigation", - "impact": "High", + "impact": "Unauthorized disclosure of customer data if exploited.", "steps_to_reproduce": null, "severity_justification": null, "references": "", @@ -182,7 +182,7 @@ Authorization: Token "updated": null, "description": "test finding", "mitigation": "test mitigation", - "impact": "High", + "impact": "Unauthorized disclosure of customer data if exploited.", "steps_to_reproduce": null, "severity_justification": null, "references": "", @@ -258,7 +258,7 @@ Authorization: Token "risk_accepted": false, "out_of_scope": false, "is_mitigated": false, - "date": "2026-07-19", + "date": "2026-07-20", "cwe": 0, "test": { "id": 3, @@ -282,8 +282,8 @@ Authorization: Token }, "locations_count": 0, "tags": [], - "created": "2026-07-19T19:08:10.759Z", - "updated": "2026-07-19T19:08:10.759Z" + "created": "2026-07-20T19:26:55.152Z", + "updated": "2026-07-20T19:26:55.152Z" }, { "id": 235, @@ -296,7 +296,7 @@ Authorization: Token "risk_accepted": false, "out_of_scope": false, "is_mitigated": false, - "date": "2026-07-19", + "date": "2026-07-20", "cwe": 0, "test": { "id": 3, @@ -320,8 +320,8 @@ Authorization: Token }, "locations_count": 0, "tags": [], - "created": "2026-07-19T19:08:10.759Z", - "updated": "2026-07-19T19:08:10.759Z" + "created": "2026-07-20T19:26:55.152Z", + "updated": "2026-07-20T19:26:55.152Z" } ] } @@ -445,6 +445,38 @@ Authorization: Token ``` +--- + +### Finding — GET list with `?fields=` opting into a detail field (`impact`) + +A list returns the slim shape by default. `?fields=` may name any **detail** field (here `impact`, normally only on the detail endpoint) and it is returned on the list with no second request (§4.7). Fields are row-columns, so this is a wider `SELECT` on the same single query — never a per-row cost; the default list defers these heavy columns entirely and requesting one un-defers exactly it. + +**Request** + +```http +GET /api/v3-alpha/findings?id__in=2&fields=id,title,severity,impact +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 2, + "title": "High Impact Test Finding", + "severity": "High", + "impact": "Unauthorized disclosure of customer data if exploited." + } + ] +} +``` + + --- ### Finding — POST a note (sub-resource) @@ -476,8 +508,8 @@ Content-Type: application/json }, "private": false, "edited": false, - "created": "2026-07-19T19:08:10.880Z", - "updated": "2026-07-19T19:08:10.880Z" + "created": "2026-07-20T19:26:55.268Z", + "updated": "2026-07-20T19:26:55.268Z" } ``` @@ -512,8 +544,8 @@ Authorization: Token }, "private": false, "edited": false, - "created": "2026-07-19T19:08:10.880Z", - "updated": "2026-07-19T19:08:10.880Z" + "created": "2026-07-20T19:26:55.268Z", + "updated": "2026-07-20T19:26:55.268Z" } ] } @@ -609,7 +641,7 @@ Content-Type: application/json "risk_accepted": false, "out_of_scope": false, "is_mitigated": false, - "date": "2026-07-19", + "date": "2026-07-20", "cwe": 0, "test": { "id": 3, @@ -633,8 +665,8 @@ Content-Type: application/json }, "locations_count": 0, "tags": [], - "created": "2026-07-19T19:08:11.022Z", - "updated": "2026-07-19T19:08:11.072Z", + "created": "2026-07-20T19:26:55.312Z", + "updated": "2026-07-20T19:26:55.340Z", "description": "before patch", "mitigation": null, "impact": null, @@ -833,8 +865,8 @@ Content-Type: application/json "example", "pci" ], - "created": "2026-07-19T19:08:11.467Z", - "updated": "2026-07-19T19:08:11.467Z", + "created": "2026-07-20T19:26:55.477Z", + "updated": "2026-07-20T19:26:55.477Z", "business_criticality": null, "platform": null, "origin": null, @@ -881,8 +913,8 @@ Content-Type: application/json "example", "pci" ], - "created": "2026-07-19T19:08:11.467Z", - "updated": "2026-07-19T19:08:11.530Z", + "created": "2026-07-20T19:26:55.477Z", + "updated": "2026-07-20T19:26:55.500Z", "business_criticality": null, "platform": null, "origin": null, diff --git a/docs/content/automation/api/api-v3-alpha-docs.md b/docs/content/automation/api/api-v3-alpha-docs.md index c861e33971a..d177ae42e93 100644 --- a/docs/content/automation/api/api-v3-alpha-docs.md +++ b/docs/content/automation/api/api-v3-alpha-docs.md @@ -96,9 +96,19 @@ add `"type": ""`. On writes you reference a relation by its **int | Param | Does | Example | |---|---|---| | `?expand=` | Swaps a ref for the target's slim object **inline** and drives the queryset. Comma-separated dotted paths, budget-guarded. | `?expand=test.engagement,reporter` | -| `?fields=` | Allowlist of fields to return (`id` always included). The picker facility. | `?fields=id,title,severity` | +| `?fields=` | Allowlist of fields to return (`id` always included). The picker facility; on a list it may also request any **detail** field. | `?fields=id,title,impact` | | `?include=counts` | Adds severity/status totals over the **filtered, authorized** queryset to `meta`, in one query — no second round-trip. | `?include=counts` | +**`?fields=` on lists may opt up into the detail shape.** A list returns the slim shape by default. +`?fields=` accepts any field in the slim **or** the detail set (plus expand keys), so a list can pull +a normally detail-only field like `impact` or `references` without a second request to the detail +endpoint — `GET /findings?fields=id,title,impact`. Fields are plain row-columns, so this is a wider +`SELECT` on the same single query, never a per-row cost: the default list stops fetching the heavy +detail columns from the DB entirely, and naming one in `?fields=` un-defers exactly that column. +**Guard:** only *row-columns* are eligible. Anything computed per row (SLA/age math, per-row counts) +is permanently out of `?fields=` — it would reintroduce the N+1 the slim default removes. An unknown +field name still returns `400`. + A finding's locations are special: `?expand=locations` replaces the cheap `locations_count` with the full edge rows `{location, status, audit_time, auditor}`. diff --git a/dojo/api_v3/expand.py b/dojo/api_v3/expand.py index cb100be606b..2457b18a66a 100644 --- a/dojo/api_v3/expand.py +++ b/dojo/api_v3/expand.py @@ -205,3 +205,98 @@ def apply_fields(data: dict, fields: set[str] | None) -> dict: if fields is None: return data return {k: v for k, v in data.items() if k in fields} + + +# --- LIST-endpoint slim/detail field planning + deferral (Part A opt-up / Part B defer) -------- + +@dataclass(frozen=True) +class ListFieldPlan: + + """ + The serialization + queryset plan for one LIST request's ``?fields=`` selection. + + ``?fields=`` on a list may opt **up** into the detail field set (Jira-style, §4.7): the allowlist + is ``DetailSchema fields`` plus the ``EXPANDABLE`` keys, and when a requested field is beyond the slim set + the row serializes with the detail shape. Independently, LIST querysets ``defer()`` the heavy + own-model detail columns that were **not** requested, so a default list never fetches them from + the DB (Part B). Both are resource-agnostic: the kernel derives everything from the two schemas + and the model's ``concrete_fields``. + """ + + base_schema: type # slim schema; base serialization always runs through it + detail_schema: type # detail schema; source of the spliced detail-only fields + detail_extra: tuple[str, ...] # requested detail-only fields to splice onto the slim row + defer: tuple[str, ...] # own-model concrete columns to .defer() on the LIST queryset + select_related: tuple[str, ...] # fixed joins for requested detail-only relation refs + requested: frozenset[str] | None # the parsed ?fields= set (id included) or None + + @property + def serialization_schema(self) -> type: + # The schema whose declared field set shapes the row (the task's "serialization_schema"): + # detail once the request opted up beyond slim, else slim. Serialization itself always runs + # through ``base_schema`` so no deferred column is ever read (see ``serialize_list_row``). + return self.detail_schema if self.detail_extra else self.base_schema + + +def _own_concrete_columns(model: type) -> set[str]: + """ + Own-model concrete columns that are NOT relations. Relations (FK/O2O columns) are excluded so we + never defer a column a ref renderer needs (§Part B: "never defer properties, annotations, or + relations"); properties/annotations are not ``concrete_fields`` at all. + """ + return {f.name for f in model._meta.concrete_fields if not f.is_relation} + + +def plan_list_fields(slim_schema: type, detail_schema: type, requested: set[str] | None) -> ListFieldPlan: + """ + Given ``(slim_schema, detail_schema, requested_fields)`` return the LIST serialization/queryset + plan (Part A opt-up + Part B defer). ``requested`` is the already-parsed/validated ``?fields=`` + set (``id`` included) or ``None`` for the default (no ``?fields=``) request. + + - ``defer`` = detail-only concrete own-model columns **minus** the requested fields. On a default + list this defers every heavy detail column (they are never serialized by the slim shape); a + ``?fields=impact`` request un-defers exactly ``impact``. + - ``detail_extra`` = the requested detail-only fields (spliced onto the slim row). + - ``select_related`` = the fixed joins the requested detail-only *relation* refs need, declared by + the detail schema's ``DETAIL_SELECT_RELATED`` (so D11 wire↔model naming and reverse-O2O joins + resolve correctly); applied only when such a field is requested and never per row. + """ + slim_fields = set(slim_schema.model_fields) + detail_only = set(detail_schema.model_fields) - slim_fields + model = getattr(slim_schema, "django_model", None) + concrete = _own_concrete_columns(model) if model is not None else set() + defer_candidates = detail_only & concrete + + requested_set = set(requested) if requested is not None else set() + detail_extra = tuple(sorted(requested_set & detail_only)) + defer = tuple(sorted(defer_candidates - requested_set)) + + detail_select_related: dict[str, tuple[str, ...]] = getattr(detail_schema, "DETAIL_SELECT_RELATED", {}) + select_related: set[str] = set() + for name in detail_extra: + select_related.update(detail_select_related.get(name, ())) + + return ListFieldPlan( + base_schema=slim_schema, + detail_schema=detail_schema, + detail_extra=detail_extra, + defer=defer, + select_related=tuple(sorted(select_related)), + requested=frozenset(requested) if requested is not None else None, + ) + + +def serialize_list_row(obj: object, plan: ListFieldPlan, expand_tree: dict) -> dict: + """ + Serialize one LIST row honoring an opt-up ``?fields=`` projection (Part A) **without ever reading + a deferred column** (Part B). The base slim serialization touches only always-loaded slim + columns/refs/annotations; each requested detail-only field is then spliced on -- concrete columns + via ``getattr`` (un-deferred because ``plan.defer`` excludes requested fields) and relation refs + via the detail schema's resolver (loaded by ``plan.select_related``). The output is identical to + ``apply_fields(serialize(obj, detail_schema, expand_tree), requested)`` but issues no per-row query. + """ + data = serialize(obj, plan.base_schema, expand_tree) + for name in plan.detail_extra: + resolver = getattr(plan.detail_schema, f"resolve_{name}", None) + data[name] = resolver(obj) if resolver else getattr(obj, name) + return apply_fields(data, plan.requested) diff --git a/dojo/engagement/api_v3/routes.py b/dojo/engagement/api_v3/routes.py index e0ae11de2f9..ba4269267c1 100644 --- a/dojo/engagement/api_v3/routes.py +++ b/dojo/engagement/api_v3/routes.py @@ -33,7 +33,16 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem, validation_problem -from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import ( + allowed_field_names, + apply_fields, + parse_fields, + plan, + plan_list_fields, + plan_queryset, + serialize, + serialize_list_row, +) from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -170,13 +179,17 @@ def list_engagements(request: HttpRequest): filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) - page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + # ?fields= may opt up into the detail field set (§4.7 Part A); defer the heavy detail + # columns not requested (Part B). + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) + fplan = plan_list_fields(schema, detail_schema, fields) + page_qs = filtered.select_related(*schema.SELECT_RELATED, *fplan.select_related).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - - fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) + if fplan.defer: + page_qs = page_qs.defer(*fplan.defer) def serialize_row(obj: object) -> dict: - return apply_fields(serialize(obj, schema, expand_tree), fields) + return serialize_list_row(obj, fplan, expand_tree) envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) include_meta = apply_includes(request, filtered, allowed=set()) diff --git a/dojo/finding/api_v3/routes.py b/dojo/finding/api_v3/routes.py index e3601f50636..9a498ad8016 100644 --- a/dojo/finding/api_v3/routes.py +++ b/dojo/finding/api_v3/routes.py @@ -29,7 +29,16 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem -from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import ( + allowed_field_names, + apply_fields, + parse_fields, + plan, + plan_list_fields, + plan_queryset, + serialize, + serialize_list_row, +) from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -153,18 +162,21 @@ def list_findings(request: HttpRequest): filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) + # ?fields= may opt up into the detail field set (§4.7 Part A); the LIST queryset defers the + # heavy detail columns that were not requested (Part B). + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) + fplan = plan_list_fields(schema, detail_schema, fields) page_qs = ( - filtered.select_related(*schema.SELECT_RELATED) + filtered.select_related(*schema.SELECT_RELATED, *fplan.select_related) .prefetch_related(*schema.PREFETCH_RELATED) .annotate(locations_count=Count("locations", distinct=True)) ) page_qs = plan_queryset(page_qs, select_related, prefetch) - - allowed_fields = allowed_field_names(schema) - fields = parse_fields(request.GET.get("fields"), allowed_fields) + if fplan.defer: + page_qs = page_qs.defer(*fplan.defer) def serialize_row(obj: object) -> dict: - return apply_fields(serialize(obj, schema, expand_tree), fields) + return serialize_list_row(obj, fplan, expand_tree) envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) diff --git a/dojo/finding/api_v3/schemas.py b/dojo/finding/api_v3/schemas.py index ae82eda56fe..60db2a5e11b 100644 --- a/dojo/finding/api_v3/schemas.py +++ b/dojo/finding/api_v3/schemas.py @@ -161,6 +161,11 @@ class FindingDetail(FindingSlim): """Slim + the documented heavier fields (§4.5). List returns slim; retrieve returns detail.""" + # Fixed join for the ``mitigated_by`` ref when a LIST ``?fields=`` opts up into it (§4.7 Part A): + # the kernel adds this select_related only when ``mitigated_by`` is requested, so the ref renders + # from the join with no per-row query. (GET /{id} loads it lazily; a single object query is fine.) + DETAIL_SELECT_RELATED: ClassVar[dict[str, tuple[str, ...]]] = {"mitigated_by": ("mitigated_by",)} + description: str | None mitigation: str | None impact: str | None diff --git a/dojo/location/api_v3/routes.py b/dojo/location/api_v3/routes.py index 108e7cb86c3..d0b9aa73562 100644 --- a/dojo/location/api_v3/routes.py +++ b/dojo/location/api_v3/routes.py @@ -33,7 +33,16 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem -from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import ( + allowed_field_names, + apply_fields, + parse_fields, + plan, + plan_list_fields, + plan_queryset, + serialize, + serialize_list_row, +) from dojo.api_v3.filtering import FilterSpec, apply_filters, filter_field, register_filter_spec from dojo.api_v3.pagination import paginate from dojo.authorization.roles_permissions import Permissions @@ -101,13 +110,18 @@ def list_locations(request: HttpRequest): filtered = apply_filters(request, _base_queryset(request), filter_spec) expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) - page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + # ?fields= may opt up into the detail field set (§4.7 Part A); the URL-subtype detail fields + # are read through the `url` reverse-O2O (not own-model columns) so nothing is deferrable, but + # the join is added only when such a field is requested (Part B / DETAIL_SELECT_RELATED). + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) + fplan = plan_list_fields(schema, detail_schema, fields) + page_qs = filtered.select_related(*schema.SELECT_RELATED, *fplan.select_related).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - - fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) + if fplan.defer: + page_qs = page_qs.defer(*fplan.defer) def serialize_row(obj: object) -> dict: - return apply_fields(serialize(obj, schema, expand_tree), fields) + return serialize_list_row(obj, fplan, expand_tree) envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) return json_response(envelope) diff --git a/dojo/location/api_v3/schemas.py b/dojo/location/api_v3/schemas.py index 32a101ff24c..9e9196620a7 100644 --- a/dojo/location/api_v3/schemas.py +++ b/dojo/location/api_v3/schemas.py @@ -92,6 +92,12 @@ class LocationDetail(LocationSlim): # The URL subtype is a reverse one-to-one; load it in the detail fetch so the resolvers below # issue no per-object query. SELECT_RELATED: ClassVar[tuple] = ("url",) + # The URL-subtype fields are read through the ``url`` reverse-O2O (not own-model columns, so never + # deferrable); when a LIST ``?fields=`` opts up into any of them the kernel adds this fixed join + # so the resolvers issue no per-row query (§4.7 Part A). + DETAIL_SELECT_RELATED: ClassVar[dict[str, tuple[str, ...]]] = dict.fromkeys( + ("protocol", "host", "port", "path", "query", "fragment"), ("url",), + ) protocol: str | None host: str | None diff --git a/dojo/product/api_v3/routes.py b/dojo/product/api_v3/routes.py index d67a90661ea..57beaef35fd 100644 --- a/dojo/product/api_v3/routes.py +++ b/dojo/product/api_v3/routes.py @@ -37,7 +37,16 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem, validation_problem -from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import ( + allowed_field_names, + apply_fields, + parse_fields, + plan, + plan_list_fields, + plan_queryset, + serialize, + serialize_list_row, +) from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -172,13 +181,17 @@ def list_assets(request: HttpRequest): filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) - page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + # ?fields= may opt up into the detail field set (§4.7 Part A); defer the heavy detail + # columns not requested (Part B). + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) + fplan = plan_list_fields(schema, detail_schema, fields) + page_qs = filtered.select_related(*schema.SELECT_RELATED, *fplan.select_related).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - - fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) + if fplan.defer: + page_qs = page_qs.defer(*fplan.defer) def serialize_row(obj: object) -> dict: - return apply_fields(serialize(obj, schema, expand_tree), fields) + return serialize_list_row(obj, fplan, expand_tree) envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) include_meta = apply_includes(request, filtered, allowed=set()) diff --git a/dojo/product/api_v3/schemas.py b/dojo/product/api_v3/schemas.py index ff49d274924..83a03a6895b 100644 --- a/dojo/product/api_v3/schemas.py +++ b/dojo/product/api_v3/schemas.py @@ -67,6 +67,14 @@ class AssetDetail(AssetSlim): # Detail fetch pulls the extra parent FKs so the ref resolvers below issue no extra queries. SELECT_RELATED: ClassVar[tuple] = ("prod_type", "product_manager", "technical_contact", "team_manager") + # Fixed joins for the user-role refs when a LIST ``?fields=`` opts up into them (§4.7 Part A). The + # wire field ``asset_manager`` maps to the model FK ``product_manager`` (D11), so the join path is + # declared here rather than derived from the wire name; added by the kernel only when requested. + DETAIL_SELECT_RELATED: ClassVar[dict[str, tuple[str, ...]]] = { + "asset_manager": ("product_manager",), + "technical_contact": ("technical_contact",), + "team_manager": ("team_manager",), + } business_criticality: str | None platform: str | None diff --git a/dojo/product_type/api_v3/routes.py b/dojo/product_type/api_v3/routes.py index bf27b041812..17dca059428 100644 --- a/dojo/product_type/api_v3/routes.py +++ b/dojo/product_type/api_v3/routes.py @@ -34,7 +34,16 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem, validation_problem -from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import ( + allowed_field_names, + apply_fields, + parse_fields, + plan, + plan_list_fields, + plan_queryset, + serialize, + serialize_list_row, +) from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -135,13 +144,18 @@ def list_organizations(request: HttpRequest): filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) - page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + # ?fields= may opt up into the detail field set (§4.7 Part A); defer the heavy detail + # columns not requested (Part B). OrganizationDetail == slim, so both are no-ops here, but + # the wiring is uniform across resources. + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) + fplan = plan_list_fields(schema, detail_schema, fields) + page_qs = filtered.select_related(*schema.SELECT_RELATED, *fplan.select_related).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - - fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) + if fplan.defer: + page_qs = page_qs.defer(*fplan.defer) def serialize_row(obj: object) -> dict: - return apply_fields(serialize(obj, schema, expand_tree), fields) + return serialize_list_row(obj, fplan, expand_tree) envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) include_meta = apply_includes(request, filtered, allowed=set()) diff --git a/dojo/test/api_v3/routes.py b/dojo/test/api_v3/routes.py index a1c1185c39c..11540c4f92b 100644 --- a/dojo/test/api_v3/routes.py +++ b/dojo/test/api_v3/routes.py @@ -34,7 +34,16 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem, validation_problem -from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import ( + allowed_field_names, + apply_fields, + parse_fields, + plan, + plan_list_fields, + plan_queryset, + serialize, + serialize_list_row, +) from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -192,13 +201,17 @@ def list_tests(request: HttpRequest): filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) - page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + # ?fields= may opt up into the detail field set (§4.7 Part A); defer the heavy detail + # columns not requested (Part B). + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) + fplan = plan_list_fields(schema, detail_schema, fields) + page_qs = filtered.select_related(*schema.SELECT_RELATED, *fplan.select_related).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - - fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) + if fplan.defer: + page_qs = page_qs.defer(*fplan.defer) def serialize_row(obj: object) -> dict: - return apply_fields(serialize(obj, schema, expand_tree), fields) + return serialize_list_row(obj, fplan, expand_tree) envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) include_meta = apply_includes(request, filtered, allowed=set()) diff --git a/dojo/user/api_v3/routes.py b/dojo/user/api_v3/routes.py index b590dc8ae1d..7becbd4e309 100644 --- a/dojo/user/api_v3/routes.py +++ b/dojo/user/api_v3/routes.py @@ -29,7 +29,16 @@ from ninja.constants import NOT_SET from dojo.api_v3.errors import json_response, not_found_problem, validation_problem -from dojo.api_v3.expand import allowed_field_names, apply_fields, parse_fields, plan, plan_queryset, serialize +from dojo.api_v3.expand import ( + allowed_field_names, + apply_fields, + parse_fields, + plan, + plan_list_fields, + plan_queryset, + serialize, + serialize_list_row, +) from dojo.api_v3.filtering import ( FilterSpec, apply_filters, @@ -144,14 +153,18 @@ def build_users_router( def list_users(request: HttpRequest): filtered = apply_filters(request, _base_queryset(request, queryset_hook), filter_spec) - _, select_related, prefetch = plan(schema, request.GET.get("expand")) - page_qs = filtered.select_related(*schema.SELECT_RELATED).prefetch_related(*schema.PREFETCH_RELATED) + expand_tree, select_related, prefetch = plan(schema, request.GET.get("expand")) + # ?fields= may opt up into the detail field set (§4.7 Part A); defer the heavy detail + # columns not requested (Part B). + fields = parse_fields(request.GET.get("fields"), allowed_field_names(detail_schema)) + fplan = plan_list_fields(schema, detail_schema, fields) + page_qs = filtered.select_related(*schema.SELECT_RELATED, *fplan.select_related).prefetch_related(*schema.PREFETCH_RELATED) page_qs = plan_queryset(page_qs, select_related, prefetch) - - fields = parse_fields(request.GET.get("fields"), allowed_field_names(schema)) + if fplan.defer: + page_qs = page_qs.defer(*fplan.defer) def serialize_row(obj: object) -> dict: - return apply_fields(serialize(obj, schema, {}), fields) + return serialize_list_row(obj, fplan, expand_tree) envelope = paginate(request, count_qs=filtered, page_qs=page_qs, serialize=serialize_row) include_meta = apply_includes(request, filtered, allowed=set()) diff --git a/unittests/api_v3/test_apiv3_engagements.py b/unittests/api_v3/test_apiv3_engagements.py index 14c515bde5b..ee181f20e86 100644 --- a/unittests/api_v3/test_apiv3_engagements.py +++ b/unittests/api_v3/test_apiv3_engagements.py @@ -68,6 +68,27 @@ def test_expand_unknown_relation_is_400(self): self.get_json("engagements", data={"expand": "not_a_relation"}, expected=400) +class TestApiV3EngagementsFieldsOptUp(ApiV3TestCase): + + """`?fields=` opt-up into detail fields is uniform across resources (§4.7 Part A) -- engagements.""" + + def test_description_absent_from_default_list(self): + row = self.get_json("engagements")["results"][0] + self.assertEqual(_SLIM_KEYS, set(row)) + self.assertNotIn("description", row) + + def test_fields_opts_up_into_description(self): + engagement = Engagement.objects.first() + engagement.description = "opt-up-engagement-description" + engagement.save(update_fields=["description"]) + row = next( + r for r in self.get_json("engagements", data={"fields": "id,name,description", "limit": 250})["results"] + if r["id"] == engagement.id + ) + self.assertEqual({"id", "name", "description"}, set(row)) + self.assertEqual("opt-up-engagement-description", row["description"]) + + class TestApiV3EngagementsFilters(ApiV3TestCase): def test_filter_asset(self): diff --git a/unittests/api_v3/test_apiv3_examples.py b/unittests/api_v3/test_apiv3_examples.py index b6d1b938994..1fea59cfce3 100644 --- a/unittests/api_v3/test_apiv3_examples.py +++ b/unittests/api_v3/test_apiv3_examples.py @@ -109,6 +109,9 @@ def test_capture_examples(self): # --- findings (the complex entity) ---------------------------------------------------------- def _capture_findings(self, finding: Finding) -> None: fid = finding.id + # Populate a detail-only field so the `?fields=` opt-up example below shows a real value. + finding.impact = "Unauthorized disclosure of customer data if exploited." + finding.save(update_fields=["impact"]) self._record( title="Finding — GET detail (slim + detail fields)", @@ -148,6 +151,19 @@ def _capture_findings(self, finding: Finding) -> None: truncate=True, ) + self._record( + title="Finding — GET list with `?fields=` opting into a detail field (`impact`)", + intro="A list returns the slim shape by default. `?fields=` may name any **detail** field " + "(here `impact`, normally only on the detail endpoint) and it is returned on the list " + "with no second request (§4.7). Fields are row-columns, so this is a wider `SELECT` on " + "the same single query — never a per-row cost; the default list defers these heavy " + "columns entirely and requesting one un-defers exactly it.", + method="GET", + url=self.v3_url(f"findings?id__in={fid}&fields=id,title,severity,impact"), + response=self.client.get(self.v3_url(f"findings?id__in={fid}&fields=id,title,severity,impact")), + truncate=True, + ) + # notes sub-resource: POST then GET note_body = {"entry": "Reviewed with the security team; scheduled for the next sprint.", "private": False} self._record( @@ -288,7 +304,8 @@ def _header(self) -> str: "reference relations by integer id — the asymmetry is intentional (§4.11)."), ("- **`?expand=` (§4.6):** dotted paths swap refs for slim objects inline and drive the " "queryset (the real N+1 fix). Budget-guarded."), - "- **`?fields=` (§4.7):** comma-separated allowlist; `id` is always included.", + ("- **`?fields=` (§4.7):** comma-separated allowlist; `id` is always included. On a list it " + "may also request any detail field (a wider SELECT on one query, never per-row)."), ("- **`?include=counts` (§4.8):** adds aggregate totals to `meta` over the filtered, " "authorized queryset."), ("- **Errors (§4.10):** RFC 9457 `application/problem+json` with a `fields` extension for " diff --git a/unittests/api_v3/test_apiv3_findings.py b/unittests/api_v3/test_apiv3_findings.py index 38d31601a76..75e55ef31f6 100644 --- a/unittests/api_v3/test_apiv3_findings.py +++ b/unittests/api_v3/test_apiv3_findings.py @@ -89,6 +89,88 @@ def test_unknown_field_is_400(self): self.get_json("findings", data={"fields": "id,not_a_field"}, expected=400) +class TestApiV3FindingsFieldsOptUp(ApiV3TestCase): + + """`?fields=` on the LIST endpoint may opt UP into the detail field set (§4.7 Part A).""" + + def test_fields_opts_up_into_detail_fields(self): + # impact + references are detail-only fields; requesting them returns exactly the projection. + row = self.get_json("findings", data={"fields": "id,title,impact,references"})["results"][0] + self.assertEqual({"id", "title", "impact", "references"}, set(row)) + + def test_detail_fields_absent_from_default_list(self): + # The default (no fields=) list is the slim shape exactly -- detail fields never appear. + row = self.get_json("findings")["results"][0] + self.assertEqual(_SLIM_KEYS, set(row)) + self.assertNotIn("impact", row) + self.assertNotIn("references", row) + + def test_opt_up_value_matches_detail_get(self): + finding = Finding.objects.first() + finding.impact = "opt-up-impact-value" + finding.save(update_fields=["impact"]) + row = next( + r for r in self.get_json("findings", data={"fields": "id,impact", "limit": 250})["results"] + if r["id"] == finding.id + ) + self.assertEqual("opt-up-impact-value", row["impact"]) + # Identical to what the detail GET (which serializes with FindingDetail) returns. + self.assertEqual(self.get_json(f"findings/{finding.id}")["impact"], row["impact"]) + + def test_detail_only_relation_ref_via_fields(self): + # mitigated_by is a detail-only relation rendered as a {id, name} ref (fixed join, Part A). + finding = Finding.objects.first() + finding.mitigated_by = self.admin + finding.save(update_fields=["mitigated_by"]) + row = next( + r for r in self.get_json("findings", data={"fields": "id,mitigated_by", "limit": 250})["results"] + if r["id"] == finding.id + ) + self.assertEqual({"id", "mitigated_by"}, set(row)) + self.assertEqual({"id", "name"}, set(row["mitigated_by"])) + self.assertEqual(self.admin.username, row["mitigated_by"]["name"]) + + def test_fields_mixing_slim_detail_and_expandable_keys(self): + # title (slim) + impact (detail) + locations (an EXPANDABLE key, not a model field). + body = self.get_json( + "findings", data={"fields": "id,title,impact,locations", "expand": "locations"}, + ) + row = body["results"][0] + self.assertEqual({"id", "title", "impact", "locations"}, set(row)) + self.assertIsInstance(row["locations"], list) + + def test_unknown_field_still_400_under_opt_up_allowlist(self): + self.get_json("findings", data={"fields": "id,not_a_field"}, expected=400) + + +class TestApiV3FindingsDefer(ApiV3TestCase): + + """Part B: the LIST row query defers the heavy detail columns that were not requested.""" + + def _main_row_sql(self, params: dict) -> str: + with CaptureQueriesContext(connection) as ctx: + response = self.client.get(self.v3_url("findings"), params) + self.assertEqual(200, response.status_code, response.content[:500]) + # The main row query is the one selecting the finding table's slim `title` column. + rows = [q["sql"] for q in ctx.captured_queries if '"dojo_finding"."title"' in q["sql"]] + self.assertEqual(1, len(rows), f"expected exactly one main finding row query, got {len(rows)}") + return rows[0] + + def test_default_list_does_not_select_deferred_columns(self): + sql = self._main_row_sql({"limit": 250}) + self.assertIn('"dojo_finding"."title"', sql) # a slim column is always selected + self.assertNotIn('"dojo_finding"."impact"', sql) # heavy detail columns are deferred + self.assertNotIn('"dojo_finding"."description"', sql) + self.assertNotIn('"dojo_finding"."mitigation"', sql) + + def test_requesting_impact_un_defers_impact(self): + sql = self._main_row_sql({"limit": 250, "fields": "id,impact"}) + self.assertIn('"dojo_finding"."impact"', sql) # requested -> selected + # Other heavy detail columns stay deferred ("un-defers exactly impact"). + self.assertNotIn('"dojo_finding"."mitigation"', sql) + self.assertNotIn('"dojo_finding"."description"', sql) + + class TestApiV3FindingsFilters(ApiV3TestCase): def test_filter_severity(self): diff --git a/unittests/api_v3/test_apiv3_findings_queries.py b/unittests/api_v3/test_apiv3_findings_queries.py index e71d1862112..4f93aeebc4a 100644 --- a/unittests/api_v3/test_apiv3_findings_queries.py +++ b/unittests/api_v3/test_apiv3_findings_queries.py @@ -63,3 +63,41 @@ def test_query_count_is_independent_of_row_count(self): queries_10_expand, queries_100_expand, f"query count must not grow with row count (expand): {queries_10_expand} vs {queries_100_expand}", ) + + def test_query_count_constant_with_beyond_slim_fields(self): + """ + `?fields=` opting up into detail fields stays row-count-independent (§4.7 Part A/B). + + Includes ``mitigated_by`` (a detail-only relation ref) to prove its extra ``select_related`` + is a fixed join, not a per-row query. + """ + test = Test.objects.first() + params = {"limit": 250, "fields": "id,title,impact,references,mitigated_by"} + + self._bulk_create_findings(10, test) + queries_10 = self._query_count(params) + + self._bulk_create_findings(90, test) + queries_100 = self._query_count(params) + + self.assertGreaterEqual(self.get_json("findings", data={"limit": 250})["count"], 100) + self.assertEqual( + queries_10, queries_100, + f"opt-up query count must not grow with row count: {queries_10} vs {queries_100}", + ) + + def test_default_list_query_count_unchanged_by_defer(self): + """ + Deferring the heavy detail columns changes the SELECT column list, never the query count. + + The default list is still the same constant number of queries with defer active (Part B). + """ + test = Test.objects.first() + self._bulk_create_findings(50, test) + default_queries = self._query_count({"limit": 250}) + # Requesting a detail column un-defers it but issues no additional query (row-column, not per-row). + opt_up_queries = self._query_count({"limit": 250, "fields": "id,title,impact"}) + self.assertEqual( + default_queries, opt_up_queries, + f"opting up into a detail column must not add queries: {default_queries} vs {opt_up_queries}", + ) From 52e93d8c710c165bcc2d7c70e531bbe7cfa0fbae Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 20 Jul 2026 21:51:01 +0200 Subject: [PATCH 16/37] docs(api-v3): implementation plan + combined debrief The complete v3 plan (decisions D1-D11, contract spec, invariants, phase log, post-alpha backlog) and the consolidated implementation debrief (commit timeline, per-phase deliverables, review findings, benchmark, invariant verdicts). --- API_V3_PLAN.md | 486 ++++++++++++++++++++++++++++++++++++++++ api-v3-alpha-debrief.md | 123 ++++++++++ 2 files changed, 609 insertions(+) create mode 100644 API_V3_PLAN.md create mode 100644 api-v3-alpha-debrief.md diff --git a/API_V3_PLAN.md b/API_V3_PLAN.md new file mode 100644 index 00000000000..e14e3d321ef --- /dev/null +++ b/API_V3_PLAN.md @@ -0,0 +1,486 @@ +# DefectDojo API v3 — Implementation Plan + +| | | +|---|---| +| **Status** | Approved for OS implementation (alpha). Downstream adoption phases are outline-only, not started. | +| **Audience** | Developers and architects implementing or reviewing v3. Written to be executable by an implementation-focused engineer or AI model without re-deriving decisions. | +| **Companion** | "API v3 Idea and Design Spec" (the original design proposal). Where this plan and the spec disagree, **this plan wins** — it incorporates codebase and downstream-consumer audits done after the spec was written. | +| **How to update** | Decisions live in §1 (numbered D1…). Don't silently change a decision — add a row to the Decision Log (§12) with rationale, then update the affected sections. Open questions also go to §12. | + +--- + +## 1. Architect Summary + +v3 is a new, parallel API mounted at `/api/v3/` next to the existing v2. v2 stays fully supported and **is not modified by this work**. The alpha covers seven objects (`product_type`, `product`, `engagement`, `test`, `finding`, `location`, `user`) plus a consolidated import endpoint. Everything else stays on v2 until v3 earns parity. + +The design decisions, with the reasoning that produced them: + +**D1 — Parallel, additive-only mount; alpha carries "alpha" in the URL.** v3 mounts in `dojo/urls.py` next to v2 — during alpha at **`/api/v3-alpha/`**, so the instability is impossible to miss (headers and docs banners are ignorable; the URL isn't). At beta it moves to `/api/v3/` and never changes again — one client-side URL migration total, timed to the additive-only contract freeze (§4.1). The entire OS deliverable is new code: no v2 serializer, viewset, or URL changes. This is what makes one big, easily-testable PR viable (§7) — nothing existing can regress. + +**D2 — Framework: Django Ninja pilot with a hard decision gate; DRF fallback.** Ninja (Pydantic v2 DTOs) is recommended because explicit schemas make the ref/expand model natural and schemas are subclassable (a downstream requirement, see D10). It is *not* justified by async — DefectDojo deploys WSGI (uwsgi), so async endpoints yield no concurrency win. Neither ninja nor pydantic is currently a dependency; adding them is a deliberate supply-chain decision surfaced in the PR. Phase OS1 ends with a go/no-go gate against seven exit criteria (§6, OS1); on failure we implement the **identical contract** on DRF. Everything in §4 is framework-agnostic. + +*Ecosystem trade-off (accepted):* Ninja has a much smaller third-party ecosystem than DRF — there is no drop-in equivalent for much of the DRF plugin landscape (filter integrations, throttling add-ons, spectacular extensions), so v3 writes more of its own kernel code (pagination, filter adapter, error handling — §6 OS1/OS2). We accept this deliberately: (a) the kernel pieces are small, contract-driven, and easier to own than to adapt — and with AI-assisted implementation the cost of writing well-specified glue code is far lower than it historically was; (b) the DRF plugin ecosystem is itself a liability in places — several widely-used packages are aging or effectively unmaintained, which is how v2 accumulated debt like the bespoke prefetch framework. Owning ~6 small kernel modules outright is preferable to depending on third-party code with uncertain maintenance. The OS1 gate still checks that this self-built surface stays small (exit criterion 4: django-filter reuse or costed equivalent). + +**D3 — Response model: slim default + `{id, name}` refs + `?expand=` + `?fields=`. No in-band memoization.** Every relation renders as a ref by default; `?expand=test.engagement` swaps refs for fuller objects and drives real `select_related`/`prefetch_related` (this is the actual N+1 fix — v2's `?prefetch=` is post-serialization and does per-row queries). We surveyed how major APIs handle nested relations: Stripe (id + `expand[]`), ServiceNow (`{value, link}` refs + display values), Jira (`fields=`/`expand=`), GitLab/GitHub (small objects inlined and repeated per row). **No mainstream API deduplicates repeated nested objects in-band** the way one downstream client implementation does; the only standardized dedup is JSON:API side-loading, which carries the client-rehydration complexity we're explicitly rejecting. gzip makes repeated `{id, name}` refs nearly free on the wire. Consequence: no memoization, no `prefetch` blob, no client rehydration contract. + +*Schemas are hand-declared DTOs, never auto-derived from models.* Ninja offers `ModelSchema` +(derive every field from the Django model); v3 deliberately does not use it — auto-derivation would +recreate v2's core disease: heavy-by-default responses where every model field leaks into the +contract and every model change is a silent breaking change. Each resource declares up to four +explicit classes — `Slim` (list/expansion shape: identity + status + refs + timestamps), +`Detail(Slim)` (adds the heavy fields; inherits so nothing is duplicated), `Write` and `Update` +(create/patch payloads — separate because writes take integer ids where reads render refs, forbid +unknown fields, and never expose server-managed fields). Pydantic auto-generates the *validation +and OpenAPI* from these declarations; the declarations themselves ARE the curated contract. Four +DTOs per resource is idiomatic ninja/FastAPI style, and the named classes are exactly what makes +schemas subclassable downstream (I4). + +**D4 — Pagination: one envelope, two modes; offset is the default; no two-phase pagination.** Envelope is always `{count, next, previous, results}` (+ optional `meta`). Default mode is limit/offset because the primary future consumer (a SPA with data tables) needs page-jump, offset URL state, and a count. Cursor/keyset mode is reserved as `?pagination=cursor` (GitLab precedent: same endpoints, opt-in keyset) for export/sync consumers: same envelope, `count: null`, opaque cursor in `next`, sort restricted to keyset-safe orderings. Alpha ships offset only; the reserved parameter makes cursor a later non-breaking addition. Clients must treat `next`/`previous` as opaque URLs — that's what makes both modes interchangeable. (Page-jump itself is a UI choice, not a law — if a future UI drops it, cursor becomes the recommended default for large collections with zero contract change; the dual-mode envelope is the hedge.) Counts follow industry practice (Jira's new endpoints drop totals; GitLab omits `x-total` >10k; Elasticsearch caps at 10k): **exact below a documented threshold, planner-estimated above it**. Below `COUNT_CAP` the count is exact (capped counting bounds the cost); above it, `count` is the Postgres planner's row estimate for the filtered query (`EXPLAIN`-based, the PostgREST `count=estimated` technique), clamped to ≥ CAP+1 and flagged `count_exact: false` in `meta`. Users see "≈1.2M" instead of "10000+"; cost stays bounded; never a full `COUNT(*)` and never a second rehydration query phase like the two-phase paginator observed in a downstream implementation, whose root cause (heavy list annotations) the slim default removes. + +**D5 — Locations, not Endpoints; M2M with status on the edge; URL-type only; flag-required.** v3 never exposes the legacy `Endpoint` model. The audit showed Finding↔Location is many-to-many via `LocationFindingReference`, which carries the status (`Active/Mitigated/FalsePositive/RiskAccepted/OutOfScope`), `auditor`, `audit_time` — so the original spec's singular `"location": {id, name}` field on Finding is wrong. Slim findings carry `locations_count`; the full list is the `/findings/{id}/locations` sub-resource whose rows are *(location ref + edge status)*. Only the `URL` location subtype has a persistable model today (`LocationData.code`/`.dependency` DTOs exist but are dropped by `LocationManager` on import), so v3 documents location support as **URL locations only** until Code/Dependency models land. v3 requires `V3_FEATURE_LOCATIONS=True` (already default-on in dev); with the flag off, `/api/v3/` is not mounted at all — v3 carries no legacy-endpoint code path. + +**D6 — One filter contract, many projections.** The most load-bearing pattern found in downstream API usage: the same filter parameters must drive the list, aggregate counts, future chart/aggregation endpoints, and future exports. Therefore (a) the per-object filter vocabulary is a documented, snapshot-tested artifact (django-filter-compatible grammar: `field`, `field__gte`, `field__in`, …, multi-sort `o=`, free-text `q=`); (b) `?include=counts` returns severity/status totals computed over the *filtered, authorized* queryset in the same response's `meta` — no second round-trip for "N active / M verified" headers; (c) future aggregation endpoints take identical filter params. Only `include=counts` ships in alpha; the grammar is what's locked. + +*Expressiveness ceiling (learned from downstream workarounds, not copied from them):* flat `field__lookup` params cannot express boolean OR/AND groups. When a downstream consumer hit this it accreted bespoke any/all-style suffix params and its dashboard layer abandoned GET params for POSTed filter dicts. v3 does not rebuild that ceiling: the flat grammar stays for the common case, and complex boolean filtering gets exactly one structured representation later — a reserved **`POST //query`** accepting a canonical JSON filter tree (also the future substrate for saved views and aggregation filters). Bespoke `_any`/`_all`-style suffix params are **banned**; if a filter need can't be expressed in the flat grammar, it waits for the structured representation rather than growing a one-off param. + +**D7 — Service layer is the kernel; routes are thin; v2 and UI views untouched (for now), converging later.** Business logic currently lives in **three** places: the classic Django UI views (e.g. the close/edit/risk-acceptance flows in `dojo/finding/views.py`), the v2 serializers (`FindingSerializer.update()` doing JIRA/risk/vuln-ids), and — without this decision — soon a third copy in v3. The UI-vs-API duplication is a known drift source (the paths have diverged on notification triggers and JIRA sync behavior because each is patched independently). Therefore: all side-effects and orchestration (JIRA push, risk acceptance, status transitions, import) live in `dojo//services.py` functions **written to serve all three consumers**, but in the alpha PR only v3 calls them — that preserves the additive property (D1). Schemas do field validation only; routes parse → call service → shape response. The import endpoint requires a new `dojo/importers/services.py` facade returning a structured `ImportResult` (the current importer returns a 7-tuple, and — correcting the spec — **no service wrapper exists today**; the v2 serializer calls `DefaultImporter` directly). **Extraction rule:** a service is extracted by reconciling *both* existing implementations (v2 serializer + UI view flow), not by copying the serializer — where they disagree, the canonical behavior is chosen consciously and recorded in §12; otherwise the later UI-view refactor silently becomes a behavior change instead of a pure refactor. v2 serializers and UI views migrate onto the services in the post-alpha **convergence track** (§7): end state is views, v2, and v3 as thin shells over one service layer — one place to fix a workflow bug. + +**D8 — Auth: token AND Django session+CSRF from day one; v2 tokens work unchanged.** Two authentication modes, both active on every endpoint: +- **Token — reuses the existing v2 tokens.** Same `Authorization: Token ` scheme, validated against the same store (`rest_framework.authtoken.models.Token`). No new token model, no migration, no re-issuing: a key that works on `/api/v2/` works on `/api/v3/` the same day. Implemented as a small header-auth class that parses the `Token ` prefix and resolves `request.user`. +- **Django session + CSRF** — session cookie plus `X-CSRFToken` on unsafe methods. Mandatory now because the eventual SPA consumer is session-authenticated; bolting it on later would ripple through every route's auth declaration. + +Auth classes are registered as an ordered list (`auth=[TokenAuth(), django_auth]`); each returns `None` on no-match so the next gets a chance; all failing → 401 problem+json. **The token scheme is intentionally replaceable:** because routes only see the pluggable auth list (invariant I7), the token backend can later be extended or swapped (scoped/expiring keys, JWT, OAuth) as a config-level change with no route changes — v2-token compatibility is the alpha baseline, not a permanent commitment. Authentication is separate from authorization: after any auth mode succeeds, all object access still flows through the existing `get_authorized_*` querysets and permission semantics (I8), so RBAC is identical to v2. Both modes are tested on the same endpoints from OS1. + +**D9 — Boring, standard conventions.** ISO-8601 UTC datetimes, explicit `null`s (no key-stripping), raw fields (no server-side display formatting of users etc.), errors as RFC 9457 `application/problem+json` with a `fields: {field: [messages]}` extension for validation errors. Where downstream consumers currently rely on non-standard conventions (Unix-ms timestamps, stripped nulls, preformatted user strings), those are client-side adaptations — they do not shape the v3 contract. + +**D10 — Forward-compatibility invariants instead of downstream features.** OS phases build **zero** downstream-specific features, but §5 defines invariants (composable schemas, router factories, pluggable auth, the filter contract, the `include`/`meta` mechanism, service-layer purity) that guarantee later downstream adoption phases are *additions on top of* v3, never *changes to* it. Every OS phase is reviewed against §5. + +**D11 — v3 speaks the new domain language: organizations and assets (wire surface only, unconditional).** The product has already renamed Product_Type→Organization and Product→Asset (`DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL`, default on); a brand-new API must not bake the legacy names into the surface consumers will codegen against for years, and the alpha is the only time the rename is free (post-beta it becomes aliases + deprecation forever). Scope: **wire surface only** — paths (`/organizations`, `/assets`), ref keys (`finding.asset`, `finding.organization`, `engagement.asset`, `asset.organization`), filter params (`asset=`, `organization=`, …), expand paths, schema class names (`AssetSlim`, `OrganizationSlim`), router factories, OpenAPI tags, import form fields (`asset_name`, `organization_name`), docs, examples, snapshots. **All-or-nothing** — no half-rename; a renamed path with a legacy ref key is the worst outcome. **Not renamed:** Django models, DB tables, and `dojo/product*/` module paths (the DTO layer is exactly what decouples wire names from models; internal relocation rides the module reorg later); `engagement`/`test`/`finding`/`user`/`location` are not part of the relabel. **Unconditional:** the API naming does NOT follow the UI relabel flag — a per-instance API contract is codegen poison; deployments that restore legacy UI labels still get `organizations`/`assets` on v3 (documented). The v2→v3 mapping table doubles as the rename documentation. + +--- + +## 2. Scope + +**OS alpha delivers:** `/api/v3/` mount; CRUD for the 7 objects; refs/slim/expand/fields/include; offset pagination with capped counts; documented+tested filter contract; generic notes/tags/files sub-resources; locations with edge status; consolidated `POST /api/v3/import` (mode auto/import/reimport); token+session auth; problem+json errors; OpenAPI with alpha marking; query-count regression tests. + +**Explicit non-goals for OS (deferred, see §8):** bulk operations, workflow actions (close/review/duplicate handling), aggregation/chart endpoints, saved views, CSV export, delete-impact preview, job-status resources, global search, permission-map/bootstrap endpoints, cursor pagination implementation (grammar reserved only), v2/UI-view refactors (deferred to the convergence track, §7). + +--- + +## 3. Evidence (audit summary) + +Three audits inform this plan (details in PR discussion / architect's notes): + +1. **OS v2 audit.** ~49 endpoint groups, ~106 serializers, now organized per-module (`dojo//api/`); `dojo/api_v2/serializers.py` is largely a re-export shim. `?prefetch=` is post-serialization with per-row-per-field queries (`dojo/api_v2/prefetch/mixins.py:7-48`) — its RBAC hole is already patched (deny-by-default registry, `authorized_querysets.py`; tests `unittests/test_apiv2_prefetch_rbac.py`), so the motivation to replace it is performance, not security. Business logic embedded in `FindingSerializer.update()/validate()` (`dojo/finding/api/serializer.py:438-560`): JIRA push, risk acceptance, vuln-ids/CWEs. Importer returns a 7-tuple (`dojo/importers/base_importer.py:183-188`); no service facade exists. Deps: DRF 3.17.1, drf-spectacular 0.30.0, django-filter 26.1; no ninja/pydantic. No existing v3 scaffolding. +2. **Downstream API-consumer audit.** A survey of how existing downstream consumers (a session-authenticated SPA among them) use the v2 API. Key findings: hard dependence on limit/offset + `count`, the OS `?prefetch=` mechanism, filtered-list totals alongside lists, bulk operations with per-item skip reasons, workflow actions, aggregation endpoints, saved views, poll-based async jobs (no websockets anywhere), delete-impact previews, and CSV export. Full capability list with dispositions: §9. +3. **Location audit.** Full parallel Location subsystem behind `V3_FEATURE_LOCATIONS` (default True): `Location` + `LocationFindingReference` (edge status incl. mitigation transitions in `dojo/importers/location_manager.py:230`) + `LocationProductReference`; `URL` is the only persistable subtype (`location_manager.py:402-405` drops others); when the flag is on, `Endpoint.__init__` raises — hard cutover, no dual-write. + +--- + +## 4. Contract specification + +This section is normative. Implementers follow it verbatim; deviations require a §12 decision-log entry. + +### 4.1 Mount & versioning +- **Alpha mounts at `/api/v3-alpha/`** in `dojo/urls.py` alongside the v2 mount (near the existing `re_path(r"^{}api/v2/"...)`, ~line 198). Entire mount is conditional on `settings.V3_FEATURE_LOCATIONS` (D5). Everywhere else in this document, `/api/v3/` is the *logical* name; during alpha the actual prefix is `/api/v3-alpha/` (a single settings/urls constant — do not hardcode the prefix anywhere else). +- **Stability-in-URL policy (Kubernetes/Google precedent, one migration total):** `/api/v3-alpha/` for the whole alpha period — the URL itself signals "contract may break at any time; do not build production dependencies on this." At beta the API moves to **`/api/v3/` and stays there through GA** — beta's additive-only contract freeze is exactly the stability a permanent URL implies. Beta/GA status is conveyed by header + docs, not further URL changes. Clients therefore migrate URLs exactly once (alpha→beta), at the moment they must re-verify against the settled contract anyway. +- OpenAPI `info.version = "3.0.0-alpha"`; every response carries header `X-API-Status: alpha` (later `beta`, then removed at GA); docs page states the alpha contract may change. +- OpenAPI JSON at `/api/v3-alpha/openapi.json`, interactive docs at `/api/v3-alpha/docs`. + +### 4.2 Auth (D8) +- **Token:** `Authorization: Token ` validated against the existing DRF token store (`rest_framework.authtoken.models.Token`) — same tokens work on v2 and v3. In Ninja: a small `APIKeyHeader` subclass parsing the `Token ` prefix; sets `request.user`. +- **Session:** Django session cookie + CSRF on unsafe methods. In Ninja: `ninja.security.django_auth` and `NinjaAPI(csrf=True)`. +- Both registered on the API instance: `auth=[TokenAuth(), django_auth]`. Anonymous → 401 problem+json. + +### 4.3 Envelope & pagination (D4) +Every list response: +```jsonc +{ + "count": 4321, // exact below COUNT_CAP, else planner estimate (see below) + "next": "https://.../api/v3/findings?limit=25&offset=50&severity=High", + "previous": null, + "results": [ ... ], + "meta": { } // present only when ?include= adds content, plus + // "count_exact": false when count was capped +} +``` +- `limit` default 25, max 250. `offset` ≥ 0. +- **Count strategy (hybrid exact → estimate), `COUNT_CAP = 10_000` (settings-overridable):** + 1. Capped exact count: `COUNT(*)` over a `LIMIT COUNT_CAP+1` subquery (`qs.order_by()[:COUNT_CAP+1].count()`-style) — bounded cost, and it detects whether the result is under the threshold. + 2. ≤ CAP → `count` is exact. + 3. = CAP+1 → `count` = the Postgres planner's row estimate for the same filtered queryset (`qs.explain(format="json")`, read `Plan Rows`), **clamped to `max(estimate, COUNT_CAP+1)`** so the reported count never contradicts step 1; response gains `"meta": {"count_exact": false}`. + - Never a full `COUNT(*)` on unbounded tables, never a second PK-prefetch phase. Estimates depend on planner statistics (autovacuum/ANALYZE freshness) and may drift — documented as approximate. Clients must tolerate an empty page when jumping near an estimated end (`results: []`, `next: null`). Tests assert the exact/estimate switchover, the clamp, and the `count_exact` flag — never specific estimate values (nondeterministic across environments). +- `?pagination=cursor` is **reserved**: alpha returns 400 problem+json `"cursor pagination not yet available"` so the param can't be squatted by filters. Grammar documented now (D4), implemented in a later phase. +- Sorting: `o=` comma-list, `-` prefix for descending (`o=-severity,date`). Allowed orderings are a curated per-object list (part of the filter contract, §4.9). + +### 4.4 Refs (D3) +```jsonc +{ "id": 42, "name": "Q3 Pentest" } +``` +- Produced by one shared `Ref` schema. The relation key conveys the type — no `type` field, **except** location refs, which add `"type": ""` because one key can hold heterogeneous location subtypes. +- Ref label registry (single source of truth, `dojo/api_v3/refs.py`): + +| Model | `name` source | +|---|---| +| Product_Type, Product, Engagement | `.name` | +| Test | `.title` if set, else `str(.test_type)` | +| Finding | `.title` | +| Dojo_User | `.username` (clients do their own display formatting — D9) | +| Location | `.location_value` | +| Test_Type, Development_Environment | `.name` | + +### 4.5 Slim shapes (starting field lists) +Slim = identity + primary status fields + parent refs + timestamps. **No per-row computed fields** (no `age`, no SLA math, no counts requiring extra queries — `locations_count` is a queryset annotation, which is allowed). Implementer: verify each field exists on the model before wiring; adjust only by removing, not adding, without a §12 entry. + +- **FindingSlim:** `id, title, severity, active, verified, false_p, duplicate, risk_accepted, out_of_scope, is_mitigated, date, cwe, test{ref}, engagement{ref}, product{ref}, product_type{ref}, reporter{ref}, locations_count, tags[], created, updated`. + - `engagement/product/product_type` are denormalized parent refs populated by one `select_related("test__engagement__product__prod_type")` — no extra queries. +- **TestSlim:** `id, name(=title), test_type{ref}, engagement{ref}, product{ref}, product_type{ref}, environment{ref|null}, lead{ref|null}, target_start, target_end, percent_complete, tags[], created, updated`. +- **EngagementSlim:** `id, name, product{ref}, product_type{ref}, lead{ref|null}, status, engagement_type, target_start, target_end, active, tags[], created, updated`. +- **ProductSlim:** `id, name, description, product_type{ref}, lifecycle, tags[], created, updated`. +- **ProductTypeSlim:** `id, name, description, critical_product, key_product, created, updated`. +- **UserSlim:** `id, username, first_name, last_name, email, is_active, is_superuser, last_login`. +- **LocationSlim:** `id, name(=location_value), type(=location_type), tags[]`. URL-subtype detail fields (`protocol, host, port, path, query, fragment`) appear on retrieve/expand only. + +Detail (`GET /{id}`) returns Slim + a documented per-object set of heavier fields (e.g. Finding adds `description, mitigation, impact, steps_to_reproduce, severity_justification, references, file_path, line, mitigated, mitigated_by{ref}`). List returns Slim only. + +### 4.6 `?expand=` (D3) +- Comma-separated dotted paths: `?expand=test.engagement,reporter`. Each path segment must be a registered expandable relation of the current schema (per-schema registry `EXPANDABLE: dict[str, tuple[django_relation_path, TargetSchema]]`). +- Expanding swaps the ref for the target's Slim schema **inline** (no side blob). +- No fixed depth cap. Two guards, both returning 400 problem+json: + - **Cycle guard:** while walking a path, track the model classes from the root; a segment whose target model already appears in its own ancestry chain is rejected (`finding → test → ... → findings` style loops). + - **Budget:** total expanded relation *nodes* across all paths ≤ `EXPAND_BUDGET = 10` (settings-overridable). `expand=test.engagement,reporter` = 3 nodes. +- **The expand set drives the queryset:** FK/O2O segments append `select_related` paths; M2M segments append `prefetch_related`. Implemented centrally in `dojo/api_v3/expand.py` (`plan_queryset(qs, paths)`), never per-route. +- Special case: `expand=locations` on a finding swaps `locations_count` for `locations: [{location: {ref+type}, status, audit_time}]` (bounded by the same budget; uses `prefetch_related`). + +### 4.7 `?fields=` (D3) +- Comma-separated allowlist per object (documented enum = the **Detail** field names + the schema's `EXPANDABLE` keys). Unknown field → 400 (dedicated `fields` problem type). `id` always included. This is the "picker/dropdown" facility (`?fields=id,name`), replacing the need for separate restricted-list endpoints. +- **On a list, `?fields=` may opt UP into the Detail field set (Jira-style).** The default (no `?fields=`) list is the Slim shape exactly. When a requested field is beyond Slim, the row serializes with the Detail shape and is then projected to the requested fields; when all requested fields are within Slim, behaviour is identical to Slim. Because `?fields=` names are plain **row-columns**, opting up is a wider `SELECT` on the same single query — never a per-row cost. List querysets therefore `defer()` the heavy detail columns that were not requested, so the default list never fetches them and `?fields=impact` un-defers exactly `impact` (Detail-only *relation* refs, e.g. `finding.mitigated_by`, add a fixed `select_related` join, applied only when requested). **Guard:** only row-columns are eligible for `?fields=`; anything computed per row (SLA/age math, per-row counts) is permanently excluded — it would reintroduce the N+1 the Slim default removes. Detail `GET /{id}` routes are never deferred. Implemented resource-agnostically by the kernel (`plan_list_fields` / `serialize_list_row` in `dojo/api_v3/expand.py`); the location-edge sub-resource lists serialize fixed edge shapes and are out of scope (§12). + +### 4.8 `?include=` (D6) +- Namespaced response add-ons rendered into `meta`. Alpha: `include=counts` on findings lists only: +```jsonc +"meta": { "counts": { "total": 812, "active": 400, "verified": 123, "duplicate": 12, + "severity": {"Critical": 3, "High": 40, "Medium": 200, "Low": 500, "Info": 69} } } +``` +- Computed on the **filtered, authorized** queryset via one aggregate query. The mechanism (`dojo/api_v3/include.py`) is generic: a registry of `include_name -> callable(filtered_qs) -> dict`, so later includes (and downstream-defined ones) plug in without contract changes. + +### 4.9 Filter contract (D6) +- Grammar (fixed): exact `field=`, lookups `field__gte/__lte/__gt/__lt/__in/__icontains/__isnull`, multi-sort `o=`, free-text `q=`. `__in` takes comma-separated values. +- Per-object vocabulary is **curated** and documented; alpha findings vocabulary: `id__in, title__icontains, severity, severity__in, active, verified, duplicate, false_p, risk_accepted, out_of_scope, is_mitigated, date__gte/__lte, cwe, cwe__in, product, product__in, product_type, engagement, test, reporter, tags__in (any-match), created__gte/__lte, updated__gte/__lte`; orderings: `o= id, date, severity, title, created, updated` (± each). Other objects: analogous minimal sets (id/name/parent-FK/dates). +- Implementation reuses django-filter `FilterSet`s where practical (framework-light: `FilterSet(request_params, queryset).qs`); severity ordering must sort by severity *rank*, not alphabetically (reuse v2's approach). +- **Snapshot-tested:** a test renders each object's vocabulary (params + orderings) to a checked-in JSON snapshot; contract drift fails CI (§6 OS2). + +### 4.10 Errors (D9) +RFC 9457 problem+json, `Content-Type: application/problem+json`: +```jsonc +{ "type": "https://docs.defectdojo.com/api/v3/errors/validation", + "title": "Validation failed", "status": 400, + "detail": "2 fields failed validation", + "fields": { "severity": ["Not a valid choice."], "date": ["Required."] } } +``` +- 401 unauthenticated, 403 forbidden (RBAC), 404 unknown-or-unauthorized object (do not leak existence), 400 validation/expand/filter errors. One exception handler in `dojo/api_v3/errors.py`; routes never hand-build error bodies. + +### 4.11 Conventions (D9) +ISO-8601 UTC with `Z` suffix for datetimes; dates as `YYYY-MM-DD`; explicit `null`s; tags as `list[str]`; enums as their stored string values; write payloads reference relations by integer id (`"test": 9`), reads render refs — asymmetry is intentional and documented. + +### 4.12 Sub-resources: notes / tags / files +One generic factory each (`dojo/api_v3/subresources.py`), attached to all seven resources: +``` +GET/POST /api/v3//{id}/notes { "entry": "...", "private": false } +GET/PUT/POST /api/v3//{id}/tags { "tags": ["pci"] } # PUT replaces, POST appends +DELETE /api/v3//{id}/tags/{tag} +GET/POST /api/v3//{id}/files (multipart: file, title) +GET /api/v3//{id}/files/{file_id}/download +``` +Authorization inherited from the parent object (mirror v2's related-object permission classes and note privacy rules). `NoteSchema`: `id, entry, author{ref}, private, edited, created, updated`. `FileSchema`: `id, title, size, created`. + +### 4.13 Consolidated import +``` +POST /api/v3/import (multipart/form-data) + mode: auto | import | reimport # default auto + file, scan_type, engagement | test | (product_name+engagement_name+auto_create_context), + close_old_findings?, do_not_reactivate?, ...CommonImportScan fields +``` +- `auto` resolves via existing `AutoCreateContextManager.get_target_test_if_exists(...)`. +- **Destructive flags are never implied by mode** — if omitted, importer defaults apply and the response echoes effective values. +- **Background processing (grammar reserved, not implemented in alpha):** `background: true` will return `202` with a **generic job resource** — `{ "job": { "id", "status": "pending|running|succeeded|failed", "result": , "error": } }`, pollable at `GET /api/v3/jobs/{id}`. One job shape for *all* future long-running work (imports, report generation, bulk ops) — deliberately unlike the current per-feature status mechanisms downstream (fire-and-forget background import, report `status` field polling, separate celery-status endpoint). Alpha imports are synchronous and reject `background=true` with 400 "not yet available". +- Endpoint delegates entirely to the new `dojo/importers/services.py` facade (§6 OS1). Response: +```jsonc +{ "mode_resolved": "reimport", "test": { "id": 9, "name": "ZAP Scan" }, + "statistics": { "new": 4, "reactivated": 1, "closed": 2, "untouched": 37 }, + "close_old_findings": true } +``` + +### 4.14 Locations (D5) +- `GET /api/v3/locations` + `GET /api/v3/locations/{id}` (read-only in alpha; lifecycle is import-driven). Filter: `type`, `name__icontains`, `product`. +- `GET /api/v3/findings/{id}/locations` and `GET /api/v3/products/{id}/locations` return edge rows: `{ location: {id, name, type}, status, audit_time }` (+ `auditor{ref}` on findings). +- Documented: URL locations only in alpha; other types 501/absent until models exist. + +--- + +## 5. Downstream forward-compatibility invariants (MUST hold in every OS phase) + +These guarantee downstream adoption phases are additions, never rework. **PR review checklist — every item verified before merge:** + +- **I1 Envelope is closed.** `{count, next, previous, results, meta}` and nothing else; `pagination=`, `include=`, `expand=`, `fields=`, `o=`, `q=` are reserved query params; new capabilities extend `meta`/`include`, never the envelope. +- **I2 Filter contract is a tested artifact.** Vocabulary snapshot in CI; grammar (§4.9) never varies per endpoint. +- **I3 Ref shape is closed.** `{id, name}` (+`type` for locations only). Extensions would break every consumer's ref handling. +- **I4 Schemas are composable.** Every response schema is a named, importable class; no anonymous/inline schemas; downstream code must be able to `class ProFindingSlim(FindingSlim): epss_score: float | None` and get correct OpenAPI. +- **I5 Routers are factories.** Each resource exposes `build__router(*, schema=None, filters=None, queryset_hook=None, auth=None) -> Router` with OS defaults. The OS mount calls the factory; a downstream distribution can call it with overrides and mount the result under its own prefix. No route logic at module import time. +- **I6 Services own all writes/side-effects.** Routes contain zero business logic (parse → authorize → service → shape). Service functions: keyword-only args, explicit `user`, return domain objects or result dataclasses, importable and callable without any HTTP context. +- **I7 Auth is a pluggable list** (§4.2); adding an auth class must require no route changes. +- **I8 RBAC only via `get_authorized_*` querysets + permission checks** — never ad-hoc `user.is_staff` logic in routes; expanded/included data is always drawn from authorized querysets. +- **I9 Error contract is closed** (§4.10); new error kinds get new `type` URIs, not new shapes. +- **I10 No UI-flavored formatting** in responses (D9) — formatting belongs to clients. + +--- + +## 6. OS phases (OS1–OS6) + +All phases land on one feature branch as **one PR** (§7), one merge-worthy commit (or small commit series) per phase. Each phase lists: goal, files, key details, tests, acceptance criteria. + +**Test suite layout — v3 is a separate package.** Unlike the flat `unittests/test_apiv2_*.py` files, all v3 tests live in a dedicated package so the suite is separately runnable, shardable in CI, and never interleaved with v2: +``` +unittests/api_v3/ + __init__.py + base.py # ApiV3TestCase(DojoAPITestCase): v3 URL-prefix helper, + # token + session/CSRF client helpers + test_apiv3_.py # all files referenced in the phases below live here + snapshots/filters.json # filter-contract snapshot (OS2) +``` +Django test discovery handles packages natively: `unittests.api_v3` runs the whole v3 suite, `unittests.api_v3.test_apiv3_findings` one module. Implementer: verify the CI unit-test workflow discovers the subpackage (standard Django labels do; a hardcoded file glob would need a tweak). Run tests exactly as repo convention requires: +```bash +./run-unittest.sh --test-case unittests.api_v3.test_apiv3_findings 2>&1 | tee /tmp/test_output.log +``` + +**Test client policy.** Ninja endpoints are plain Django views, so the standard in-process Django test client (via `DojoAPITestCase`) works against v3 unchanged — no socket, same thread: server-side exceptions propagate as real tracebacks (`raise_request_exception` default), logging is visible, `mock.patch` affects what routes see, `assertNumQueries` works, and the test transaction is shared (DB-state assertions for import equivalence). **All contract/integration tests use it** (full URLconf/middleware/auth/CSRF stack). `ninja.testing.TestClient` (invokes router operations directly, skipping URL resolution and middleware — and therefore real auth) is allowed **only** for kernel-internal unit tests (expand parser, pagination math). + +### OS1 — Foundation, findings read path, import, and the framework gate +**Goal:** working `/api/v3/` skeleton proving the whole contract on its hardest consumer (findings), plus the import endpoint; produce the evidence for the framework decision. + +Files: +- `requirements.txt`: add `django-ninja` (pin latest stable 1.x at implementation time; it vendors/pulls pydantic v2 — note both in the PR description). +- `dojo/api_v3/__init__.py` — `NinjaAPI` instance: `csrf=True`, `auth=[TokenAuth(), django_auth]`, exception handlers registered, `X-API-Status: alpha` via a response hook/middleware, version string. +- `dojo/api_v3/auth.py` — `TokenAuth` (parse `Authorization: Token ` → `Token.objects.select_related("user")`, set `request.user`; return None on mismatch so session auth can try). +- `dojo/api_v3/refs.py` — `Ref` schema + label registry (§4.4) + `to_ref(obj)` helper. +- `dojo/api_v3/pagination.py` — envelope paginator (§4.3) incl. the hybrid exact→planner-estimate count; rejects `pagination=cursor` with 400. +- `dojo/api_v3/errors.py` — problem+json handlers (validation, 401/403/404, expand/filter errors). +- `dojo/api_v3/expand.py` — parse/validate/plan (§4.6). OS1 may hard-scope the registry to finding relations; generic walker completed in OS2. +- `dojo/api_v3/filtering.py` — django-filter adapter: build FilterSet from a declarative per-object spec; apply `o=`/`q=`. +- `dojo/api_v3/include.py` — include registry + `counts` implementation (§4.8). +- `dojo/finding/api_v3/schemas.py` — `FindingSlim`, `FindingDetail` (§4.5); `dojo/finding/api_v3/routes.py` — `build_findings_router()` (I5) with `GET /findings`, `GET /findings/{id}`. Queryset: `get_authorized_findings(Permissions.Finding_View)` + base `select_related("test__test_type", "test__engagement__product__prod_type", "reporter")` + `annotate(locations_count=Count("locations"))`. +- `dojo/importers/services.py` — the facade (framework-neutral; useful even if the gate fails): + ```python + @dataclass(frozen=True) + class ImportResult: + test: Test + mode_resolved: str # "import" | "reimport" + new: int; closed: int; reactivated: int; untouched: int + test_import: Test_Import | None + close_old_findings: bool; do_not_reactivate: bool # effective values + + def import_scan(*, user, scan_file, scan_type, ...) -> ImportResult + def reimport_scan(*, user, scan_file, test, ...) -> ImportResult + def auto_import_scan(*, user, ...) -> ImportResult # resolves via AutoCreateContextManager + ``` + Internally constructs the importer context exactly as `ImportScanSerializer.save()` does today (`dojo/api_v2/serializers.py:526-551` is the reference implementation; do not modify it) and unpacks the 7-tuple into `ImportResult`. +- `dojo/api_v3/import_routes.py` — `POST /import` (§4.13): multipart parse, permission check mirroring `UserHasImportPermission`/`UserHasReimportPermission` semantics, delegate to facade. +- `dojo/urls.py` — conditional mount (D5/§4.1). + +Tests (OS1): `test_apiv3_auth.py` (token OK, session+CSRF OK, anonymous 401, both on same endpoint); `test_apiv3_findings.py` (slim shape incl. denormalized parent refs + `locations_count`; `expand=test.engagement`; `fields=`; `include=counts` under RBAC; filter basics; pagination envelope; count switchover exact→estimate with a lowered `COUNT_CAP` override, incl. clamp + `count_exact` flag); `test_apiv3_findings_queries.py` (**`assertNumQueries` constant** for 100-row list, with and without expand — the headline guarantee); `test_apiv3_import.py` (import/reimport/auto DB-state equivalence vs the v2 endpoints on identical payloads — same counts, same finding states, incl. `close_old_findings`). + +**Gate (architect-reviewed, not self-certified):** the implementer produces a gate report (criteria results + benchmark numbers, posted on the draft PR) and **stops**; the architect reviews, records the outcome in §12, and releases OS2. Criteria: (1) query-count tests green; (2) import equivalence green; (3) RBAC via authorized querysets works as an auth dependency; (4) django-filter reuse demonstrated or equivalent costed; (5) both auth modes green; (6) OpenAPI renders + client codegen sane for Ref/Slim/expand; (7) I4/I5 seam demo (subclass `FindingSlim` + remount router, no fork). Failure → re-implement OS1 surface on DRF with identical contract; OS2+ unchanged. + +### OS2 — Kernel hardening +**Goal:** finish the generic machinery so OS3 resources are declaration-only. +- `expand.py`: generic registry-driven walker, cycle guard, budget, M2M prefetch planning, `expand=locations` edge-row special case. +- `filtering.py`: severity rank ordering; `q=` per-object field sets; vocabulary **snapshot test** (`test_apiv3_filter_contract.py` writes/compares `unittests/api_v3/snapshots/filters.json`). +- `include.py`: registry finalized; counts on any findings-shaped queryset. +- `errors.py`: full coverage sweep — every failure path returns problem+json (add tests for expand-budget 400, cycle 400, unknown field/filter 400, unauthorized 404-vs-403 policy). +- OpenAPI polish: tags per resource, alpha banner text, `openapi.json` served; CI check that schema generation succeeds (mirror how v2's spectacular check runs in CI if present; otherwise add a unit test that renders the schema). + +Acceptance: kernel modules have no per-resource imports (dependency direction: resources import kernel, never reverse — keeps I5 honest). + +### OS3 — Core resources + finding write path +**Goal:** CRUD for `product_type`, `product`, `engagement`, `test`, `user`; write endpoints for `finding`. +- Per resource: `dojo//api_v3/{schemas,routes}.py`, router factory, filter spec, authorized queryset (`dojo//queries.py` — all exist), permission checks mirroring the v2 permission classes for create/update/delete. +- **Write schemas (create/update):** the editable subset of the detail fields; required-vs-optional mirrors the v2 serializer for that resource; relations by integer id (§4.11); server-managed fields (`id`, `created`, `updated`, computed refs) are never writable. Ambiguity → conservative choice + §12 entry (per §10.2). +- **Finding writes go through a new service** (`dojo/finding/services.py`), extracted as *new code* from the logic in `FindingSerializer.update()/validate()` (`dojo/finding/api/serializer.py:438-560` is the reference; leave it untouched): + ```python + def update_finding(finding, *, changes: dict, vulnerability_ids: list[str] | None = None, + push_to_jira: bool = False, user) -> Finding + def create_finding(*, test, data: dict, user, push_to_jira: bool = False) -> Finding + def delete_finding(finding, *, user) -> None + ``` + Must reproduce: JIRA push + keep-in-sync (`jira_services.push`, `is_keep_in_sync`), risk-acceptance processing, vuln-id/CWE persistence, mitigated-field edit rules, active/verified/duplicate/false_p invariants. Port the *validation semantics* into schema+service validation errors. + **Extraction rule (D7):** reconcile the v2 serializer path with the corresponding UI view flows (`dojo/finding/views.py` — edit, close, risk-acceptance) before finalizing each service function. List every behavioral divergence found (notifications, JIRA sync, status side-effects) in §12 with the chosen canonical behavior — these services are the future home for the UI views too (CONV2), so API-only semantics must not get baked in unnoticed. +- Users: read + self; admin-only writes (mirror v2 `UserViewSet` permission behavior). +- Tests: per-resource `test_apiv3_.py` (CRUD happy path, RBAC 404/403, filters, expand where applicable); `test_apiv3_finding_writes.py` asserting service side-effects (JIRA mocked, risk acceptance, vuln ids) match v2 behavior for the same payloads. + +### OS4 — Locations +Per §4.14: `dojo/location/api_v3/`, finding/product location sub-resource routes, `locations_count` already wired in OS1. Read-only. Tests: edge-status shape, URL-only registry behavior, flag-off ⇒ whole `/api/v3/` absent (404), RBAC inheritance from parent. + +### OS5 — Notes / tags / files sub-resources +`dojo/api_v3/subresources.py` factories (§4.12) attached to all seven resources. Reference semantics: the v2 `@action` implementations (`dojo/finding/api/views.py:412-586`) and note-privacy/permission classes. Tests: `test_apiv3_subresources.py` — matrix over (resource × notes/tags/files × CRUD), privacy rules, parent-authorization inheritance, multipart upload + download. + +### OS6 — Verification sweep, docs, examples, release readiness +- Port the intent of `unittests/test_apiv2_prefetch_rbac.py` to expand: `test_apiv3_expand_rbac.py` — a user must never see an expanded/included object outside their authorized querysets. +- Repeat query-count + latency benchmark v2 (`?prefetch=`) vs v3 (`?expand=`), 100-row findings list on the largest fixture; record numbers in the PR description. +- Docs-site page (`docs/content/...`): v3 alpha overview, contract summary, v2→v3 mapping table for the seven objects, "one way to do notes/tags/files", import consolidation, alpha disclaimer. +- **`api_v3_examples.md` (repo root):** real captured request/response pairs (not hand-written) for developer/architect review — findings (detail, list with filtering+pagination+expand+include=counts, notes sub-resource, locations sub-resource, import) and products (the simple-entity contrast: detail, list, CRUD). Captured by executing real requests through the in-process test client against fixture data; bodies verbatim. +- **Docs UI:** built-in Swagger UI already serves `/api/v3-alpha/docs` (try-it works with session auth). Evaluate swapping to **Scalar** (modern OpenAPI reference UI; self-hostable single JS asset pointing at `/openapi.json` — must be vendored locally like the v2 spectacular sidecar, no CDN). Keep Swagger if vendoring is awkward — this is polish, not contract. +- Final §5 invariant checklist pass; final ruff/lint pass; ensure v2 test suite untouched and green. + +--- + +### Post-alpha OS backlog (explicitly deferred TODOs — architect-confirmed) + +Deliberately out of the alpha, to be scheduled in later phases. Each is enabled by the service +layer and the closed contract, so all are additive: + +- [ ] **Bulk operations** (`bulk_update`/`bulk_delete`/… with per-item skip reasons — checklist #5) +- [ ] **Workflow actions** (`close`, `request_review`, `mark_duplicate`, `unaccept_risk`, … — checklist #6) +- [ ] Delete-impact preview (`GET //{id}/delete-preview` — checklist #14) +- [ ] CSV/XLSX export from the filter contract (checklist #15) +- [ ] Generic `/jobs/{id}` resource + `background=true` import (grammar reserved, §4.13) +- [ ] Cursor pagination mode (`?pagination=cursor`, grammar reserved, D4) +- [ ] PUT (full replace), delete-time `push_to_jira` param, `configuration_permissions` on user + writes, v3 self-profile endpoint, filter vocabularies for the location-edge sub-resources +- [ ] **Port the v2 endpoint-level test corpora to v3** (architect-requested). Priority order: + (1) the import/reimport corpus (`test_import_reimport.py`'s mixin, `test_apiv2_scan_import_options.py`, + `test_importers_closeold.py`) — prefer a dual-endpoint adapter over copy: parametrize the + existing mixins with a client shim that maps v2 field names to the v3 form (`asset_name`, + `organization_name`, consolidated `mode=`), so both APIs run the same scenarios; + (2) JIRA push flows (`test_jira_import_and_pushing_api.py` intent) against v3 finding + writes + import; (3) evaluate the rest of `test_apiv2_*` per scope — much of it covers + endpoints v3 deliberately does not have in alpha (metadata, notifications, risk acceptance), + port only what maps. The v3 suite already covers DB-state import equivalence, RBAC/expand + leakage, and per-resource CRUD; this item is about scenario breadth, not basics. + +## 7. PR strategy + +- **One feature branch, one PR** for OS1–OS6. Rationale: the deliverable is additive-only (D1), so blast radius is contained; a single PR deploys/tests as one unit; reviewers get one coherent contract instead of six partial views. Keep one merge-worthy commit per phase so the history doubles as the phase log. +- The OS1 framework gate happens **on the branch** (draft PR): benchmark + exit-criteria results posted as a PR comment before OS2 work proceeds. +- **Post-alpha convergence track (separate PRs, not part of alpha; ordered by risk):** + - **CONV1 — v2 delegates to services.** Refactor `FindingSerializer.update()` and `ImportScanSerializer` (then other resources) to call the services. Small diffs; the extensive `test_apiv2_*` suite pins behavior. Zero contract change. + - **CONV2 — UI views delegate to services.** View-by-view (never big-bang), starting with the flows the services already reconciled (finding edit/close, risk acceptance, import). UI view test coverage is thinner than the API's, so each flow gets behavior-pinning tests *before* its refactor. Zero template/UX change. + - **CONV3 — delete dead duplicates.** Remove the now-unused logic bodies (serializer `update()` internals, per-resource notes/files action implementations the views duplicate). Only after CONV1+2 prove the services in production. + +## 8. Downstream adoption phases (outline only — do not start; the §5 invariants are what make these pure additions) + +- **DS1 — Consume:** mount OS v3 in the downstream distribution; verify session auth end-to-end; adapt the SPA's list controller to the v3 envelope/filter grammar (both offset-based — mechanical); migrate 1–2 read-heavy tables (findings first) behind a feature flag. +- **DS2 — Extend:** subclass schemas (EPSS, priority, …), extend filter specs, remount via router factories (I4/I5); replace restricted picker endpoints with `?fields=`. +- **DS3 — Write parity:** bulk operations (partial-success result shape: `{updated, skipped: [{id, reason}]}`) and workflow actions (`close`, `request_review`, `mark_duplicate`, …) as routers over the OS service layer; delete-impact preview; CSV export from the filter contract. +- **DS4 — Aggregation:** dashboard and metrics needs served by a **small set of generic aggregation primitives** (group-by, time-bucket, top-N) taking the identical filter params (D6) and returning chart-neutral data — never per-chart-type endpoints whose envelopes couple the API to one charting library; chart shaping moves to the client. New `include=` members where cheap. +- **DS5 — App services:** saved table preferences (backed by the structured filter representation, D6), permission map, global search. Any bootstrap-style mega-endpoint is **split into small cacheable resources** when it moves — do not port a monolith. +- **DS6 — Retirement:** legacy private API surfaces retired domain-by-domain; v2 deprecation window last. + +## 9. Appendix — downstream capability checklist (from the downstream-consumer audit) + +| # | Capability | Disposition | +|---|---|---| +| 1 | `{count,next,previous,results}` + limit/offset (max 250) | **OS1** (D4) | +| 2 | Filtered-list totals in `meta` | **OS1** `include=counts` (D6) | +| 3 | Relation inlining (v2 `?prefetch=`, 30+ SPA call sites) | **OS1** refs+`expand` — strictly better | +| 4 | In-band memoized nested objects | **Rejected** (D3) — no mainstream precedent; gzip; client machinery gets deleted, not ported | +| 5 | Bulk ops w/ per-item skip reasons | DS3 (services designed for it: I6) | +| 6 | Workflow actions (close/review/duplicate/…) | DS3 (same service extractions as OS3) | +| 7 | Dashboard aggregation engine | DS4 (enabled by D6) — as generic group-by/time-bucket/top-N primitives, not per-chart-type endpoints | +| 8 | Metrics/insights rollups | DS4 | +| 9 | Per-object count endpoints | Covered by `include=counts` pattern | +| 10 | Saved views / table preferences | DS5 | +| 11 | django-filter vocabulary + multi-sort + match modes | **OS1/OS2** filter contract (D6) | +| 12 | Restricted/picker list variants | Covered by `?fields=` + RBAC querysets | +| 13 | Async import flag / job polling / celery status | Import facade OS1; **generic `/jobs/{id}` resource** (shape reserved in §4.13) replaces the three per-feature mechanisms observed downstream — fire-and-forget import, report `status` polling, a separate task-status endpoint. Poll-based OK — no websockets required downstream | +| 14 | Delete-impact preview | DS3 (`GET //{id}/delete-preview` fits contract) | +| 15 | CSV/XLSX export | DS3 (falls out of filter contract) | +| 16 | Global search + `meta.by_type` | DS5 | +| 17 | Auth bootstrap + permission map | DS5 (session auth ready since OS1: D8) | +| 18 | Unix-ms datetimes / null-stripping / preformatted users | **Rejected** (D9) — client-side adaptations | +| 19 | Error contract for field errors + retry markers | **OS1** problem+json with `fields` extension (D9) | + +## 10. Implementer instructions (read before writing code) + +1. **Never modify v2** (serializers, viewsets, urls, prefetch) or any existing test. v3 is additive. The only shared-file edits allowed: `dojo/urls.py` (mount), `requirements.txt`, settings (new constants `COUNT_CAP`, `EXPAND_BUDGET`). +2. **Contract is §4; invariants are §5.** If the contract is ambiguous or a model field doesn't match §4.5, do not improvise silently — pick the conservative option, implement it, and record the question + choice in §12. +3. **Tests:** all v3 tests live in the `unittests/api_v3/` package (base class `ApiV3TestCase`); run only via `./run-unittest.sh --test-case unittests.api_v3[.module] 2>&1 | tee /tmp/test_output.log`; analyze with `grep -E "PASSED|FAILED|ERROR" /tmp/test_output.log`; narrate each iteration (what failed, the fix, then re-run). Never `pytest`/`manage.py test` directly. Contract tests go through the Django test client (in-process, full stack); `ninja.testing.TestClient` only for kernel-internal units (§6 preamble). +4. **Reuse, don't rewrite:** RBAC only through `dojo//queries.py` `get_authorized_*` + `dojo.authorization` permission semantics; import only through `dojo/importers/*` wrapped by the new facade; JIRA through `dojo/jira/services.py`. Reference implementations to *read but not touch*: `dojo/finding/api/serializer.py:438-560`, `dojo/api_v2/serializers.py:526-551`, `dojo/finding/api/views.py:412-586`. +5. **Every list endpoint ships with an `assertNumQueries` test** proving query count is independent of row count, with and without `expand`. This is non-negotiable — it is the project's headline claim for v3. +6. **Lint:** ruff config is in-repo; run it before finishing a phase. Match surrounding code style; comments only for non-obvious constraints. +7. **Never commit** `CLAUDE.md`, anything under `.claude/`, or scratch/log files. +8. Phase order is OS1→OS6; do not start OS2 before the OS1 gate outcome is recorded in §12 **by the architect** — the gate is reviewed, never self-certified. Produce the gate report, then stop and wait. + +## 11. Verification summary (release bar for the OS PR) + +- Query-count regression suite green (constant queries, ±expand) — the headline claim, tested. +- Import equivalence: v3 `POST /import` (all three modes) reproduces v2 import/reimport DB state for identical payloads, incl. `close_old_findings`. +- RBAC parity: expand/include never leak unauthorized objects (port of prefetch-RBAC test intent). +- Contract tests: slim shapes (incl. denormalized finding parent refs, `locations_count`), expand permutations + cycle guard + budget, `fields`, `include=counts`, filter vocabulary snapshot, problem+json on every failure path, both auth modes. +- Locations: edge-status shape, URL-only, flag-off ⇒ no `/api/v3/`. +- OpenAPI generation + client codegen succeed (alpha-tagged). +- Benchmark artifact in PR: v2 `?prefetch=` vs v3 `?expand=` (query count + latency), 100-row findings list. +- Full existing v2 test suite untouched and green. + +## 12. Decision log & open questions + +| Date | Entry | +|---|---| +| 2026-07-19 | Plan created from the design spec + three audits (OS v2 API, downstream consumer usage, Location subsystem). Decisions D1–D10 recorded in §1; supersedes spec where they conflict (notably: finding↔location cardinality, pagination default, memoization rejection, importer-facade-as-new-work). | +| 2026-07-19 | Downstream-anchoring review ("current downstream usage is not written in stone"): four inherited habits replaced with better designs — (1) structured `POST //query` filter representation reserved; bespoke `_any`/`_all` suffix params banned (D6); (2) generic `/jobs/{id}` resource reserved for all async work instead of the three per-feature status mechanisms observed downstream (§4.13); (3) DS4 aggregation as chart-neutral primitives, not chart-library-shaped per-chart endpoints; (4) DS5 splits any bootstrap-style mega-endpoint into cacheable resources. Checked and kept deliberately: offset-default pagination (dual-mode envelope is the hedge if page-jump UX ever goes) and session+CSRF auth (independently correct for first-party SPAs). | +| 2026-07-19 | Duplication across UI views / v2 / v3 addressed via convergence strategy (D7, §7): v3 services are written for all three consumers but only v3 calls them in the alpha PR (stays additive); extraction reconciles serializer + UI-view semantics with divergences logged here; post-alpha CONV1 (v2→services) then CONV2 (UI views→services, behavior-pinning tests first) then CONV3 (delete dead duplicates). | +| 2026-07-19 | Alpha URL carries the stability marker: mount at `/api/v3-alpha/`, moving to `/api/v3/` at beta and staying there through GA (D1, §4.1). Rationale: URL is the only unmissable instability signal (K8s/Google precedent); structuring beta+GA on one URL limits clients to a single migration, timed to the additive-only contract freeze. Prefix is one constant; router factories make the remount a one-liner. | +| 2026-07-19 | Count strategy upgraded from "capped at COUNT_CAP" to hybrid exact→planner-estimate (D4, §4.3): above the cap, `count` becomes the Postgres `EXPLAIN` row estimate (PostgREST `count=estimated` technique), clamped to ≥ CAP+1, flagged `count_exact: false`. Rationale: real approximate numbers beat a "10000+" floor for pagination UX at ~zero extra cost; `pg_class.reltuples` rejected (whole-table only, useless under filters/RBAC). | +| 2026-07-19 | **OS1 GATE: GO on django-ninja — architect released all OS phases.** All 7 exit criteria met; benchmark shows v3 query count constant (5 no-expand / 7 with `expand=test.engagement`) at both 10 and 100 rows vs v2 scaling 91→271 (no prefetch) / 222→762 (`?prefetch=test`). Evidence in `.claude/os1-gate-report.md` (gate report reviewed by coordinator). Coordinator review found+fixed one pre-commit bug: `locations_count` needed `Count(..., distinct=True)` against `tags__in` joins. Latency benchmark deferred to OS6. | +| 2026-07-19 | **Trailing slash decided: NO trailing slash** on route paths (`/findings`, `/findings/{id}`, `/import`) — ninja-idiomatic, matches GitHub/Stripe convention. §4.3 example updated. `next`/`previous` are opaque (D4) so this binds only documentation and client codegen. | +| — | **OPEN:** exact `django-ninja`/`pydantic` pins + license/supply-chain sign-off. **OS1 implementer note:** pinned `django-ninja==1.6.2` (latest stable 1.x at implementation; MIT). It requires `pydantic>=2,<3`; `pydantic==2.13.4` (+`pydantic-core==2.46.4`, MIT) is already present in the image as a transitive dependency, so no *new* top-level pin was added for pydantic. Both are widely used, actively maintained. | +| — | **OPEN:** `COUNT_CAP`/`EXPAND_BUDGET` defaults (10 000 / 10 proposed) — revisit after benchmarks. **OS1 implementer note:** implemented as `DD_API_V3_COUNT_CAP=10000` and `DD_API_V3_EXPAND_BUDGET=10` env settings (`API_V3_COUNT_CAP`/`API_V3_EXPAND_BUDGET`), settings-overridable per §4.3/§4.6; tests exercise a lowered cap. | +| 2026-07-19 | **OS1 impl — CSRF wiring (§4.2 conservative choice):** §4.2 specifies `NinjaAPI(csrf=True)`, but `django-ninja` 1.6.x removed the `csrf` parameter from `NinjaAPI.__init__`. Conservative choice: rely on ninja's `SessionAuth` (`ninja.security.django_auth`), which defaults `csrf=True` and enforces CSRF on unsafe methods for cookie/session auth, while `TokenAuth` (an `APIKeyHeader`) requires no CSRF. This achieves the D8 contract exactly (token bypasses CSRF; session enforces it) — verified by `test_apiv3_auth`. No `csrf=` kwarg is passed to `NinjaAPI`. | +| 2026-07-19 | **OS1 impl — query-count fidelity (beyond §6 literal base-queryset):** §6 OS1 lists the findings base queryset as `select_related(...) + annotate(locations_count)`. To keep the headline query count constant, the route also `prefetch_related("tags")` (FindingSlim exposes `tags[]`, §4.5) and the `expand` planner additionally pre-loads each expand-target schema's own `SELECT_RELATED`/`PREFETCH_RELATED` (e.g. `test__tags`) so serializing expanded objects issues no per-row queries. Purely additive to the spec; required by criterion 1. | +| 2026-07-19 | **OS1 impl — expand-target slim schemas placement:** §6 OS1 lists only `FindingSlim`/`FindingDetail` in `dojo/finding/api_v3/schemas.py`, but `?expand=test.engagement` needs slim schemas for the targets. `TestSlim`, `EngagementSlim`, `ProductSlim`, `ProductTypeSlim`, `UserSlim`, `TestTypeSlim`, `EnvironmentSlim` are defined there for OS1 (findings-only). OS3 relocates the canonical copies to their resource modules (`dojo//api_v3/schemas.py`); these will re-export or move. | +| 2026-07-19 | **OS1 impl — login-redirect exemption (settings):** DefectDojo's `LoginRequiredMiddleware` redirects unauthenticated requests to `/login` unless the path matches `LOGIN_EXEMPT_URLS`. v3 was appended to that list (mirroring `^api/v2/`) so anonymous v3 requests get a 401 problem+json instead of a UI redirect. Uses the single-source `API_V3_URL_PREFIX` constant. | +| 2026-07-19 | **OS1 impl — route path trailing slash (§4.3 example vs implementation):** routes are registered without a trailing slash (`/findings`, `/findings/{id}`, `/import`) — django-ninja's idiomatic convention and it resolves cleanly under `APPEND_SLASH`. The §4.3 example shows `.../findings/?...` with a trailing slash; this does not bind clients because `next`/`previous` are opaque URLs (D4) built from the actual request path. Flagged for architect confirmation; switching to trailing slashes is a one-line change per route if preferred (would then need the v2-style POST-trailing-slash middleware handling). | +| 2026-07-19 | **OS1 impl — response rendering:** routes return a pre-built `JsonResponse` (custom encoder gives ISO-8601 `Z` datetimes + `X-API-Status` header) which ninja passes through unchanged, so `?expand=`/`?fields=` can reshape `results` dynamically while the declared `response=` schema still documents the base shape in OpenAPI. Base slim serialization is schema-driven (`Schema.model_validate(obj)` runs ninja resolvers), so a subclassed schema serializes its new fields automatically (I4). | +| 2026-07-19 | **OS2 impl — severity ordering direction (§4.9 ambiguity, conservative choice):** `o=severity` sorts by *rank* (Critical first) and `o=-severity` reverses it (Info first). §4.9 names the rank order "(Critical>High>Medium>Low>Info)" but does not say which sign is "ascending"; resolved so plain `o=severity` yields exactly that listed order (also DefectDojo's canonical display order and `Finding.Meta.ordering`). Implemented in `filtering.py` as a query-time `Case/When` (`SEVERITY_RANK` Critical=0…Info=4) exposed via `FilterSpec.order_expressions`, mirroring v2's `numerical_severity` ordering but *computed at query time* so it never depends on the denormalized `numerical_severity` column being populated. | +| 2026-07-19 | **OS2 impl — unknown filter param → 400 (§6 OS2 item 2, conservative choice):** django-filter silently ignores query params it doesn't declare; §6 OS2 says "keep unknown-filter 400 behavior". Resolved by making `apply_filters` reject any query param that is neither reserved (`RESERVED_PARAMS`) nor a declared filter with a 400 `filter` problem+json, strengthening the single filter contract (D6). Caveat: cache-buster/tracking params (e.g. jQuery's `_=`) would 400 — acceptable for an alpha API and consistent with a strict typed contract. | +| 2026-07-19 | **OS2 impl — `?fields=` gets its own error type (I9):** unknown `?fields=` values previously reused the `expand` problem type; OS2 adds a dedicated `fields` type URI (`.../errors/fields`) so the closed error contract has one type per error kind (I9). Pure add; no shape change. | +| 2026-07-19 | **OS2 impl — `expand=locations` edge-row shape (§4.6/§4.14 reconciliation):** `expand=locations` on a finding renders `[{location: {id,name,type}, status, audit_time}]` and *replaces* `locations_count` in the output (§4.6 "swaps"), driven by `prefetch_related("locations__location")` (query count stays constant, verified by test). §4.14's additional `auditor{ref}` on finding-location rows is deferred to the OS4 `/findings/{id}/locations` sub-resource (OS2 delivers only the `expand=` projection per the OS2 task's explicit shape). Implemented generically via `ExpandRel.special`/`prefetch_paths`/`replaces` so any resource can declare a computed, prefetch-backed, count-replacing expansion. | +| 2026-07-19 | **OS2 impl — filter-contract snapshot storage (read-only test FS):** `unittests/api_v3/snapshots/filters.json` is checked in and compared on every run; the auto-create-on-missing branch of `test_apiv3_filter_contract` only works on a writable FS (the docker test bind-mount is read-only), so the baseline is generated on the host. Deliberate regeneration: `DD_API_V3_UPDATE_SNAPSHOTS=1` (host run). | +| 2026-07-19 | **OS2 impl — I5 dependency direction, verified:** the seven reusable kernel machinery modules (`auth`, `errors`, `expand`, `filtering`, `include`, `pagination`, `refs`) import **zero** resource/service modules. The only resource import under `dojo/api_v3/*` is a **deferred** import inside `api.build_api()` (the composition root/mount that wires the OS router factories) and `import_routes.py`'s use of `dojo.importers.*` (services, explicitly allowed). Reusable machinery stays resource-agnostic, keeping I5 honest; whether `api.py` (the mount) belongs in the kernel package or a separate mount package is left as an OS-cleanup question for the architect. | +| 2026-07-19 | **OS3a scope + no PUT (§6 OS3, decision recorded per task):** CRUD for the three simpler resources `product_type`, `product`, `user` shipped (`dojo//api_v3/{schemas,routes}.py` + `build__router()` factories, mounted in `build_api()`). Each exposes `GET` list, `GET /{id}`, `POST`, `PATCH /{id}`, `DELETE /{id}`, no trailing slash. **No `PUT` in alpha** — PATCH-only partial update (pydantic `model_dump(exclude_unset=True)`); a full-replace `PUT` is an additive, non-breaking addition available later if a consumer needs it. Engagement/test/finding **writes** are explicitly OS3b, not delivered here. | +| 2026-07-19 | **OS3a impl — canonical slim relocation (fulfils the OS1 §12 placement note):** `ProductTypeSlim`/`ProductSlim`/`UserSlim` moved from `dojo/finding/api_v3/schemas.py` to their resource modules (`dojo//api_v3/schemas.py`); the finding module now imports (re-exports) them so there is exactly **one** canonical class per model — verified at runtime that `finding.api_v3.schemas.ProductSlim is product.api_v3.schemas.ProductSlim` (and likewise product_type/user), so finding `expand=` targets and the resource endpoints serialize through the same schema (I4). No duplicate class. `TestSlim`/`EngagementSlim`/`TestTypeSlim`/`EnvironmentSlim` stay in the finding module (their resources are OS3b). Import direction `finding → product → product_type`, `finding → user` (no cycle). All 65 OS1/OS2 tests stay green. | +| 2026-07-19 | **OS3a v2-semantics finding — deletion (mirrored exactly):** `ProductTypeViewSet.destroy` / `ProductViewSet.destroy` both do: `async_delete().delete(instance)` when `get_setting("ASYNC_OBJECT_DELETE")` is truthy, else a **synchronous** `instance.delete()` wrapped in `with Endpoint.allow_endpoint_init():` (the `Endpoint` context is required while `V3_FEATURE_LOCATIONS` is on, because `Endpoint.__init__` otherwise raises during the delete cascade). v3 `DELETE` reproduces this verbatim (`_destroy()` in each routes module). `UsersViewSet.destroy` is different: **no async, no Endpoint context** — a plain `instance.delete()`, except a user may **not delete themselves** (v2 returns 400 "Users may not delete themselves"); v3 mirrors this (400 problem+json, self left intact). | +| 2026-07-19 | **OS3a v2-semantics finding — user visibility (v2-parity `view_user` gate + guaranteed self-read; coordinator-corrected):** §6 OS3 says "Users: read + self; admin-only writes". v2 `UsersViewSet` gates all access behind `UserHasConfigurationPermissionSuperuser` (DjangoModelPermissions: `auth.view_user` for GET) and returns `User.objects.all()`. v3 `_base_queryset` mirrors this (conservative per §10.2, never widening PII/email exposure beyond v2): holders of `auth.view_user` (superusers/staff via the OS bypass; a non-staff holder via explicit grant) get the RBAC-scoped `get_authorized_users("view", user=request.user)` queryset — for a non-staff holder that is the collaborator subset (co-members of their authorized products/types, **plus superusers**), i.e. **≤** v2's "all users" exposure; **everyone else sees only themselves** (`Dojo_User.objects.filter(pk=request.user.pk)`), so a plain user lists exactly their own record and `GET /users/{own id}` always resolves (the previous collaborator-for-all design was rejected: it exposed email to every authenticated user and 404'd a plain user's own record). **Writes** stay admin/superuser-only via the `auth.add_user`/`change_user`/`delete_user` configuration permissions (`user_has_configuration_permission`; superusers pass automatically). `UserSerializer.validate()` rules ported verbatim into the route: only superusers may add/edit superusers or staff; `password` is write-only and **cannot** be changed via PATCH (400); a password is required on create when `REQUIRE_PASSWORD_ON_USER` (400 otherwise); password run through Django `validate_password`. `configuration_permissions` (a v2 write field) is intentionally **out of the alpha write surface** (not in the read shape; M2M member-management concern) — additive later. | +| 2026-07-19 | **OS3a v2-semantics finding — write permission checks (mirror the v2 permission classes exactly):** `product_type` (`UserHasProductTypePermission`): create = `user_has_global_permission(user, "add")` (the literal string `"add"` is kept, not the `Permissions` enum, because the `dojo.add_product_type` config-permission carve-out keys on `permission == "add"`); update = object `edit`; delete = object `delete` (⇒ **staff-only** for a non-staff member under the legacy model, so a member can view/edit but a member `DELETE` is 403 — used as the "authorized-to-see-but-not-modify" 403 test). `product` (`UserHasProductPermission`): create = `add` on the target `prod_type` in the payload (404 if that product type doesn't exist, 403 if unauthorized — mirrors `check_post_permission`'s `get_object_or_404`); update = object `edit` **plus** `add` on a *reassigned* `prod_type` (re-checked only when the FK actually changes, mirroring `check_update_permission`); delete = object `delete`. Reads resolve the object through the authorized `view` queryset first → **404** for unknown-or-unauthorized, **403** only when visible-but-not-modifiable. `permission_to_action` was verified to map the `Permissions` enum and the action string identically, so either form is safe for the object checks. | +| 2026-07-19 | **OS3a impl — write-schema strictness + shapes:** create/update schemas are ninja `Schema`s with `model_config = {"extra": "forbid"}`, so an unknown body field is a **400** problem+json — consistent with the kernel's strict query contract (unknown `filter`/`fields`/`expand` params also 400; I9). Required-vs-optional mirrors the v2 serializers: `ProductType` requires `name`; `Product` requires `name`+`description`+`prod_type` (`description` is a non-null model field; `sla_configuration` falls back to the model default when omitted); `User` requires `username`+`email` (+password per `REQUIRE_PASSWORD_ON_USER`). Relations by integer id (§4.11). §4.5 does not enumerate detail fields for these resources, so the conservative additions are: `ProductDetail` += `business_criticality, platform, origin, external_audience, internet_accessible` + `product_manager/technical_contact/team_manager` refs; `UserDetail` += `is_staff, date_joined`; `ProductTypeDetail` == slim (no heavier fields). Filter vocabularies follow §4.9's minimal template (`id__in`, `name__icontains`, parent FK where present, date ranges; orderings id/name/created/updated) and are captured in the regenerated `snapshots/filters.json` (diff = exactly the three new resources, 73 insertions / 0 deletions, finding section untouched). Per-list-endpoint `assertNumQueries`-style constant-query tests pass (product_types 2, users 2, products 3 queries, unchanged at 10 vs 100 rows; products constant with `expand=product_type` too). | +| 2026-07-19 | **OS3a impl — no service layer for the three simple resources (I6 note):** unlike finding writes (which own JIRA/risk/notification side-effects and get a `dojo/finding/services.py` in OS3b), product_type/product/user writes are plain ORM CRUD with no cross-cutting orchestration, so the (thin) create/update/delete logic lives directly in the route factories. The one real side-effect — the delete strategy (async vs sync-inside-`Endpoint.allow_endpoint_init()`) — is a per-route `_destroy()` helper mirroring v2 exactly. `204 No Content` responses return an empty body via `HttpResponse(status=204)` (not JSON `null`) while still carrying `X-API-Status` (§4.1); the kernel `errors.py` was **not** extended (only `api.py` mount was touched, per §10). | +| 2026-07-19 | **OS3b scope + canonical-slim relocation:** engagement + test CRUD (`dojo/engagement/api_v3/`, `dojo/test/api_v3/`) and the finding write path shipped. `EngagementSlim` relocated to `dojo/engagement/api_v3/schemas.py`; `TestSlim`/`TestTypeSlim`/`EnvironmentSlim` relocated to `dojo/test/api_v3/schemas.py`; the finding module now **re-exports** all four so there is exactly one canonical class per model (is-identity asserted in `test_apiv3_engagements.TestApiV3EngagementsRelocation` and `test_apiv3_tests.TestApiV3TestsRelocation`, mirroring the OS3a pattern). Import direction stays acyclic: `finding → {engagement, test}`, `test → engagement`, `engagement → {product, product_type, user}`. No `PUT` (PATCH-only, per the OS3a decision). Snapshot regenerated additively: `filters.json` = +engagement +test (66 insertions / 1 formatting deletion; finding/product/product_type/user sections unchanged, verified by the order-independent contract test). All 197 `unittests.api_v3` tests green. | +| 2026-07-19 | **OS3b v2-semantics finding — engagement/test write permissions (mirror the v2 permission classes exactly):** `engagement` (`UserHasEngagementPermission`): create = `add` on the target `product` in the payload (404 if the product doesn't exist, 403 if unauthorized — mirrors `check_post_permission(request, Product, "product", "add")`); update = object `edit` **plus** `add` on a *reassigned* `product` (re-checked only when the FK actually changes, mirroring `check_update_permission`); delete = object `delete` (⇒ **staff-only** for a non-staff member under the legacy model). `test` (`UserHasTestPermission`): create = `add` on the target `engagement` (404/403) **plus** `view` on `api_scan_configuration` when present (mirrors the `required=False` sibling `check_post_permission`); update = object `edit` **plus** `view` on a *reassigned* `api_scan_configuration` (mirrors `check_update_permission(request, obj, "view", "api_scan_configuration")`) — note the update path does **not** re-authorize any `engagement` reassignment because `engagement` is `editable=False` and therefore not writable on update (mirrors v2, where the update serializer treats it read-only); delete = object `delete` (staff-only). Reads resolve through the authorized `view` queryset first → **404** for unknown-or-unauthorized, **403** only when visible-but-not-modifiable. `Permissions.Engagement_Add`/`Test_Add`/`Finding_Add` map to `Action.Add` identically to the literal string `"add"` (verified via `permission_to_action`), so either form is safe. | +| 2026-07-19 | **OS3b v2-semantics finding — engagement/test deletion (mirrored exactly; differs from product/product_type):** `EngagementViewSet.destroy` and `TestsViewSet.destroy` both do `async_delete().delete(instance)` when `ASYNC_OBJECT_DELETE` is truthy, else a **plain** synchronous `instance.delete()` — crucially with **no** `Endpoint.allow_endpoint_init()` wrapper (unlike `ProductViewSet`/`ProductTypeViewSet` destroy, which do wrap it). v3 `_destroy()` in each routes module reproduces this verbatim: no Endpoint context for engagement/test. (Cascade to findings under `V3_FEATURE_LOCATIONS` goes through `LocationFindingReference`, not the legacy `Endpoint`, so the wrapper is unnecessary here — and mirroring v2 exactly is the mandate.) | +| 2026-07-19 | **OS3b impl — engagement/test write-schema shapes (editable subset, conservative FK set):** required-vs-optional mirrors the v2 `ModelSerializer` requiredness rule (`required = not (blank or has_default or null)`): `Engagement` requires `product`+`target_start`+`target_end`; `Test` requires `engagement`+`test_type`+`target_start`+`target_end` (`environment` is `null=True` ⇒ optional). `EngagementSerializer.validate` (POST: `target_start > target_end` → 400) ported to `_validate_dates`, applied on create and on any update that changes either date. Conservative FK subset in the write surface (additive later, per §10.2): engagement exposes `lead` only (dropped `preset`/`report_type`/`requester`/`build_server`/`scm_server`/`orchestration_engine` tool-config FKs — niche, and each needs its own resolve+validate); test exposes `test_type`/`environment`/`lead` (resolved by id → 400 on bad pk, mirroring DRF `PrimaryKeyRelatedField`) and `api_scan_configuration` (permission-gated: 404 if absent, 403 without `view`). `EngagementDetail`/`TestDetail` add conservative heavier read fields (§4.5 doesn't enumerate them): engagement += description/version/first_contacted/reason/tracker/test_strategy/threat_model/api_test/pen_test/check_list/build_id/commit_hash/branch_tag/source_code_management_uri/deduplication_on_engagement; test += description/scan_type/version/build_id/commit_hash/branch_tag (`api_scan_configuration` left out of the read shape — its model has no `Ref` label registry entry; additive later). Per-list `assertNumQueries`-style constant-query tests pass, including with `expand`. | +| 2026-07-19 | **OS3b — finding write service (D7 flagship extraction) reconciliation + divergences:** `dojo/finding/services.py` provides `create_finding`/`update_finding`/`delete_finding` (I6: keyword-only, explicit `user`, no HTTP context; validation raises `rest_framework.exceptions.ValidationError` — the same exception the reference serializer raises — which the kernel's DRF boundary adapter maps to 400 problem+json, §12 OS1). It reconciles the v2 serializer path (`FindingSerializer`/`FindingCreateSerializer` `.update`/`.create`/`.validate`, `dojo/finding/api/serializer.py:328-744`) with the UI flow (`EditFinding` in `dojo/finding/ui/views.py:718-1053`). **The serializer path is the chosen canonical for the API** (the plan names it as the reference). UI-only side-effects are consciously **deferred to the convergence track** (CONV2), not baked into the API. Full divergence table in `.claude/os3b-report.md`. The material divergences and chosen canonical behavior: **(1) risk-acceptance** — serializer's `process_risk_acceptance` (simple_risk_accept/risk_unaccept, gated on `enable_simple_risk_acceptance`, run in validate) is canonical; the UI's `perform_save=False` + explicit `finding.save()` variant is deferred. **(2) JIRA push** — canonical = serializer semantics: update pushes synchronously with `force_sync=True` and raises on failure (`push_to_jira or is_keep_in_sync`); create pushes without force_sync; the route OR-s `push_to_jira` with `jira_project.push_all_issues` (mirrors `perform_update`). UI-only jira link/unlink/change-key and finding-group push are deferred. **(3) status side-effects** — the UI's `process_mitigated_data` (auto-`is_mitigated`+location-edge mitigation when active flips off), `process_false_positive_history` (retroactive FP reactivation), `last_reviewed`/`last_reviewed_by` stamping, burp req/resp, finding-group handling, and github are **all UI-only and deferred**; the API mirrors only the serializer's field-level updates. **(4) notifications** — create dispatches the `finding_added` notification exactly as the serializer does; update dispatches none (matches the serializer). **(5) `numerical_severity`** — not set in the service (the model's `save()` computes it; the UI sets it explicitly, redundantly). | +| 2026-07-19 | **OS3b impl — finding write specifics (conscious choices):** **(a) `cve` mirror** — the v3 write schema exposes a flat `vulnerability_ids: list[str]` (§4.11), not the v2 nested `vulnerability_id_set`; `create_finding` writes `vulnerability_ids[0]` into `Finding.cve` **before** the initial `save()` (mirroring the serializer, which sets `validated_data["cve"]` pre-save) because `save_vulnerability_ids` only sets `finding.cve` in memory; `update_finding` calls `save_vulnerability_ids` before `finding.save()` so the mirror persists. Empty/absent `vulnerability_ids` is a no-op (mirrors the serializer's truthy guard). **(b) CWE** — v3 exposes a scalar `cwe` (not the v2 nested `cwes` list). Create always persists the primary `Finding.cwe` as a `Finding_CWE` row (`save_cwes`, mirrors serializer). On **update**, `save_cwes` is called whenever the scalar `cwe` is in the change set (the closest analog to the v2 `finding_cwe_set`-provided guard) so `Finding.cwe` and its `Finding_CWE` rows stay consistent — this resyncs to just the primary CWE, wiping any extra imported `Finding_CWE` rows (documented divergence: v2's scalar-cwe update alone does not touch `Finding_CWE`). **(c) `delete_finding`** — mirrors `FindingViewSet.destroy`'s dedup/grading hooks via the model's `Finding.delete()` (`finding_helper.finding_delete` + `perform_product_grading`); the plan-mandated signature `delete_finding(finding, *, user)` omits v2 destroy's `push_to_jira` query-param (niche delete-time JIRA closure push) — additive later. **(d) mitigated-edit rules** — `can_edit_mitigated_data` (requires `EDITABLE_MITIGATED_DATA` **and** superuser) gates any attempt to set non-null `mitigated`/`mitigated_by`, on both create and update (ported verbatim from both serializers). | +| 2026-07-19 | **OS3b — RBAC 403 test shape under OS legacy auth (view-but-not-edit not expressible):** the task asks for a "403 view-but-not-edit" finding test, but the OS legacy authorization model (`user_has_permission`) grants View/Edit/Add **equally** to any `authorized_users` member (only Delete is staff-gated). So a member who can *see* a finding/engagement/test can also *edit* it — a view-but-not-**edit** 403 is not expressible without a granular RBAC role model. Conservative resolution (mirrors the OS3a product RBAC test): the "authorized-to-see-but-not-modify" 403 is demonstrated on **delete** (member GET 200, member DELETE 403 staff-only), and the 404-for-unauthorized case is tested with a non-member (`limited`) user. Recorded so the API contract test is not mistaken for asserting an edit/view permission split that OS legacy does not implement. | +| 2026-07-19 | **OS4 scope + sub-resource placement:** read-only Locations resource (`GET /locations`, `GET /locations/{id}`) + the `GET /findings/{id}/locations` and `GET /products/{id}/locations` edge sub-resources shipped in a new `dojo/location/api_v3/{schemas,routes}.py`. **All three routers are factories (I5)** mounted in `build_api()`. **Placement choice:** the finding/product *location* sub-resources live in the **location** module (not the finding/product route factories) so all location code stays cohesive and the finding/product factories are untouched (lower regression risk); each is an independent `build__router()` and the `/findings/{id}/locations` path resolves cleanly alongside `/findings/{id}` (distinct URL patterns). Read-only: locations are import-driven (§4.14); no write routes. All 229 `unittests.api_v3` tests green (198 prior + 31 new). | +| 2026-07-19 | **OS4 v2-semantics finding — Location RBAC (verified superuser-only; mirrored):** the v2 `LocationViewSet` (`dojo/location/api/views.py`) is a `ReadOnlyModelViewSet` with `permission_classes = (IsSuperUser, DjangoModelPermissions)` — AND-ed, and `IsSuperUser.has_permission` = `request.user.is_superuser`, so the resource is **superuser-only** (confirmed). v3 `/locations` mirrors this exactly: the router gates the whole resource behind `request.user.is_superuser` → **403** problem+json for any non-superuser (both list and detail), and draws rows from `get_authorized_locations("view", user=...)` (I8, forward-compatible — a downstream distribution can scope the queryset without a route change; in OS that helper returns all locations, matching v2's `Location.objects.order_by_id()`). The **sub-resources** are not superuser-gated: they use **parent-inherited authorization** — the parent finding/product is resolved through `get_authorized_findings`/`get_authorized_products` and an unknown *or unauthorized* parent is a **404** (never leak existence, §4.10); edges are then read from the parent's own reverse manager (`finding.locations` / `product.locations`), matching the v2 `LocationFindingReference`/`LocationProductReference` viewsets' RBAC-scoped querysets. | +| 2026-07-19 | **OS4 — product edge shape reconciliation (§4.14 shorthand vs model):** §4.14 lists both finding- and product-location edge rows as `{location, status, audit_time}`, but `LocationProductReference` has **no** `audit_time`/`auditor` columns (only `status`, `relationship`, `relationship_data`). Resolved per the OS4 task's explicit item-2 shapes and the model: **finding** edge = `{location: {id,name,type}, status, audit_time, auditor: {id,name}|null}`; **product** edge = `{location: {id,name,type}, status}`. `LocationRef` (the one ref subtype carrying `type`) is emitted for the `location` field. | +| 2026-07-19 | **OS4 — `auditor` added to `expand=locations` edge rows (supersedes the OS2 deferral):** OS2 (§12) deferred the `auditor{ref}` on finding-location rows to OS4; the OS4 task adds it to **both** the `/findings/{id}/locations` sub-resource **and** the existing `expand=locations` projection. `_finding_location_edges` now emits `{location, status, audit_time, auditor}` with `prefetch_paths=("locations__location", "locations__auditor")` so the query count stays constant (verified). The pre-existing `test_apiv3_findings.test_expand_locations_swaps_count_for_edge_rows` edge-key assertion was updated to include `auditor` (a v3 test reflecting the new intended contract, not a v2 test). | +| 2026-07-19 | **OS4 — `?fields=` / `?expand=` interplay (OS2 open question, coordinator decision):** the `?fields=` allowlist is now **schema fields ∪ the schema's registered `EXPANDABLE` keys**, implemented as one kernel helper `dojo.api_v3.expand.allowed_field_names(schema)` (the single permitted kernel edit besides the mount) and applied at **all** resource list/detail `parse_fields` call sites (findings/products/product_types/users/engagements/tests/locations) so the contract is uniform. Effect: `?expand=locations&fields=id,title,locations` works; a genuinely unknown name still 400s (dedicated `fields` type URI, I9); naming an expand key in `fields=` **without** `expand=` is accepted (no 400) but renders nothing (`apply_fields` keeps only keys present in the serialized dict). Purely widens the allowlist, so no existing 200 becomes a 400. | +| 2026-07-19 | **OS4 — flag-off behaviour tested in-process (D5):** `V3_FEATURE_LOCATIONS=False` ⇒ the whole `/api/v3-alpha/` tree is absent (the mount in `dojo/urls.py` is import-time conditional). Made cleanly testable via a **URLconf reload**: `test_apiv3_locations.TestApiV3LocationsFlagOff` reloads `dojo.urls` under `override_settings(V3_FEATURE_LOCATIONS=False)` (+`clear_url_caches()`), asserts every v3 path (`locations`/`findings`/`products`/`import`) raises `Resolver404` against the reloaded urlconf, then restores the real (flag-on) urlconf in a `finally` (reload + `clear_url_caches()`) so the rest of the suite is unaffected. Verified: the test passes and the other 228 tests stay green in the same run, so the reload does not pollute the shared test process. | +| 2026-07-19 | **OS4 — URL-subtype detail fields + query-count fidelity:** `LocationDetail` adds `protocol/host/port/path/query/fragment` read from the `URL` subtype via the reverse one-to-one (`location.url`, `related_name="%(class)s"`), loaded with `select_related("url")` on the detail fetch so the six resolvers issue **no** extra query; a non-URL location (none exist in alpha, D5) renders those fields `null` (the reverse-O2O accessor's `RelatedObjectDoesNotExist` is caught). Per-list `assertNumQueries`-style constant-query tests pass for `/locations`, `/findings/{id}/locations` and `/products/{id}/locations` (sub-resources use `select_related("location"[, "auditor"])` — constant regardless of edge count). Filter vocabulary (`type`, `name__icontains`, `product` via `products__product`; orderings `id`/`name`) captured in `snapshots/filters.json` as an **additive-only** diff (+`location` block, all other sections byte-identical; verified by the passing snapshot contract test). Whole-surface query sweep extended with the four new GET endpoints (with location-edge fan-out so per-row queries can't hide) and stays green. | +| 2026-07-19 | **OS5 scope + storage support matrix (coordinator scope correction: attach only where the MODEL stores it, verified against `dojo/*/models.py`; §4.12's "all seven resources" is wrong):** three generic factories in `dojo/api_v3/subresources.py` (`build_notes_router`/`build_files_router`/`build_tags_router`). Model storage: **notes** (`Notes` M2M) — engagement, test, finding; **files** (`FileUpload` M2M) — engagement, test, finding; **tags** (`TagField`) — product, engagement, test, finding, location. product_type/user have **none**. Attachment: notes+files on **finding/engagement/test**; tags on **finding/engagement/test/product**. **Location tags NOT attached** despite the `TagField`: location is a read-only, superuser-only resource (OS4) with **no v2 tag-mutation endpoint** to mirror, and its tags are already surfaced via `LocationSlim.tags[]` (§4.5) — a tag sub-resource would be a redundant read + an invented write (conservative, §10.2). Unsupported combos return **404** (path not registered); tested. Total surface: 13 new GET paths + POST/PUT/DELETE. All 252 `unittests.api_v3` tests green (229 prior + 23 new). | +| 2026-07-19 | **OS5 — note privacy = v2 parity (return all; `private` is not a per-user read filter):** verified v2's notes `@action` GET returns `parent.notes.all()` (no privacy filter) and the UI `comments.html` shows every note to any viewer; `private` only excludes a note from **generated reports** (`dojo/reports/ui/views.py` `finding.notes.filter(private=False)`). So v3 mirrors v2 exactly: the notes list returns **all** notes (private included) to anyone who can view the parent, with `private` exposed on `NoteSchema` so clients can label/exclude. Inventing per-user filtering would be a behavior divergence, not a mirror. Tested (`test_private_note_visible_to_other_authorized_user_v2_parity`). | +| 2026-07-19 | **OS5 — NoteSchema/FileSchema field mappings (model has no matching columns; conservative):** `NoteSchema.created`←`Notes.date`, `updated`←`Notes.edit_time` (the model has no `created`/`updated`; `edit_time` defaults to now at creation, and `edited` distinguishes a real edit). `note_type` is **out of the alpha write/read surface** (§4.12 POST body is `{entry, private?}`; the v2 single-note-type check is moot without it — additive later). `FileSchema.created` is always **`null`**: `FileUpload` has no creation timestamp column; `size`←`file.size` (a storage stat, not a DB query — keeps list query counts flat). | +| 2026-07-19 | **OS5 — tags sub-resource semantics (new convenience API; v2 has only `remove_tags`):** body is a JSON `{"tags": [...]}` list (§4.11), `extra="forbid"` (unknown field → 400). GET → `{"tags":[...]}`; **PUT** replaces, **POST** appends (order-preserving, case-insensitive dedup), both return `200 {"tags":[...]}`; **DELETE /tags/{tag}** removes one and returns `204`, **404 if absent** (the OS5 task's explicit contract — differs from v2 `remove_tags`'s 400-on-absent). Write path mirrors v2 `remove_tags` exactly: `parent.tags = ; parent.save()` — so tagulous `force_lowercase` normalization and the tag-inheritance signals fire identically (assigning a *list*, not a rendered string, is safe for tags with spaces/commas). The `{tag}` path param is lowercased before matching (stored tags are force-lowercase). No `FilterSpec` (parent-scoped, simple) ⇒ **no snapshot regeneration** (§6 OS5). | +| 2026-07-19 | **OS5 — file upload/download (mirror v2 validation):** extension validated via `FileUpload.clean()` against `settings.FILE_UPLOAD_TYPES` (mirrors v2 `FileSerializer.validate`); v2 enforces **no size cap**, so v3 doesn't either. `FileUpload.title` is globally `unique=True`, so a pre-save `exists()` check returns **400** on duplicate title (mirrors DRF `UniqueValidator`, avoids an IntegrityError→500). Download streams via `dojo.utils.generate_file_response` (correct content-type + `Content-Disposition: attachment`, mirrors v2 `download_file`); the `FileResponse` is returned through ninja unchanged with `X-API-Status` set manually. Multipart parsed with ninja `Form(...)`/`File(...)`. Roundtrip tested (bytes + disposition). | +| 2026-07-19 | **OS5 — parent-inherited authorization + permission values (mirror the v2 related-object permission classes exactly):** the parent is resolved through its `get_authorized_*` view queryset → **404** unknown-or-unauthorized (never leak existence, §4.10); the applicable permission is then checked on the parent via `user_has_permission` → **403**. Permission *values* mirror v2: notes GET/POST = `view` (`UserHas*NotePermission` post_permission is `view`); tags GET = `view`, PUT/POST/DELETE = `edit` (`UserHas*RelatedObjectPermission`); files GET/download = `Product_Tracking_Files_View`, POST = `Product_Tracking_Files_Add` (`UserHas*FilePermission`). **403-write not naturally expressible for members under OS legacy** (view==edit==add for `authorized_users` members; only Delete is staff-gated, and no sub-resource write maps to Delete — same finding as OS3b §12): the 403 code path is therefore tested by making the permission check fail (`mock` `user_has_permission`→False while the parent stays visible); the 404 path is tested with a real non-member. Parent-view resolvers: finding/product take `user=request.user`, engagement/test use crum's current user (matching their `get_authorized_*` signatures and the OS3 routes). | +| 2026-07-19 | **OS5 — kernel purity + wiring + unique operation ids:** `subresources.py` imports **no parent-resource model** (finding/product/…); the parent model/queryset/label/permissions all arrive as factory arguments. It imports only its **own** storage models (`Notes`/`NoteHistory`/`FileUpload`), the `generate_file_response` helper, and `user_has_permission` (authorization infra, not a resource/service module — same class as the kernel's existing rest_framework/django imports; I8). Wiring lives in `dojo/api_v3/api.py::_mount_subresources` (the composition root, alongside the existing deferred router-factory imports — consistent with the §12 OS2 "only `build_api()`/`import_routes.py` import resources under `dojo/api_v3/*`" rule). Inner view functions are registered **manually** (`router.get(path,...)(fn)`) after setting a unique `fn.__name__` per resource, so the once-per-resource factory calls don't collide into duplicate OpenAPI `operationId`s. Query sweep extended with all 13 new GET endpoints (notes/files/tags fan-out + a real file per parent for `download`); `capture_request` made streaming-aware (the download `FileResponse` has no `.content`). | +| 2026-07-19 | **OS6 — expand/include/sub-resource RBAC sweep (ports `test_apiv2_prefetch_rbac.py` intent):** `unittests/api_v3/test_apiv3_expand_rbac.py` (15 tests, all green). Builds a two-product world, authorizes a non-superuser on product A only (legacy `authorized_users` M2M), and proves **no** v3 projection discloses product B: the list/`?expand=test.engagement,product,product_type` rows are all product A; a product-B finding detail (even with `?expand=`) is **404**; `?product=` intersects the authorized queryset → empty; `?expand=reporter` never surfaces product B's distinctive reporter (the v2 4a user-enumeration analogue — note expand swaps the ref for `UserSlim`, keyed by `username`); `?include=counts` totals reflect only product A (B's severities stay 0, admin's total strictly exceeds the member's); and the `/findings/{id}/{notes,locations}` sub-resources + `expand=locations` return 404 for an unauthorized parent (parent-inherited authorization). Structural reason it holds: every projection is computed over `get_authorized_findings(...)`, so nothing reachable from an authorized finding can belong to another product (I8). | +| 2026-07-19 | **OS6 — latency + query-count benchmark (honest, in-process; deferred from OS1 gate).** Harness `unittests/api_v3/test_apiv3_benchmark.py`, CI-excluded via `@skipUnless(DD_API_V3_BENCH=1)`; run `docker compose exec -e DD_API_V3_BENCH=1 uwsgi python manage.py test unittests.api_v3.test_apiv3_benchmark`. Seeded 1000 findings on one test, `limit=100`, N=30 timed in-process GETs. **Results (real capture, 1021 findings):** v2 `?prefetch=test` = **636 queries, median 521 ms / p95 721 ms**; v2 no-prefetch = 229 q, 193/424 ms; **v3 slim = 5 q, 38/45 ms**; **v3 `?expand=test.engagement` = 7 q, 60/231 ms**. **CAVEATS (binding on the latency numbers):** in-process only — no HTTP/uwsgi/network/gzip layer, single-threaded, no pool warmup, shared test transaction; treat **latency as directional** and the **query counts (5/7 vs 636, constant vs row count) as the load-bearing, environment-independent evidence**. The wall-clock-against-uwsgi procedure in `.claude/os1-gate-report.md` remains the way to quote production latency. Full report: `.claude/os6-benchmark.md`. | +| 2026-07-19 | **OS6 — captured examples + docs page.** `api_v3_examples.md` (repo root) is generated by `unittests/api_v3/test_apiv3_examples.py` (CI-excluded, `DD_API_V3_EXAMPLES=1`) making **real** in-process requests; bodies verbatim, tokens redacted, lists truncated to ~3 rows. Covers findings (detail slim; `expand=test.engagement,locations`; filtered list + page-2 envelope showing non-null `next`/`previous`; `include=counts` meta; notes POST+GET; locations edge rows; PATCH; `POST /import`) and products (detail/list/POST/PATCH). Docs-site page: `docs/content/automation/api/api-v3-alpha-docs.md` (Doks/thulite theme, `weight: 3`, next to the v2 `api-v2-docs.md` — **not** the `asset_modelling` pages). Written in **plain markdown only** (blockquote instead of an `{{% alert %}}` shortcode) because the theme is a not-locally-installed Hugo module and an unknown shortcode would break the build; covers overview, alpha disclaimer (URL→`/api/v3/` at beta), token+session auth, envelope/refs/expand/fields/include/filter summary, the v2→v3 mapping table for the seven objects + import, one-way notes/tags/files, **Known alpha gaps**, and the `/api/v3-alpha/docs` link. | +| 2026-07-19 | **OS6 — Docs UI (Scalar) evaluation — coordinator decision: DEFER; alpha keeps ninja's built-in Swagger at `/api/v3-alpha/docs`.** Rationale: swapping to Scalar means vendoring an unreviewed third-party JS bundle into a **security-product** repo, which needs its own supply-chain review — the same class of decision as adding django-ninja (D2), not "polish". What the swap would take when approved: one template view (Hugo shortcode or a small Django `TemplateView`) rendering the Scalar ` + + +""" + + +def scalar_reference(request: HttpRequest) -> HttpResponse: + """Render the Scalar reference shell (no data of its own — the schema URL does the work).""" + return HttpResponse(_PAGE.format( + openapi_url=reverse("api_v3:openapi-json"), + docs_url=reverse("api_v3:openapi-view"), + cdn_url=SCALAR_CDN_URL, + sri_hash=SCALAR_SRI_HASH, + )) diff --git a/dojo/urls.py b/dojo/urls.py index b7ae627daca..7103e16dedb 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -217,8 +217,16 @@ # whole /api/v3-alpha/ tree is absent. The prefix and version live in settings (single source). if getattr(settings, "V3_FEATURE_LOCATIONS", False): from dojo.api_v3.api import api_v3 + from dojo.api_v3.reference_docs import scalar_reference api_v2_urls += [ + # Scalar reference (CDN + SRI, §12) must be registered BEFORE the NinjaAPI catch-all + # prefix so /reference is not swallowed by the API's 404 handling. + re_path( + r"^{}{}/reference$".format(get_system_setting("url_prefix"), settings.API_V3_URL_PREFIX), + scalar_reference, + name="api_v3_reference", + ), re_path( r"^{}{}/".format(get_system_setting("url_prefix"), settings.API_V3_URL_PREFIX), api_v3.urls, diff --git a/unittests/api_v3/test_apiv3_reference_docs.py b/unittests/api_v3/test_apiv3_reference_docs.py new file mode 100644 index 00000000000..a8adcfee311 --- /dev/null +++ b/unittests/api_v3/test_apiv3_reference_docs.py @@ -0,0 +1,44 @@ +""" +Scalar reference page (§12: supersedes the OS6 Scalar deferral — CDN + SRI, no vendored asset). + +The page is an HTML shell: the only executable content is the version-pinned CDN bundle whose +SRI hash the browser enforces. These tests pin that contract: exact pinned URL, integrity attr, +crossorigin, and the schema/docs URLs resolved by name (so the beta URL move carries them along). +""" +from __future__ import annotations + +from django.urls import reverse + +from dojo.api_v3.api import api_v3 +from dojo.api_v3.reference_docs import SCALAR_CDN_URL, SCALAR_SRI_HASH + +from .base import ApiV3TestCase + + +class TestApiV3ScalarReference(ApiV3TestCase): + + def _get(self): + return self.anonymous_client().get(reverse("api_v3_reference")) + + def test_page_serves_pinned_bundle_with_sri(self): + response = self._get() + self.assertEqual(200, response.status_code) + html = response.content.decode() + self.assertIn(SCALAR_CDN_URL, html) + self.assertIn(f'integrity="{SCALAR_SRI_HASH}"', html) + self.assertIn('crossorigin="anonymous"', html) + # Version must be pinned, never floating. + self.assertIn("@scalar/api-reference@", SCALAR_CDN_URL) + self.assertNotIn("@latest", SCALAR_CDN_URL) + + def test_page_points_at_v3_schema_and_swagger_fallback(self): + html = self._get().content.decode() + self.assertIn(f'data-url="{reverse("api_v3:openapi-json")}"', html) + # Swagger (locally-served assets) remains the offline-safe default, linked for noscript. + self.assertIn(reverse("api_v3:openapi-view"), html) + + def test_reference_page_is_not_an_api_operation(self): + # A plain Django view: it must NOT appear in the OpenAPI schema (and therefore places no + # obligations on the authz/query completeness gates, which walk the schema). + paths = api_v3.get_openapi_schema()["paths"] + self.assertFalse(any(p.endswith("/reference") for p in paths)) From 31ccc5bd13be0d5c109552a4a63c4bf7aa4fa930 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 21 Jul 2026 15:33:13 +0200 Subject: [PATCH 27/37] feat(api-v3): accept a cwes list on finding writes Closes the read/write asymmetry from the FindingSlim identity-fields change: FindingWrite/Update/Replace accept cwes: list[int], popped at the routes and passed as a service kwarg - fully parallel to vulnerability_ids. Contract (\$12): - precedence mirrors v2 and the vuln-ids->cve pattern: explicit non-None scalar cwe stays primary; else cwes[0] is mirrored into cwe BEFORE save so the primary persists; rows primary-first via the existing save_cwes helper (no duplicated label logic - plain ints go onto unsaved_cwes verbatim, the helper canonicalizes) - omission (None) never touches rows beyond the existing scalar-change resync (no-reset-on-omit, symmetric with vulnerability_ids/reporter) - explicit cwes: [] clears the extras, resyncing to the scalar-derived primary - an explicit statement, unlike omission - PUT subtlety: the full-replace dict always carries cwe (reset-default None), so the mirror keys off explicit-non-None, not key presence +9 tests (precedence, mirror, omission, empty-list, PUT semantics, read-back symmetry). 504 tests green (+6 skipped). --- API_V3_PLAN.md | 3 +- dojo/finding/api_v3/routes.py | 9 +- dojo/finding/api_v3/schemas.py | 15 ++- dojo/finding/services.py | 51 ++++++- unittests/api_v3/test_apiv3_finding_writes.py | 127 ++++++++++++++++++ 5 files changed, 192 insertions(+), 13 deletions(-) diff --git a/API_V3_PLAN.md b/API_V3_PLAN.md index c0bd5bb017a..1ef982bb8e1 100644 --- a/API_V3_PLAN.md +++ b/API_V3_PLAN.md @@ -356,7 +356,7 @@ layer and the closed contract, so all are additive: - [ ] delete-time `push_to_jira` param, `configuration_permissions` on user writes, v3 self-profile endpoint, filter vocabularies for the location-edge sub-resources (PUT full replace was pulled forward out of this bundle and shipped — §12, 2026-07-20) -- [ ] **Accept a `cwes` list on finding writes** — the read side exposes `cwes` (flat `list[int]`), but writes still accept only the scalar `cwe` (+ `vulnerability_ids`); make finding create/update accept a `cwes` list symmetric with the read shape (§12, 2026-07-21) +- [x] **Accept a `cwes` list on finding writes** — DONE: finding create/update/replace now accept a `cwes` list (flat `list[int]`, primary-first) symmetric with the read shape and parallel to `vulnerability_ids` (§12, 2026-07-21). - **Port the v2 endpoint-level test corpora to v3** (architect-requested). Priority order: - [x] **(1) the import/reimport corpus** (`test_import_reimport.py`'s mixin, `test_apiv2_scan_import_options.py`, `test_importers_closeold.py`) — DONE via the dual-endpoint adapter `unittests/api_v3/import_corpus_shim.py` @@ -569,3 +569,4 @@ itself (conservative, §10.2). *Subsequently closed:* the matching-policy read f | 2026-07-21 | **BACKLOG PRIORITY 3 — v2 API test-corpus evaluation delivered as the §9.1 disposition table** (no code, per the task). Every remaining `test_apiv2_*` + the named API-adjacent suites + an `APIClient`/`rest_framework`/`-list` scan were inventoried and classified PORTED / PORT-LATER / SKIP / SUPERSEDED, with `test_rest_framework.py` split by area. Two open **PORT-LATER** checkboxes remain (both new §6 checkboxes): (a) `TestDetail` matching-policy read fields (ports `test_apiv2_test_dedupe_policy.py`), (b) `authorized_users` member-management on asset writes gated by `Product_Manage_Members` (ports `test_product_authorized_users_api_authz.py`); `configuration_permissions`-on-user and the v3 self-profile endpoint (which also close `ConfigurationPermissionTest`/`UserProfileTest`) were already tracked in the pre-existing backlog bundle. Notable SUPERSEDED mappings: prefetch-RBAC→`test_apiv3_expand_rbac`, methods-and-endpoints smoke→OpenAPI guard + authz/query sweeps, per-endpoint SchemaChecker→OpenAPI guard, FK-reassign authz→v3 write RBAC re-check. **No trivial ports were implemented** — every PORT-LATER item needs new schema/route surface beyond the <30-min bar (§10.2 conservative). | | 2026-07-21 | **FINDINGSLIM EXTENDED WITH `vulnerability_ids` + `cwes` (architect directive — identity-critical fields available on lists without per-row detail fetches).** Two resolver-backed READ fields added to `FindingSlim` (so `FindingDetail` inherits them): **`vulnerability_ids: list[str]`** — flat strings (`["CVE-2020-1234", ...]`), NOT v2's object list; read directly from the `vulnerability_id_set` reverse relation in **storage order** (first = the id mirrored into `cve`), symmetric with what `FindingWrite` already accepts. **`cwes: list[int]`** — the numeric part of each `finding_cwe_set` row parsed via `dojo.finding.cwe.cwe_number`. **Shape choice `list[int]` (not the stored strings):** `Finding_CWE.cwe` is a `varchar(11)` that `save_cwes` always writes as the canonical `CWE-` label (via `finding_cwe_labels`/`cwe_label`), so the stored format is reliably `CWE-`; parsing to int is consistent with the slim's scalar `cwe: int | None`. **Primary is included:** `save_cwes` persists the primary `Finding.cwe` as a `Finding_CWE` row too (first), so reading the rows directly (no scalar-merge) yields the primary first — mirroring v2's `finding_cwe_set` row semantics. Both resolvers read only the prefetched reverse relations (mirror the `resolve_tags` pattern) — never a per-row query; empty lists (not null) when a finding has no rows. **related_names used:** `vulnerability_id_set` / `finding_cwe_set` (default reverse accessors — neither FK on `Vulnerability_Id`/`Finding_CWE` declares `related_name`; identical to v2's serializer `source=`). **Query-pin deltas (+2 fixed in-batch prefetches on findings lists):** `FindingSlim.PREFETCH_RELATED` grew from `("tags",)` to `("tags", "vulnerability_id_set", "finding_cwe_set")`. Offset findings list 5→7 queries; cursor 4→6 — `test_apiv3_cursor.EXPECTED_CURSOR_QUERIES` 4→6 (comment updated; the `offset−1==cursor` relation still holds). No other absolute pin exists (the `test_apiv3_findings_queries` tests are row-independence comparisons, still green; the whole-surface N+1 sweep threshold is 4 and the two new prefetches are distinct single-shot `IN` batches, so no shape repeats). **Defer:** `vulnerability_ids`/`cwes` are relations, not `model._meta.concrete_fields`, so `plan_list_fields` never enters them into the defer set (asserted); `?fields=` accepts them as ordinary slim names. **CSV:** the two list fields flatten to one semicolon-joined column each (`cwes`→`"79;89"`, `vulnerability_ids`→`"CVE-..;GHSA-.."`); `_FINDING_DEFAULT_COLUMNS` updated (inserted after `cwe`). **Write asymmetry (pre-existing, NOT changed here):** finding writes accept `vulnerability_ids` (flat, mirrored into `cve`) but only the scalar `cwe` — there is no `cwes` list on write. Documented + a backlog bullet added ("accept a `cwes` list on finding writes"); not implemented (directive). **Examples:** `api_v3_examples.md` regenerated via the CI-excluded harness (`DD_API_V3_EXAMPLES=1`); the new `cwes`/`vulnerability_ids` keys appear on every captured finding body. **Tests (+7):** `test_apiv3_findings` `_SLIM_KEYS` += both names + new `TestApiV3FindingsVulnIdsAndCwes` (2-vuln-id + 2-CWE-row render on list AND detail, empty-lists-not-null, `?fields=id,cwes,vulnerability_ids` projection = 4) + a defer-set assertion (1); `test_apiv3_findings_queries` new constant-query test (identity rows on every finding stay row-independent, 1); `test_apiv3_csv_export` header pin + new semicolon-join value test (1). Full `unittests.api_v3` = **476 executed + 6 skipped, 0 failures** (`/tmp/apiv3_slimext_full.log`; +7 over the 469 baseline). Ruff clean. No v2 code/UI/v2-test modified. | | 2026-07-21 | **TESTDETAIL MATCHING-POLICY READ FIELDS PORTED (§9.1 PORT-LATER→PORTED; ports `test_apiv2_test_dedupe_policy.py`).** `TestDetail` gains two READ-ONLY **computed** fields exposing the effective finding-matching policy: **`deduplication_algorithm: str`** (always non-null; `legacy` fallback) and **`hash_code_fields: list[str] \| None`** (`None` when the scan type has no per-scanner config). **Helper reused verbatim (never duplicated):** both resolvers return `obj.deduplication_algorithm` / `obj.hash_code_fields` — the v2 `Test` model **properties** (`dojo/test/models.py:156-193`), the exact settings-driven per-scanner lookup the v2 `TestSerializer` reads (`DEDUPLICATION_ALGORITHM_PER_PARSER` / `HASHCODE_FIELDS_PER_SCANNER`, keyed `test_type.name` then `scan_type`, `DEDUPE_ALGO_LEGACY` default). **Slim unchanged** (§4.5): these are config lookups, not identity fields, so `TestSlim` stays as-is. **Reject-vs-ignore deviation (the deliberate hardening):** the fields are **not** on `TestWrite`/`TestUpdate`/`TestReplace` (all `extra="forbid"`), so a PATCH or PUT supplying either → **400 unknown-field** problem+json; v2's serializer declares them `read_only` and therefore silently **ignores** writes to them (200, no change). v3 rejects because silent-ignore is the exact failure mode v3 rejects everywhere (same principle as unknown filter/expand/`fields=` params — D6/I9); the reject is atomic (a mixed PATCH bundling the policy fields with a real `description` change alters nothing). **Query impact = zero on detail:** the resolvers read only `test_type` (already `select_related` in `TestSlim.SELECT_RELATED`) and `scan_type` (a loaded concrete column — detail routes never `defer()`), verified with `assertNumQueries(0)`. **`?fields=` opt-up + defer handling (kernel accommodation, §4.7):** on a LIST these detail-only fields opt up via `?fields=`; being **resolver-backed (not `model._meta.concrete_fields`)** they never enter the defer set (asserted). But their resolvers read the concrete `scan_type` column, which is **deferred by default** on tests lists (§12 2026-07-20) — a naive opt-up would lazy-load it per row (N+1). Smallest correct accommodation: a new optional per-schema `DETAIL_FIELD_COLUMNS: dict[computed_field, tuple[concrete_column, ...]]` honored by the resource-agnostic kernel `plan_list_fields` (`dojo/api_v3/expand.py`) — requesting a computed detail field **un-defers exactly** the columns its resolver reads (analogous to `DETAIL_SELECT_RELATED`, but for own columns rather than relation joins). `TestDetail.DETAIL_FIELD_COLUMNS = {deduplication_algorithm: (scan_type,), hash_code_fields: (scan_type,)}`. Kernel change is additive (a schema without `DETAIL_FIELD_COLUMNS` behaves identically) and covers the CSV route too (it reuses `plan_list_fields`). `GET /tests?fields=id,name,deduplication_algorithm` verified **constant-query** (row-independent). **CSV:** flows automatically — `deduplication_algorithm` → one column, `hash_code_fields` (list) → one semicolon-joined column (empty when null); asserted. **Docs:** the v3 docs page does not enumerate per-object detail fields — no docs edit needed. **Tests:** new `unittests/api_v3/test_apiv3_test_dedupe_policy.py` (16): two scan types with distinct policies (ZAP Scan→`hash_code`+`[title,cwe,severity]`; Checkmarx Scan detailed→`unique_id_from_tool`+`None`), default-fallback (`legacy`+`None`), detail zero-extra-query, PATCH×2/PUT×2 reject + atomic reject-vs-ignore, list not-on-default + opt-up value-parity + defer-set + constant-query, CSV flatten. Full `unittests.api_v3` = **492 executed + 6 skipped, 0 failures** (`/tmp/apiv3_dedupe_full.log`; +16 over the 476 baseline). No new endpoints → the authz/query-report/OpenAPI completeness gates are unaffected and stay green. Ruff clean. No v2 code/UI/v2-test modified. | +| 2026-07-21 | **`cwes` LIST ACCEPTED ON FINDING WRITES (architect directive; §6 backlog bullet closed; symmetric with the read side + parallel to `vulnerability_ids`).** **Wire:** `cwes: list[int] \| None = None` added to `FindingWrite`, `FindingUpdate`, `FindingReplace` (plain CWE numbers, e.g. `79`; matches `FindingSlim.cwes`). Read side unchanged. **Precedence (mirrors v2 `FindingSerializer.update` ~:450-454 and the `vulnerability_ids[0]→cve` mirror):** an explicit **non-None** scalar `cwe` wins as the primary; when only `cwes` is supplied its first entry is mirrored into `cwe` **before** the initial/`save()` so the primary persists (like `cve`). Persisted `Finding_CWE` rows = **primary first, then the rest**, via the existing `save_cwes` (`finding_cwe_labels(finding.cwe, finding.unsaved_cwes)` dedups with the primary first). When BOTH a scalar `cwe` and a non-None `cwes` are present in one request, `save_cwes` runs **once** through the cwes branch (not the scalar-only resync `elif`) — list drives the rows, scalar stays primary; no double-persist. **`unsaved_cwes` shape discovered:** `save_cwes`→`finding_cwe_labels`→`cwe_label` accepts **plain ints OR `CWE-`/`"79"` strings** and canonicalizes+dedups, so the wire `list[int]` is placed onto `finding.unsaved_cwes` **verbatim** (no pre-formatting) — the full list, letting `save_cwes` order primary-first. **Omission (no-reset-on-omit, extends the vulnerability_ids/reporter record to cwes):** the service runs the list-driven mutation **only when `cwes` is non-None**; omitted (`None`) on PATCH **and** PUT ⇒ no list mutation, rows untouched **except the pre-existing scalar-`cwe`-change resync** (§12 2026-07-19 OS3b) — on PATCH that resync fires only if the scalar `cwe` changed; on PUT `cwe` is always in the full-replace set (as its reset-to-`None` default) so the scalar resync always fires and the rows follow the (possibly reset) scalar. **Because PUT always carries `cwe`, a key-presence test would wrongly suppress the mirror**, so the mirror keys off an **explicit non-None** scalar (`cwe_explicit = "cwe" in changes and changes["cwe"] is not None`; create uses the equivalent `"cwe" not in scalars`, since `None` is dropped from `scalars`). **Empty-list decision (conservative, recorded):** an explicit `cwes: []` is a statement (unlike omission) → it **clears the extra rows and resyncs to just the scalar-derived primary** (`unsaved_cwes=[]`, `save_cwes` ⇒ rows = `{primary}` when a scalar exists, else empty); on PUT `[]` and omission converge (both leave just the scalar primary, because the PUT scalar resync always runs). **Read-back symmetry** (rows persisted primary-first, `resolve_cwes` reads storage order): write `cwes=[89,79]` (no scalar) → read `[89,79]`; write scalar `79` + `cwes=[89,100]` → read `[79,89,100]`. **Files:** `dojo/finding/api_v3/schemas.py` (field + docstrings on the three write schemas), `dojo/finding/api_v3/routes.py` (pop `cwes` at create/PATCH/PUT, pass as service kwarg), `dojo/finding/services.py` (`cwes` kwarg on `create_finding`/`update_finding`, `"cwes"` added to `_SPECIAL_KEYS`, scalar mirror before save + rows resync). **Docs page unchanged** (it documents write *conventions*, not per-object write field lists — verified). **Tests (+9)** in `unittests/api_v3/test_apiv3_finding_writes.py`: create cwes-only (mirror+rows+read-back), create scalar+list (scalar wins), PATCH cwes replaces rows, PATCH scalar-only still resyncs (existing behavior), PATCH `[]` clears extras keeps primary, PATCH omit-both leaves rows untouched, PATCH scalar+list (scalar wins), PUT with cwes, PUT omitting cwes follows the scalar resync. Module 30→39. Full `unittests.api_v3` = **504 executed + 6 skipped, 0 failures** (`/tmp/apiv3_cwes_full.log`); the +12 over the 492 dedupe-policy baseline is this change's +9 plus concurrent-workstream tests (TEST dedupe-policy / Scalar docs) present in the tree — all green. Ruff clean. No v2 code/UI/v2-test modified. | diff --git a/dojo/finding/api_v3/routes.py b/dojo/finding/api_v3/routes.py index e4d3b859bea..021b1d5adf4 100644 --- a/dojo/finding/api_v3/routes.py +++ b/dojo/finding/api_v3/routes.py @@ -238,6 +238,7 @@ def create_finding_route(request: HttpRequest, payload: FindingWrite): test_id = data.pop("test") push_to_jira = data.pop("push_to_jira") vulnerability_ids = data.pop("vulnerability_ids") + cwes = data.pop("cwes") # Mirror UserHasFindingPermission -> check_post_permission(request, Test, "test", "add"): # 404 if the test doesn't exist, 403 if the user can't add findings to it. test = get_object_or_none(Test, pk=test_id) @@ -248,7 +249,7 @@ def create_finding_route(request: HttpRequest, payload: FindingWrite): raise PermissionDenied finding = create_finding( test=test, data=data, user=request.user, - push_to_jira=push_to_jira, vulnerability_ids=vulnerability_ids, + push_to_jira=push_to_jira, vulnerability_ids=vulnerability_ids, cwes=cwes, ) obj = _detail_object(request, queryset_hook, detail_schema, finding.pk) or finding return json_response(serialize(obj, detail_schema, {}), status=201) @@ -265,6 +266,7 @@ def update_finding_route(request: HttpRequest, finding_id: int, payload: Finding changes = payload.dict(exclude_unset=True) push_to_jira = changes.pop("push_to_jira", False) vulnerability_ids = changes.pop("vulnerability_ids", None) + cwes = changes.pop("cwes", None) # Mirror FindingViewSet.perform_update: OR push_to_jira with the project's push_all_issues. jira_project = jira_services.get_project(finding) if get_system_setting("enable_jira") and jira_project: @@ -272,7 +274,7 @@ def update_finding_route(request: HttpRequest, finding_id: int, payload: Finding update_finding( finding, changes=changes, user=request.user, - push_to_jira=push_to_jira, vulnerability_ids=vulnerability_ids, + push_to_jira=push_to_jira, vulnerability_ids=vulnerability_ids, cwes=cwes, ) obj = _detail_object(request, queryset_hook, detail_schema, finding_id) or finding return json_response(serialize(obj, detail_schema, {})) @@ -296,6 +298,7 @@ def replace_finding_route(request: HttpRequest, finding_id: int, payload: Findin changes = payload.dict() push_to_jira = changes.pop("push_to_jira", False) vulnerability_ids = changes.pop("vulnerability_ids", None) + cwes = changes.pop("cwes", None) # Mirror FindingViewSet.perform_update: OR push_to_jira with the project's push_all_issues. jira_project = jira_services.get_project(finding) if get_system_setting("enable_jira") and jira_project: @@ -303,7 +306,7 @@ def replace_finding_route(request: HttpRequest, finding_id: int, payload: Findin update_finding( finding, changes=changes, user=request.user, - push_to_jira=push_to_jira, vulnerability_ids=vulnerability_ids, + push_to_jira=push_to_jira, vulnerability_ids=vulnerability_ids, cwes=cwes, ) obj = _detail_object(request, queryset_hook, detail_schema, finding_id) or finding return json_response(serialize(obj, detail_schema, {})) diff --git a/dojo/finding/api_v3/schemas.py b/dojo/finding/api_v3/schemas.py index 7228b98722d..a53a2a91726 100644 --- a/dojo/finding/api_v3/schemas.py +++ b/dojo/finding/api_v3/schemas.py @@ -209,8 +209,11 @@ class FindingWrite(Schema): Create payload (POST /findings). ``test``/``title``/``severity``/``description``/``active``/ ``verified`` required (mirrors ``FindingCreateSerializer``: ``active``/``verified`` carry ``extra_kwargs required=True``). ``vulnerability_ids`` is a flat ``list[str]`` (§4.11); the - service persists them and mirrors the first into the ``cve`` field. ``found_by`` references - ``Test_Type`` ids; ``reporter``/``mitigated_by`` reference user ids. + service persists them and mirrors the first into the ``cve`` field. ``cwes`` is a flat + ``list[int]`` (plain CWE numbers, e.g. ``79``), symmetric with ``FindingSlim.cwes`` and parallel + to ``vulnerability_ids``: an explicit scalar ``cwe`` wins as the primary when both are supplied; + when only ``cwes`` is supplied its first entry is mirrored into ``cwe`` (§12). ``found_by`` + references ``Test_Type`` ids; ``reporter``/``mitigated_by`` reference user ids. """ model_config = {"extra": "forbid"} @@ -240,6 +243,7 @@ class FindingWrite(Schema): reporter: int | None = None found_by: list[int] | None = None vulnerability_ids: list[str] | None = None + cwes: list[int] | None = None tags: list[str] | None = None push_to_jira: bool = False @@ -274,6 +278,7 @@ class FindingUpdate(Schema): reporter: int | None = None found_by: list[int] | None = None vulnerability_ids: list[str] | None = None + cwes: list[int] | None = None tags: list[str] | None = None push_to_jira: bool = False @@ -297,6 +302,11 @@ class FindingReplace(Schema): Required, strict (``extra="forbid"``) and applied without ``exclude_unset`` by the route so omitted optionals reset to the defaults below. All side-effect/status-invariant validation still lives in the service (``dojo/finding/services.py`` ``update_finding``, D7/I6). + + ``vulnerability_ids``/``cwes`` default to ``None`` and the service treats ``None`` as "not + supplied" (rows left untouched) -- so on a PUT that omits them the reset-to-``None`` is a no-op, + NOT a reset to empty. This is the documented no-reset-on-omit deviation for the identity-row + fields, symmetric with ``reporter`` (§12); an explicit ``cwes: []`` clears the extra rows. """ model_config = {"extra": "forbid"} @@ -325,5 +335,6 @@ class FindingReplace(Schema): reporter: int | None = None found_by: list[int] | None = None vulnerability_ids: list[str] | None = None + cwes: list[int] | None = None tags: list[str] | None = None push_to_jira: bool = False diff --git a/dojo/finding/services.py b/dojo/finding/services.py index bb29d6bc3f5..95d195b2429 100644 --- a/dojo/finding/services.py +++ b/dojo/finding/services.py @@ -55,7 +55,7 @@ # Scalar Finding fields consumed via **kwargs on create; relations/side-effect keys are handled # explicitly (popped) before this set is applied. -_SPECIAL_KEYS = frozenset({"reporter", "mitigated_by", "found_by", "tags", "vulnerability_ids", "push_to_jira", "test"}) +_SPECIAL_KEYS = frozenset({"reporter", "mitigated_by", "found_by", "tags", "vulnerability_ids", "cwes", "push_to_jira", "test"}) # --- shared validation (ported from FindingSerializer.validate / FindingCreateSerializer.validate) -- @@ -137,11 +137,17 @@ def _resolve_test_types(ids: list[int]) -> list[Test_Type]: # --- public service API (I6) ------------------------------------------------------------------ def create_finding(*, test: Test, data: dict, user, push_to_jira: bool = False, - vulnerability_ids: list[str] | None = None) -> Finding: + vulnerability_ids: list[str] | None = None, + cwes: list[int] | None = None) -> Finding: """ Create a finding under ``test``. Ports ``FindingCreateSerializer.create``/``validate``: reporter defaulting, status invariants, vulnerability-id + CWE persistence, found_by, JIRA push, and the ``finding_added`` notification. + + ``cwes`` is a flat ``list[int]`` symmetric with the read shape and parallel to + ``vulnerability_ids`` (§12): an explicit scalar ``cwe`` wins as the primary; when only ``cwes`` + is supplied its first entry is mirrored into ``cwe`` before save (so the primary persists, like + ``cve``). The persisted ``Finding_CWE`` rows are the primary first then the rest (``save_cwes``). """ data = {k: v for k, v in data.items() if k not in _SPECIAL_KEYS or k in {"reporter", "mitigated_by", "found_by", "tags"}} @@ -169,6 +175,11 @@ def create_finding(*, test: Test, data: dict, user, push_to_jira: bool = False, # is persisted (save_vulnerability_ids below only sets it in memory). if vulnerability_ids: scalars["cve"] = vulnerability_ids[0] + # CWE list precedence (mirror vulnerability_ids -> cve): an explicit scalar `cwe` is the primary; + # when only `cwes` is supplied, mirror its first entry into `cwe` before the initial save so the + # primary persists. `"cwe" not in scalars` means the scalar was omitted/None on this create. + if cwes and "cwe" not in scalars: + scalars["cwe"] = cwes[0] new_finding = Finding(test=test, reporter=reporter, **scalars) if mitigated_by is not None: new_finding.mitigated_by = mitigated_by @@ -180,7 +191,11 @@ def create_finding(*, test: Test, data: dict, user, push_to_jira: bool = False, new_finding.found_by.set(_resolve_test_types(found_by_ids)) if vulnerability_ids: save_vulnerability_ids(new_finding, vulnerability_ids) - # Create always persists the primary Finding.cwe as a Finding_CWE row (mirror serializer). + # Create always persists the primary Finding.cwe as a Finding_CWE row (mirror serializer); when a + # `cwes` list is supplied, save_cwes persists the full set (primary first, then the rest). `cwes` + # values are plain ints -- finding_cwe_labels/cwe_label accept ints and canonicalize to CWE-. + if cwes is not None: + new_finding.unsaved_cwes = cwes save_cwes(new_finding) if tags is not None: new_finding.tags = tags @@ -202,12 +217,20 @@ def create_finding(*, test: Test, data: dict, user, push_to_jira: bool = False, def update_finding(finding: Finding, *, changes: dict, user, push_to_jira: bool = False, - vulnerability_ids: list[str] | None = None) -> Finding: + vulnerability_ids: list[str] | None = None, + cwes: list[int] | None = None) -> Finding: """ Update ``finding`` from a partial ``changes`` dict. Ports ``FindingSerializer.update``/ ``validate``: mitigated-edit rules, status invariants (PATCH defaults from the instance), risk-acceptance processing, vulnerability-id + CWE persistence, found_by set/clear, reporter, and the synchronous JIRA push (force_sync, raising on failure) + keep-in-sync. + + ``cwes`` (flat ``list[int]``, §12): a supplied list drives the ``Finding_CWE`` rows (primary + first, then the rest); an explicit scalar ``cwe`` in the same request stays the primary, else the + first list entry is mirrored into ``cwe`` before save. Omitting ``cwes`` (``None``) leaves the + rows untouched except the existing scalar-``cwe``-change resync (no-reset-on-omit, symmetric with + ``vulnerability_ids``/``reporter``); an explicit ``cwes: []`` clears the extra rows, resyncing to + just the scalar-derived primary. """ changes = dict(changes) @@ -235,6 +258,10 @@ def update_finding(finding: Finding, *, changes: dict, user, push_to_jira: bool found_by_ids = changes.pop("found_by", _UNSET) tags = changes.pop("tags", _UNSET) cwe_provided = "cwe" in changes + # Only a non-None scalar `cwe` counts as "explicitly supplied" for the list-vs-scalar precedence. + # A `None` (including PUT's reset-to-default, where `cwe` is always present in the full-replace + # change set) is not an explicit primary, so a `cwes` list still mirrors its first entry. + cwe_explicit = cwe_provided and changes.get("cwe") is not None # Persist vuln ids first so model save computes the hash including them (mirror serializer). if vulnerability_ids: @@ -248,11 +275,21 @@ def update_finding(finding: Finding, *, changes: dict, user, push_to_jira: bool for key, value in changes.items(): setattr(finding, key, value) + # CWE list precedence (mirror vulnerability_ids -> cve): mirror the first list entry into the + # scalar `cwe` before save so the primary persists, unless an explicit non-None scalar was + # supplied in this request (in which case that scalar stays the primary). + if cwes and not cwe_explicit: + finding.cwe = cwes[0] finding.save() - # v3 exposes a scalar `cwe` (not the v2 nested `cwes` list), so resync the Finding_CWE rows - # whenever `cwe` is updated -- keeps Finding.cwe and its Finding_CWE rows consistent (§12). - if cwe_provided: + # Resync the Finding_CWE rows (§12). An explicit `cwes` list wins: it drives the rows (primary + # first, then the rest); an explicit empty list clears the extras and resyncs to the primary. + # Otherwise a scalar-only `cwe` change resyncs the rows to just the scalar-derived primary + # (existing behavior); omitting both leaves the rows untouched. + if cwes is not None: + finding.unsaved_cwes = cwes + save_cwes(finding) + elif cwe_provided: save_cwes(finding) if tags is not _UNSET: diff --git a/unittests/api_v3/test_apiv3_finding_writes.py b/unittests/api_v3/test_apiv3_finding_writes.py index 6c355029c4d..ba12f233c90 100644 --- a/unittests/api_v3/test_apiv3_finding_writes.py +++ b/unittests/api_v3/test_apiv3_finding_writes.py @@ -65,6 +65,36 @@ def test_create_persists_cwe_row(self): self.assertEqual(79, created.cwe) self.assertTrue(Finding_CWE.objects.filter(finding=created, cwe="CWE-79").exists()) + def test_create_with_cwes_list_mirrors_primary_and_persists_rows(self): + # `cwes` only (no scalar `cwe`): the first entry becomes the primary (mirrored into `cwe`, + # like vulnerability_ids[0]->cve); rows are persisted primary-first and read back symmetric. + payload = _finding_payload(self.test.id, cwes=[89, 79]) + response = self.client.post(self.v3_url("findings"), payload, format="json") + self.assertEqual(201, response.status_code, response.content[:500]) + body = response.json() + created = Finding.objects.get(pk=body["id"]) + self.assertEqual(89, created.cwe) # first list entry mirrored into the scalar primary + self.assertEqual( + {"CWE-89", "CWE-79"}, + set(Finding_CWE.objects.filter(finding=created).values_list("cwe", flat=True)), + ) + self.assertEqual([89, 79], body["cwes"]) # read-back symmetry: primary first, then the rest + + def test_create_scalar_and_cwes_scalar_wins_primary(self): + # Both scalar `cwe` and `cwes`: the explicit scalar is the primary; the list supplies the + # rest of the rows (primary first on read-back). + payload = _finding_payload(self.test.id, cwe=79, cwes=[89, 100]) + response = self.client.post(self.v3_url("findings"), payload, format="json") + self.assertEqual(201, response.status_code, response.content[:500]) + body = response.json() + created = Finding.objects.get(pk=body["id"]) + self.assertEqual(79, created.cwe) # explicit scalar stays primary + self.assertEqual([79, 89, 100], body["cwes"]) # primary first, then the list + self.assertEqual( + {"CWE-79", "CWE-89", "CWE-100"}, + set(Finding_CWE.objects.filter(finding=created).values_list("cwe", flat=True)), + ) + def test_create_duplicate_active_invariant_is_400(self): payload = _finding_payload(self.test.id, active=True, duplicate=True) response = self.client.post(self.v3_url("findings"), payload, format="json") @@ -113,6 +143,16 @@ def test_update_vulnerability_ids(self): self.assertEqual("CVE-2019-9999", self.finding.cve) self.assertTrue(Vulnerability_Id.objects.filter(finding=self.finding, vulnerability_id="CVE-2019-9999").exists()) + def _seed_cwe_rows(self, primary: int, extras: list[int]) -> None: + self.finding.cwe = primary + self.finding.save() + Finding_CWE.objects.filter(finding=self.finding).delete() + for n in [primary, *extras]: + Finding_CWE.objects.create(finding=self.finding, cwe=f"CWE-{n}") + + def _cwe_rows(self) -> set[str]: + return set(Finding_CWE.objects.filter(finding=self.finding).values_list("cwe", flat=True)) + def test_update_cwe_resyncs_finding_cwe(self): response = self.client.patch( self.v3_url(f"findings/{self.finding.id}"), {"cwe": 89}, format="json", @@ -122,6 +162,66 @@ def test_update_cwe_resyncs_finding_cwe(self): self.assertEqual(89, self.finding.cwe) self.assertTrue(Finding_CWE.objects.filter(finding=self.finding, cwe="CWE-89").exists()) + def test_update_cwes_list_replaces_rows(self): + # A `cwes` list drives the rows: old rows replaced, first entry mirrored into the scalar + # primary (no explicit scalar in the request), read-back symmetric. + self._seed_cwe_rows(11, [22, 33]) + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), {"cwes": [79, 89]}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.finding.refresh_from_db() + self.assertEqual(79, self.finding.cwe) # first list entry mirrored (no scalar supplied) + self.assertEqual({"CWE-79", "CWE-89"}, self._cwe_rows()) # old rows replaced + self.assertEqual([79, 89], response.json()["cwes"]) # read-back symmetry + + def test_update_scalar_cwe_only_still_resyncs_to_primary(self): + # Existing behavior unchanged: a scalar-only `cwe` change resyncs the rows to just that + # primary, wiping any extra rows (documented §12 OS3b divergence). + self._seed_cwe_rows(11, [22, 33]) + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), {"cwe": 89}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.finding.refresh_from_db() + self.assertEqual(89, self.finding.cwe) + self.assertEqual({"CWE-89"}, self._cwe_rows()) # extras wiped by the scalar resync + + def test_update_cwes_empty_clears_extras_keeps_primary(self): + # An explicit empty list clears the extra rows and resyncs to just the scalar-derived primary + # (an explicit empty is a statement, unlike omission). + self._seed_cwe_rows(11, [22, 33]) + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), {"cwes": []}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.finding.refresh_from_db() + self.assertEqual(11, self.finding.cwe) # scalar untouched + self.assertEqual({"CWE-11"}, self._cwe_rows()) # extras cleared, primary retained + + def test_update_omitting_cwe_and_cwes_leaves_rows_untouched(self): + # Omitting BOTH `cwe` and `cwes` leaves the rows exactly as they were (no-reset-on-omit, + # symmetric with vulnerability_ids/reporter). + self._seed_cwe_rows(11, [22, 33]) + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), {"severity": "Low"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.assertEqual({"CWE-11", "CWE-22", "CWE-33"}, self._cwe_rows()) + + def test_update_scalar_and_cwes_scalar_wins_primary(self): + # Both supplied on PATCH: the explicit scalar stays primary, the list drives the extra rows + # (list wins over the scalar-only resync -- save_cwes runs once). + self._seed_cwe_rows(11, []) + response = self.client.patch( + self.v3_url(f"findings/{self.finding.id}"), {"cwe": 79, "cwes": [89, 100]}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.finding.refresh_from_db() + self.assertEqual(79, self.finding.cwe) + self.assertEqual([79, 89, 100], response.json()["cwes"]) + self.assertEqual({"CWE-79", "CWE-89", "CWE-100"}, self._cwe_rows()) + def test_update_duplicate_active_invariant_is_400(self): self.finding.active = True self.finding.save() @@ -228,6 +328,33 @@ def test_put_non_null_boolean_resets_to_model_default(self): self.finding.refresh_from_db() self.assertFalse(self.finding.out_of_scope) + def _cwe_rows(self) -> set[str]: + return set(Finding_CWE.objects.filter(finding=self.finding).values_list("cwe", flat=True)) + + def test_put_with_cwes_persists_rows_primary_first(self): + # PUT accepts a `cwes` list: first entry mirrored into the scalar primary (no explicit scalar + # in the payload), rows persisted primary-first, read-back symmetric. + response = self._put(cwes=[89, 79]) + self.assertEqual(200, response.status_code, response.content[:500]) + self.finding.refresh_from_db() + self.assertEqual(89, self.finding.cwe) + self.assertEqual({"CWE-89", "CWE-79"}, self._cwe_rows()) + self.assertEqual([89, 79], response.json()["cwes"]) + + def test_put_omitting_cwes_follows_scalar_resync(self): + # Omitting `cwes` on PUT does not run the list-driven replacement (no-reset-on-omit); the rows + # follow the existing scalar-`cwe` resync. Here PUT supplies scalar cwe=55 and no list, so the + # rows become just {CWE-55} -- the seeded extras are gone via the scalar resync of the full + # replace, not because the omitted `cwes` cleared them (§12). + Finding_CWE.objects.filter(finding=self.finding).delete() + Finding_CWE.objects.create(finding=self.finding, cwe="CWE-11") + Finding_CWE.objects.create(finding=self.finding, cwe="CWE-22") + response = self._put(cwe=55) + self.assertEqual(200, response.status_code, response.content[:500]) + self.finding.refresh_from_db() + self.assertEqual(55, self.finding.cwe) + self.assertEqual({"CWE-55"}, self._cwe_rows()) + def test_put_missing_required_is_400(self): response = self.client.put( self.v3_url(f"findings/{self.finding.id}"), From 5fdb09921d94da7423cb97ee337e919886f077ff Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 21 Jul 2026 18:35:38 +0200 Subject: [PATCH 28/37] feat(api-v3): serve Scalar from image-built static files, not CDN Supersedes the CDN+SRI approach (architect directive): @scalar/ api-reference is now an exact-pinned yarn dependency in components/ package.json (1.63.0, lockfile-verified integrity), installed by the EXISTING yarn step in Dockerfile.nginx-alpine - zero Dockerfile changes because STATICFILES_DIRS already includes components/node_modules. - reference view points at the local static path; SRI attribute dropped (same-origin, image-baked); no runtime third-party, air-gapped deployments now get a working reference page, no metadata leaks - trust model identical to every other yarn-managed frontend dep in this repo; integrity enforced by yarn.lock hashes at image build - engine note: Scalar 1.63.0 wants node>=22; the image base ships v22.23.0 (verified), local lockfile regen on older node needs --ignore-engines - tests: static-URL + no-CDN/no-SRI assertions + exact-pin guard reading components/package.json 504 tests green (+6 skipped). --- API_V3_PLAN.md | 1 + components/package.json | 1 + components/yarn.lock | 2220 ++++++++++++++++- .../automation/api/api-v3-alpha-docs.md | 8 +- dojo/api_v3/reference_docs.py | 53 +- unittests/api_v3/test_apiv3_reference_docs.py | 42 +- 6 files changed, 2283 insertions(+), 42 deletions(-) diff --git a/API_V3_PLAN.md b/API_V3_PLAN.md index 1ef982bb8e1..0fc9c6e3811 100644 --- a/API_V3_PLAN.md +++ b/API_V3_PLAN.md @@ -570,3 +570,4 @@ itself (conservative, §10.2). *Subsequently closed:* the matching-policy read f | 2026-07-21 | **FINDINGSLIM EXTENDED WITH `vulnerability_ids` + `cwes` (architect directive — identity-critical fields available on lists without per-row detail fetches).** Two resolver-backed READ fields added to `FindingSlim` (so `FindingDetail` inherits them): **`vulnerability_ids: list[str]`** — flat strings (`["CVE-2020-1234", ...]`), NOT v2's object list; read directly from the `vulnerability_id_set` reverse relation in **storage order** (first = the id mirrored into `cve`), symmetric with what `FindingWrite` already accepts. **`cwes: list[int]`** — the numeric part of each `finding_cwe_set` row parsed via `dojo.finding.cwe.cwe_number`. **Shape choice `list[int]` (not the stored strings):** `Finding_CWE.cwe` is a `varchar(11)` that `save_cwes` always writes as the canonical `CWE-` label (via `finding_cwe_labels`/`cwe_label`), so the stored format is reliably `CWE-`; parsing to int is consistent with the slim's scalar `cwe: int | None`. **Primary is included:** `save_cwes` persists the primary `Finding.cwe` as a `Finding_CWE` row too (first), so reading the rows directly (no scalar-merge) yields the primary first — mirroring v2's `finding_cwe_set` row semantics. Both resolvers read only the prefetched reverse relations (mirror the `resolve_tags` pattern) — never a per-row query; empty lists (not null) when a finding has no rows. **related_names used:** `vulnerability_id_set` / `finding_cwe_set` (default reverse accessors — neither FK on `Vulnerability_Id`/`Finding_CWE` declares `related_name`; identical to v2's serializer `source=`). **Query-pin deltas (+2 fixed in-batch prefetches on findings lists):** `FindingSlim.PREFETCH_RELATED` grew from `("tags",)` to `("tags", "vulnerability_id_set", "finding_cwe_set")`. Offset findings list 5→7 queries; cursor 4→6 — `test_apiv3_cursor.EXPECTED_CURSOR_QUERIES` 4→6 (comment updated; the `offset−1==cursor` relation still holds). No other absolute pin exists (the `test_apiv3_findings_queries` tests are row-independence comparisons, still green; the whole-surface N+1 sweep threshold is 4 and the two new prefetches are distinct single-shot `IN` batches, so no shape repeats). **Defer:** `vulnerability_ids`/`cwes` are relations, not `model._meta.concrete_fields`, so `plan_list_fields` never enters them into the defer set (asserted); `?fields=` accepts them as ordinary slim names. **CSV:** the two list fields flatten to one semicolon-joined column each (`cwes`→`"79;89"`, `vulnerability_ids`→`"CVE-..;GHSA-.."`); `_FINDING_DEFAULT_COLUMNS` updated (inserted after `cwe`). **Write asymmetry (pre-existing, NOT changed here):** finding writes accept `vulnerability_ids` (flat, mirrored into `cve`) but only the scalar `cwe` — there is no `cwes` list on write. Documented + a backlog bullet added ("accept a `cwes` list on finding writes"); not implemented (directive). **Examples:** `api_v3_examples.md` regenerated via the CI-excluded harness (`DD_API_V3_EXAMPLES=1`); the new `cwes`/`vulnerability_ids` keys appear on every captured finding body. **Tests (+7):** `test_apiv3_findings` `_SLIM_KEYS` += both names + new `TestApiV3FindingsVulnIdsAndCwes` (2-vuln-id + 2-CWE-row render on list AND detail, empty-lists-not-null, `?fields=id,cwes,vulnerability_ids` projection = 4) + a defer-set assertion (1); `test_apiv3_findings_queries` new constant-query test (identity rows on every finding stay row-independent, 1); `test_apiv3_csv_export` header pin + new semicolon-join value test (1). Full `unittests.api_v3` = **476 executed + 6 skipped, 0 failures** (`/tmp/apiv3_slimext_full.log`; +7 over the 469 baseline). Ruff clean. No v2 code/UI/v2-test modified. | | 2026-07-21 | **TESTDETAIL MATCHING-POLICY READ FIELDS PORTED (§9.1 PORT-LATER→PORTED; ports `test_apiv2_test_dedupe_policy.py`).** `TestDetail` gains two READ-ONLY **computed** fields exposing the effective finding-matching policy: **`deduplication_algorithm: str`** (always non-null; `legacy` fallback) and **`hash_code_fields: list[str] \| None`** (`None` when the scan type has no per-scanner config). **Helper reused verbatim (never duplicated):** both resolvers return `obj.deduplication_algorithm` / `obj.hash_code_fields` — the v2 `Test` model **properties** (`dojo/test/models.py:156-193`), the exact settings-driven per-scanner lookup the v2 `TestSerializer` reads (`DEDUPLICATION_ALGORITHM_PER_PARSER` / `HASHCODE_FIELDS_PER_SCANNER`, keyed `test_type.name` then `scan_type`, `DEDUPE_ALGO_LEGACY` default). **Slim unchanged** (§4.5): these are config lookups, not identity fields, so `TestSlim` stays as-is. **Reject-vs-ignore deviation (the deliberate hardening):** the fields are **not** on `TestWrite`/`TestUpdate`/`TestReplace` (all `extra="forbid"`), so a PATCH or PUT supplying either → **400 unknown-field** problem+json; v2's serializer declares them `read_only` and therefore silently **ignores** writes to them (200, no change). v3 rejects because silent-ignore is the exact failure mode v3 rejects everywhere (same principle as unknown filter/expand/`fields=` params — D6/I9); the reject is atomic (a mixed PATCH bundling the policy fields with a real `description` change alters nothing). **Query impact = zero on detail:** the resolvers read only `test_type` (already `select_related` in `TestSlim.SELECT_RELATED`) and `scan_type` (a loaded concrete column — detail routes never `defer()`), verified with `assertNumQueries(0)`. **`?fields=` opt-up + defer handling (kernel accommodation, §4.7):** on a LIST these detail-only fields opt up via `?fields=`; being **resolver-backed (not `model._meta.concrete_fields`)** they never enter the defer set (asserted). But their resolvers read the concrete `scan_type` column, which is **deferred by default** on tests lists (§12 2026-07-20) — a naive opt-up would lazy-load it per row (N+1). Smallest correct accommodation: a new optional per-schema `DETAIL_FIELD_COLUMNS: dict[computed_field, tuple[concrete_column, ...]]` honored by the resource-agnostic kernel `plan_list_fields` (`dojo/api_v3/expand.py`) — requesting a computed detail field **un-defers exactly** the columns its resolver reads (analogous to `DETAIL_SELECT_RELATED`, but for own columns rather than relation joins). `TestDetail.DETAIL_FIELD_COLUMNS = {deduplication_algorithm: (scan_type,), hash_code_fields: (scan_type,)}`. Kernel change is additive (a schema without `DETAIL_FIELD_COLUMNS` behaves identically) and covers the CSV route too (it reuses `plan_list_fields`). `GET /tests?fields=id,name,deduplication_algorithm` verified **constant-query** (row-independent). **CSV:** flows automatically — `deduplication_algorithm` → one column, `hash_code_fields` (list) → one semicolon-joined column (empty when null); asserted. **Docs:** the v3 docs page does not enumerate per-object detail fields — no docs edit needed. **Tests:** new `unittests/api_v3/test_apiv3_test_dedupe_policy.py` (16): two scan types with distinct policies (ZAP Scan→`hash_code`+`[title,cwe,severity]`; Checkmarx Scan detailed→`unique_id_from_tool`+`None`), default-fallback (`legacy`+`None`), detail zero-extra-query, PATCH×2/PUT×2 reject + atomic reject-vs-ignore, list not-on-default + opt-up value-parity + defer-set + constant-query, CSV flatten. Full `unittests.api_v3` = **492 executed + 6 skipped, 0 failures** (`/tmp/apiv3_dedupe_full.log`; +16 over the 476 baseline). No new endpoints → the authz/query-report/OpenAPI completeness gates are unaffected and stay green. Ruff clean. No v2 code/UI/v2-test modified. | | 2026-07-21 | **`cwes` LIST ACCEPTED ON FINDING WRITES (architect directive; §6 backlog bullet closed; symmetric with the read side + parallel to `vulnerability_ids`).** **Wire:** `cwes: list[int] \| None = None` added to `FindingWrite`, `FindingUpdate`, `FindingReplace` (plain CWE numbers, e.g. `79`; matches `FindingSlim.cwes`). Read side unchanged. **Precedence (mirrors v2 `FindingSerializer.update` ~:450-454 and the `vulnerability_ids[0]→cve` mirror):** an explicit **non-None** scalar `cwe` wins as the primary; when only `cwes` is supplied its first entry is mirrored into `cwe` **before** the initial/`save()` so the primary persists (like `cve`). Persisted `Finding_CWE` rows = **primary first, then the rest**, via the existing `save_cwes` (`finding_cwe_labels(finding.cwe, finding.unsaved_cwes)` dedups with the primary first). When BOTH a scalar `cwe` and a non-None `cwes` are present in one request, `save_cwes` runs **once** through the cwes branch (not the scalar-only resync `elif`) — list drives the rows, scalar stays primary; no double-persist. **`unsaved_cwes` shape discovered:** `save_cwes`→`finding_cwe_labels`→`cwe_label` accepts **plain ints OR `CWE-`/`"79"` strings** and canonicalizes+dedups, so the wire `list[int]` is placed onto `finding.unsaved_cwes` **verbatim** (no pre-formatting) — the full list, letting `save_cwes` order primary-first. **Omission (no-reset-on-omit, extends the vulnerability_ids/reporter record to cwes):** the service runs the list-driven mutation **only when `cwes` is non-None**; omitted (`None`) on PATCH **and** PUT ⇒ no list mutation, rows untouched **except the pre-existing scalar-`cwe`-change resync** (§12 2026-07-19 OS3b) — on PATCH that resync fires only if the scalar `cwe` changed; on PUT `cwe` is always in the full-replace set (as its reset-to-`None` default) so the scalar resync always fires and the rows follow the (possibly reset) scalar. **Because PUT always carries `cwe`, a key-presence test would wrongly suppress the mirror**, so the mirror keys off an **explicit non-None** scalar (`cwe_explicit = "cwe" in changes and changes["cwe"] is not None`; create uses the equivalent `"cwe" not in scalars`, since `None` is dropped from `scalars`). **Empty-list decision (conservative, recorded):** an explicit `cwes: []` is a statement (unlike omission) → it **clears the extra rows and resyncs to just the scalar-derived primary** (`unsaved_cwes=[]`, `save_cwes` ⇒ rows = `{primary}` when a scalar exists, else empty); on PUT `[]` and omission converge (both leave just the scalar primary, because the PUT scalar resync always runs). **Read-back symmetry** (rows persisted primary-first, `resolve_cwes` reads storage order): write `cwes=[89,79]` (no scalar) → read `[89,79]`; write scalar `79` + `cwes=[89,100]` → read `[79,89,100]`. **Files:** `dojo/finding/api_v3/schemas.py` (field + docstrings on the three write schemas), `dojo/finding/api_v3/routes.py` (pop `cwes` at create/PATCH/PUT, pass as service kwarg), `dojo/finding/services.py` (`cwes` kwarg on `create_finding`/`update_finding`, `"cwes"` added to `_SPECIAL_KEYS`, scalar mirror before save + rows resync). **Docs page unchanged** (it documents write *conventions*, not per-object write field lists — verified). **Tests (+9)** in `unittests/api_v3/test_apiv3_finding_writes.py`: create cwes-only (mirror+rows+read-back), create scalar+list (scalar wins), PATCH cwes replaces rows, PATCH scalar-only still resyncs (existing behavior), PATCH `[]` clears extras keeps primary, PATCH omit-both leaves rows untouched, PATCH scalar+list (scalar wins), PUT with cwes, PUT omitting cwes follows the scalar resync. Module 30→39. Full `unittests.api_v3` = **504 executed + 6 skipped, 0 failures** (`/tmp/apiv3_cwes_full.log`); the +12 over the 492 dedupe-policy baseline is this change's +9 plus concurrent-workstream tests (TEST dedupe-policy / Scalar docs) present in the tree — all green. Ruff clean. No v2 code/UI/v2-test modified. | +| 2026-07-21 | **SCALAR MOVED FROM CDN+SRI TO NPM-AT-IMAGE-BUILD (architect directive — supersedes the CDN+SRI row above).** `@scalar/api-reference` is now an exact-pinned dependency in `components/package.json` (1.63.0, integrity via `components/yarn.lock`), installed by the EXISTING yarn step in `Dockerfile.nginx-alpine` — zero Dockerfile changes because `STATICFILES_DIRS` already includes `components/node_modules` (`settings.dist.py:467`), so the bundle is served as a normal static file (`dojo/api_v3/reference_docs.py` → `static("@scalar/api-reference/dist/browser/standalone.js")`). Rationale: identical trust model to every other yarn-managed frontend dependency; asset fetched once at image build (lockfile hashes) instead of from every user's browser at runtime; air-gapped deployments get a working reference page; no usage metadata leaks to jsDelivr. SRI attribute dropped (same-origin, image-baked). Engine note: Scalar 1.63.0 declares node>=22; the image base ships node 22 (verified v22.23.0 on python:3.14.5-alpine3.22), so the image build passes engines — local lockfile regeneration on older node needs `--ignore-engines`. Tests updated: static-URL assertion, no-CDN/no-SRI assertions, exact-pin guard reading `components/package.json`. Docs page air-gap wording flipped to works-everywhere. | diff --git a/components/package.json b/components/package.json index cf01ec08359..d6d08a9de08 100644 --- a/components/package.json +++ b/components/package.json @@ -5,6 +5,7 @@ "private": true, "dependencies": { "@fontsource-variable/work-sans": "^5.3", + "@scalar/api-reference": "1.63.0", "JUMFlot": "jumjum123/JUMFlot#*", "alpinejs": "^3.15", "bootstrap": "^3.4.1", diff --git a/components/yarn.lock b/components/yarn.lock index d78e536090c..fe32922604c 100644 --- a/components/yarn.lock +++ b/components/yarn.lock @@ -2,6 +2,195 @@ # yarn lockfile v1 +"@ai-sdk/gateway@3.0.13": + version "3.0.13" + resolved "https://registry.yarnpkg.com/@ai-sdk/gateway/-/gateway-3.0.13.tgz#3726b60ff411729a3bac8e5b0eeae17a34526d7c" + integrity sha512-g7nE4PFtngOZNZSy1lOPpkC+FAiHxqBJXqyRMEG7NUrEVZlz5goBdtHg1YgWRJIX776JTXAmbOI5JreAKVAsVA== + dependencies: + "@ai-sdk/provider" "3.0.2" + "@ai-sdk/provider-utils" "4.0.5" + "@vercel/oidc" "3.1.0" + +"@ai-sdk/provider-utils@4.0.5": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider-utils/-/provider-utils-4.0.5.tgz#ab531c9f78ac30e33ba1f66ca6e901f6cf68fec9" + integrity sha512-Ow/X/SEkeExTTc1x+nYLB9ZHK2WUId8+9TlkamAx7Tl9vxU+cKzWx2dwjgMHeCN6twrgwkLrrtqckQeO4mxgVA== + dependencies: + "@ai-sdk/provider" "3.0.2" + "@standard-schema/spec" "^1.1.0" + eventsource-parser "^3.0.6" + +"@ai-sdk/provider@3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider/-/provider-3.0.2.tgz#d4ee0b53e2c0b2a1b3e36f7356844fda53e63487" + integrity sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw== + dependencies: + json-schema "^0.4.0" + +"@ai-sdk/vue@3.0.33": + version "3.0.33" + resolved "https://registry.yarnpkg.com/@ai-sdk/vue/-/vue-3.0.33.tgz#b42d513d5909f9acd933426c8194bec2e4dc2a6a" + integrity sha512-czM9Js3a7f+Eo35gjEYEeJYUoPvMg5Dfi4bOLyDBghLqn0gaVg8yTmTaSuHCg+3K/+1xPjyXd4+2XcQIohWWiQ== + dependencies: + "@ai-sdk/provider-utils" "4.0.5" + ai "6.0.33" + swrv "^1.0.4" + +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + +"@babel/parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" + integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== + dependencies: + "@babel/types" "^7.29.7" + +"@babel/types@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" + integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + +"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.18.3": + version "6.20.3" + resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz#696b740312c6a962e14567b49a3661b5924bc5ae" + integrity sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.17.0" + "@lezer/common" "^1.0.0" + +"@codemirror/commands@^6.7.1": + version "6.10.4" + resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.10.4.tgz#64dec1bd043976eb344e3cc9d15af13b6f724bfd" + integrity sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.7.0" + "@codemirror/view" "^6.27.0" + "@lezer/common" "^1.1.0" + +"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@^6.3.1": + version "6.3.1" + resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.3.1.tgz#763ca41aee81bb2431be55e3cfcc7cc8e91421a3" + integrity sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@lezer/common" "^1.0.2" + "@lezer/css" "^1.1.7" + +"@codemirror/lang-html@^6.4.8": + version "6.4.11" + resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.11.tgz#c46ba46ae642fd567cf05c4129005d2913ac248d" + integrity sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/lang-css" "^6.0.0" + "@codemirror/lang-javascript" "^6.0.0" + "@codemirror/language" "^6.4.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.17.0" + "@lezer/common" "^1.0.0" + "@lezer/css" "^1.1.0" + "@lezer/html" "^1.3.12" + +"@codemirror/lang-javascript@^6.0.0": + version "6.2.5" + resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz#b9ea6b2f0383ed6895fae7888c0322541538f10a" + integrity sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/language" "^6.6.0" + "@codemirror/lint" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.17.0" + "@lezer/common" "^1.0.0" + "@lezer/javascript" "^1.0.0" + +"@codemirror/lang-json@^6.0.0": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@codemirror/lang-json/-/lang-json-6.0.2.tgz#054b160671306667e25d80385286049841836179" + integrity sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ== + dependencies: + "@codemirror/language" "^6.0.0" + "@lezer/json" "^1.0.0" + +"@codemirror/lang-xml@^6.0.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz#e3e786e1a89fdc9520efe75c1d6d3de1c40eb91c" + integrity sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/language" "^6.4.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + "@lezer/common" "^1.0.0" + "@lezer/xml" "^1.0.0" + +"@codemirror/lang-yaml@^6.1.2": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz#4d4127e8339984639715d1e3f8edca1ea5bfabfb" + integrity sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@lezer/common" "^1.2.0" + "@lezer/highlight" "^1.2.0" + "@lezer/lr" "^1.0.0" + "@lezer/yaml" "^1.0.0" + +"@codemirror/language@^6.0.0", "@codemirror/language@^6.10.7", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0": + version "6.12.4" + resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.12.4.tgz#01e70fd5aa3a8a067ff1dfec75d5b6394cdfa058" + integrity sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.23.0" + "@lezer/common" "^1.5.0" + "@lezer/highlight" "^1.0.0" + "@lezer/lr" "^1.0.0" + style-mod "^4.0.0" + +"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.8.4": + version "6.9.7" + resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.9.7.tgz#841fc733674389d91fe49a1c34027ad3babdf105" + integrity sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.42.0" + crelt "^1.0.5" + +"@codemirror/state@^6.0.0", "@codemirror/state@^6.5.0", "@codemirror/state@^6.7.0": + version "6.7.1" + resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.7.1.tgz#9e88a17448c1dbc7b50acbeeec979ed7ccf1d6fc" + integrity sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A== + dependencies: + "@marijn/find-cluster-break" "^1.0.0" + +"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.3", "@codemirror/view@^6.42.0": + version "6.43.6" + resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.6.tgz#81e4eeb2855e7cb6c62305fba8df319b5476280c" + integrity sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA== + dependencies: + "@codemirror/state" "^6.7.0" + crelt "^1.0.6" + style-mod "^4.1.0" + w3c-keyname "^2.2.4" + "@emnapi/core@^1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" @@ -24,11 +213,80 @@ dependencies: tslib "^2.4.0" +"@floating-ui/core@^1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.8.0.tgz#d01c0bbea02e4a57f6fd7d5de6fc2c5c7dca40e1" + integrity sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ== + dependencies: + "@floating-ui/utils" "^0.2.12" + +"@floating-ui/dom@^1.6.7", "@floating-ui/dom@^1.7.4", "@floating-ui/dom@^1.7.6": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.8.0.tgz#8a20e6facbe2456afdbeb6c8b968a72df689cf63" + integrity sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg== + dependencies: + "@floating-ui/core" "^1.8.0" + "@floating-ui/utils" "^0.2.12" + +"@floating-ui/utils@0.2.10": + version "0.2.10" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" + integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== + +"@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.11", "@floating-ui/utils@^0.2.12": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.12.tgz#afefe785949f16ac4cdd1e695935a321572dd56a" + integrity sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww== + +"@floating-ui/vue@1.1.9": + version "1.1.9" + resolved "https://registry.yarnpkg.com/@floating-ui/vue/-/vue-1.1.9.tgz#508c386bd3d595247f1dda8dbca00b76fe8fcaf9" + integrity sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ== + dependencies: + "@floating-ui/dom" "^1.7.4" + "@floating-ui/utils" "^0.2.10" + vue-demi ">=0.13.0" + +"@floating-ui/vue@^1.1.0": + version "1.1.11" + resolved "https://registry.yarnpkg.com/@floating-ui/vue/-/vue-1.1.11.tgz#94fb1e62f6e157f91258a315a78b17079dd260e3" + integrity sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw== + dependencies: + "@floating-ui/dom" "^1.7.6" + "@floating-ui/utils" "^0.2.11" + vue-demi ">=0.13.0" + "@fontsource-variable/work-sans@^5.3": version "5.3.0" resolved "https://registry.yarnpkg.com/@fontsource-variable/work-sans/-/work-sans-5.3.0.tgz#14d99be2bca1f6354674f9b4dcc37f2dd4e98953" integrity sha512-le8h/OIIuhm5Z7TdvuMw0yKg2gaCZUiMec8zaumsVVjzeJRBPEF0bQI/MkaZDHCVzZDvZoP/O2LiGOSfDLSbaw== +"@headlessui/tailwindcss@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz#8ebde73fabca72d48636ea56ae790209dc5f0d49" + integrity sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw== + +"@headlessui/vue@1.7.23": + version "1.7.23" + resolved "https://registry.yarnpkg.com/@headlessui/vue/-/vue-1.7.23.tgz#7fe19dbeca35de9e6270c82c78c4864e6a6f7391" + integrity sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg== + dependencies: + "@tanstack/vue-virtual" "^3.0.0-beta.60" + +"@internationalized/date@^3.5.4": + version "3.12.2" + resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.12.2.tgz#08a65edd2a29775e22c168ddc029fb54bf9b8a85" + integrity sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw== + dependencies: + "@swc/helpers" "^0.5.0" + +"@internationalized/number@^3.5.3": + version "3.6.7" + resolved "https://registry.yarnpkg.com/@internationalized/number/-/number-3.6.7.tgz#5a0a8fa413b5f8679a59dcf37e2a74dc508b8371" + integrity sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg== + dependencies: + "@swc/helpers" "^0.5.0" + "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" @@ -68,6 +326,84 @@ resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf" integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w== +"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0", "@lezer/common@^1.2.3", "@lezer/common@^1.3.0", "@lezer/common@^1.5.0": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.5.2.tgz#d6840db13779e3f1b42e70c9a97c4086d12fae22" + integrity sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ== + +"@lezer/css@^1.1.0", "@lezer/css@^1.1.7": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.3.4.tgz#ba05a03d5ca114ad523aa3c0e93aff8d1e26ed70" + integrity sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q== + dependencies: + "@lezer/common" "^1.2.0" + "@lezer/highlight" "^1.0.0" + "@lezer/lr" "^1.3.0" + +"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3", "@lezer/highlight@^1.2.0", "@lezer/highlight@^1.2.1": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.3.tgz#a20f324b71148a2ea9ba6ff42e58bbfaec702857" + integrity sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g== + dependencies: + "@lezer/common" "^1.3.0" + +"@lezer/html@^1.3.12": + version "1.3.13" + resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.13.tgz#6a1305ae3bd2c9c01f877f8a8dc1e15ec652d01c" + integrity sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg== + dependencies: + "@lezer/common" "^1.2.0" + "@lezer/highlight" "^1.0.0" + "@lezer/lr" "^1.0.0" + +"@lezer/javascript@^1.0.0": + version "1.5.4" + resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.5.4.tgz#11746955f957d33c0933f17d7594db54a8b4beea" + integrity sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA== + dependencies: + "@lezer/common" "^1.2.0" + "@lezer/highlight" "^1.1.3" + "@lezer/lr" "^1.3.0" + +"@lezer/json@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@lezer/json/-/json-1.0.3.tgz#e773a012ad0088fbf07ce49cfba875cc9e5bc05f" + integrity sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ== + dependencies: + "@lezer/common" "^1.2.0" + "@lezer/highlight" "^1.0.0" + "@lezer/lr" "^1.0.0" + +"@lezer/lr@^1.0.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.4.0": + version "1.4.10" + resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.10.tgz#b3acc36e5ad049b74ddb7719594e7e74d9161ff5" + integrity sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A== + dependencies: + "@lezer/common" "^1.0.0" + +"@lezer/xml@^1.0.0": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@lezer/xml/-/xml-1.0.6.tgz#908c203923288f854eb8e2f4d9b06c437e8610b9" + integrity sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww== + dependencies: + "@lezer/common" "^1.2.0" + "@lezer/highlight" "^1.0.0" + "@lezer/lr" "^1.0.0" + +"@lezer/yaml@^1.0.0": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@lezer/yaml/-/yaml-1.0.4.tgz#66a622188f1984a71d34506759b5807699043589" + integrity sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw== + dependencies: + "@lezer/common" "^1.2.0" + "@lezer/highlight" "^1.0.0" + "@lezer/lr" "^1.4.0" + +"@marijn/find-cluster-break@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz#bdbe907e00c17c84e33fc51b1519d9aa211e7e8e" + integrity sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA== + "@napi-rs/wasm-runtime@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1" @@ -85,6 +421,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== +"@opentelemetry/api@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" + integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== + "@parcel/watcher-android-arm64@2.5.1": version "2.5.1" resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1" @@ -174,6 +515,351 @@ "@parcel/watcher-win32-ia32" "2.5.1" "@parcel/watcher-win32-x64" "2.5.1" +"@phosphor-icons/core@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@phosphor-icons/core/-/core-2.1.1.tgz#62a4cfbec9772f1a613a647da214fbb96f3ad39d" + integrity sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ== + +"@replit/codemirror-css-color-picker@^6.3.0": + version "6.3.0" + resolved "https://registry.yarnpkg.com/@replit/codemirror-css-color-picker/-/codemirror-css-color-picker-6.3.0.tgz#069835261d2b7b7ff5cb5f3ce354253d6e7e1100" + integrity sha512-19biDANghUm7Fz7L1SNMIhK48tagaWuCOHj4oPPxc7hxPGkTVY2lU/jVZ8tsbTKQPVG7BO2CBDzs7CBwb20t4A== + +"@scalar/agent-chat@0.12.22": + version "0.12.22" + resolved "https://registry.yarnpkg.com/@scalar/agent-chat/-/agent-chat-0.12.22.tgz#a9c25d27557a68a19493f052655fece599fe473a" + integrity sha512-TQ4K8DOPMzRSwFWEVuJp7a+JQoiPD4WxbG0d7sJilC1dgtdDS6efRmadh87eRa17N+06xWfx5EcXg8hDrfKAFw== + dependencies: + "@ai-sdk/vue" "3.0.33" + "@scalar/api-client" "3.13.7" + "@scalar/components" "0.27.9" + "@scalar/helpers" "0.9.2" + "@scalar/icons" "0.7.5" + "@scalar/json-magic" "0.12.19" + "@scalar/openapi-types" "0.9.3" + "@scalar/schemas" "0.7.4" + "@scalar/themes" "0.17.1" + "@scalar/types" "0.16.4" + "@scalar/use-toasts" "0.10.4" + "@scalar/validation" "0.6.2" + "@scalar/workspace-store" "0.55.6" + "@vueuse/core" "13.9.0" + ai "6.0.33" + js-base64 "^3.7.8" + neverpanic "0.0.8" + truncate-json "3.0.1" + vue "^3.5.30" + +"@scalar/api-client@3.13.7": + version "3.13.7" + resolved "https://registry.yarnpkg.com/@scalar/api-client/-/api-client-3.13.7.tgz#7393015fedd0fa06f100c9bb1a2038ac01ef0590" + integrity sha512-bd+2nI27gnfCgzCGEP13u7rd2IlNdAB+Rx+CX0i29UXEu6SVxWpv5hBVrLLTQebfNzcUk9Evr+NkIX/WjSHp0Q== + dependencies: + "@headlessui/tailwindcss" "^0.2.2" + "@headlessui/vue" "1.7.23" + "@scalar/blocks" "0.1.8" + "@scalar/components" "0.27.9" + "@scalar/helpers" "0.9.2" + "@scalar/icons" "0.7.5" + "@scalar/oas-utils" "0.19.8" + "@scalar/openapi-types" "0.9.3" + "@scalar/sidebar" "0.9.33" + "@scalar/snippetz" "0.9.23" + "@scalar/themes" "0.17.1" + "@scalar/typebox" "^0.1.3" + "@scalar/types" "0.16.4" + "@scalar/use-codemirror" "0.14.14" + "@scalar/use-hooks" "0.4.9" + "@scalar/use-toasts" "0.10.4" + "@scalar/workspace-store" "0.55.6" + "@vueuse/core" "13.9.0" + "@vueuse/integrations" "13.9.0" + focus-trap "^7.8.0" + fuse.js "^7.1.0" + js-base64 "^3.7.8" + jsonc-parser "3.3.1" + nanoid "^5.1.6" + pretty-ms "^9.3.0" + radix-vue "^1.9.17" + set-cookie-parser "3.1.0" + vue "^3.5.30" + yaml "^2.8.3" + zod "^4.3.5" + +"@scalar/api-reference@1.63.0": + version "1.63.0" + resolved "https://registry.yarnpkg.com/@scalar/api-reference/-/api-reference-1.63.0.tgz#3c16aca56acc5dbec7d9f402604c64e4dba65098" + integrity sha512-ie2DfrOeElmaVmY/xHd3aMfUpA5ApBDuPHYbcypn01l7rQPGQMFjPa7GX4QNzp4tgjnEMX3X9BYKSkyMQtH5ng== + dependencies: + "@headlessui/vue" "1.7.23" + "@scalar/agent-chat" "0.12.22" + "@scalar/api-client" "3.13.7" + "@scalar/blocks" "0.1.8" + "@scalar/code-highlight" "0.4.3" + "@scalar/components" "0.27.9" + "@scalar/helpers" "0.9.2" + "@scalar/icons" "0.7.5" + "@scalar/oas-utils" "0.19.8" + "@scalar/schemas" "0.7.4" + "@scalar/sidebar" "0.9.33" + "@scalar/snippetz" "0.9.23" + "@scalar/themes" "0.17.1" + "@scalar/types" "0.16.4" + "@scalar/use-hooks" "0.4.9" + "@scalar/use-toasts" "0.10.4" + "@scalar/validation" "0.6.2" + "@scalar/workspace-store" "0.55.6" + "@unhead/vue" "^2.1.4" + "@vueuse/core" "13.9.0" + fuse.js "^7.1.0" + microdiff "^1.5.0" + nanoid "^5.1.6" + vue "^3.5.30" + yaml "^2.8.3" + +"@scalar/asyncapi-upgrader@0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@scalar/asyncapi-upgrader/-/asyncapi-upgrader-0.1.4.tgz#5c6548434777773b055fdc626aaab687d940ef6e" + integrity sha512-pyAoyuFVlUf+ZThiuHAShV4lyF9LlPab5AJrvoER8JBz00bHF1NWiGveYySRcNcvke5U5nWCr7NxtiMIokP8sA== + dependencies: + "@scalar/helpers" "0.9.2" + +"@scalar/blocks@0.1.8": + version "0.1.8" + resolved "https://registry.yarnpkg.com/@scalar/blocks/-/blocks-0.1.8.tgz#61e0186feb191770c8fc749eee3a35ff83b38015" + integrity sha512-I0bOMnb8+8cjboaRMWz4Vo16rcYu8+4tzLIpPeJHsJs4I5OaIIgaoNHRPaWDip9ZrqbD4m1I2gMDJmpJU/hEmA== + dependencies: + "@scalar/components" "0.27.9" + "@scalar/helpers" "0.9.2" + "@scalar/icons" "0.7.5" + "@scalar/snippetz" "0.9.23" + "@scalar/themes" "0.17.1" + "@scalar/types" "0.16.4" + "@scalar/workspace-store" "0.55.6" + "@types/har-format" "^1.2.16" + js-base64 "^3.7.8" + vue "^3.5.30" + +"@scalar/code-highlight@0.4.3": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@scalar/code-highlight/-/code-highlight-0.4.3.tgz#2ec31ef62e97c452e268c8ee293a5513c8a53077" + integrity sha512-uueW22siajrJUtszH+k/PmABqau2MmJzajpEFTPapK7Zak167xcKUIjcMbFFFgW8SGKLMSVzLkuB/VNDbOHuOg== + dependencies: + hast-util-to-text "^4.0.2" + highlight.js "^11.11.1" + lowlight "^3.3.0" + rehype-external-links "^3.0.0" + rehype-format "^5.0.1" + rehype-parse "^9.0.1" + rehype-raw "^7.0.0" + rehype-sanitize "^6.0.0" + rehype-stringify "^10.0.1" + remark-gfm "^4.0.1" + remark-parse "^11.0.0" + remark-rehype "^11.1.2" + remark-stringify "^11.0.0" + unified "^11.0.5" + unist-util-visit "^5.1.0" + +"@scalar/components@0.27.9": + version "0.27.9" + resolved "https://registry.yarnpkg.com/@scalar/components/-/components-0.27.9.tgz#043cc55f3efb9ec800f76e28e69273c0ed36920c" + integrity sha512-AoUTHwTfVc44zZbZnridPd7mp8lhe2LgX9KeyKw5keFlhLY1MzQv9OuXD7riLbJPrFFF/7lbqCLEpyG6vWSXQg== + dependencies: + "@floating-ui/utils" "0.2.10" + "@floating-ui/vue" "1.1.9" + "@headlessui/tailwindcss" "^0.2.2" + "@headlessui/vue" "1.7.23" + "@scalar/code-highlight" "0.4.3" + "@scalar/helpers" "0.9.2" + "@scalar/icons" "0.7.5" + "@scalar/themes" "0.17.1" + "@scalar/use-hooks" "0.4.9" + "@vueuse/core" "13.9.0" + cva "1.0.0-beta.4" + radix-vue "^1.9.17" + vue "^3.5.30" + vue-component-type-helpers "^3.2.6" + +"@scalar/helpers@0.9.2": + version "0.9.2" + resolved "https://registry.yarnpkg.com/@scalar/helpers/-/helpers-0.9.2.tgz#e030a20d47c108e3ebd085b0cd1a343e8f2f646a" + integrity sha512-hjyMpMZjTBZQhyByZmz5oUgRKUQJO5V5AOiJxsVEGbUmgA7sJRQeTrXLB+BEwzaKS5nm2opJeNyMBYLFNK4hiQ== + +"@scalar/icons@0.7.5": + version "0.7.5" + resolved "https://registry.yarnpkg.com/@scalar/icons/-/icons-0.7.5.tgz#1c938618f4253c0a2fc0163a24f01af8750a87f7" + integrity sha512-kWYkjmYlHzrZ+31d8L1uJpVeil6r1xNcyR+MewnxFslpMBCDIeo9udhYzg09v8s6nWKxwuPokhdQPLbKS+SSgg== + dependencies: + "@phosphor-icons/core" "^2.1.1" + "@types/node" "^24.1.0" + chalk "^5.6.2" + vue "^3.5.30" + +"@scalar/json-magic@0.12.19": + version "0.12.19" + resolved "https://registry.yarnpkg.com/@scalar/json-magic/-/json-magic-0.12.19.tgz#006adbd5c9cf7057e91a6c8047bab2c855c73cf3" + integrity sha512-1T4QoFYZ1nKt25xFeHtghAuZzaLq2X4CpCSLFXG0Fjcz6K2HIZqo+RtywfI0WD8RRRRgS34keo7X4Gv1BQUNoQ== + dependencies: + "@scalar/helpers" "0.9.2" + pathe "^2.0.3" + yaml "^2.8.3" + +"@scalar/oas-utils@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@scalar/oas-utils/-/oas-utils-0.19.8.tgz#c0c97ba49fc9fe473a84fcb2a201db82a48d957d" + integrity sha512-A/bGTqthxnsgoveQ7Xomxtwk0JQ7V/cweaxz/Dhp9/JmvnAnjBjpcG050gcC1EuVkHhJL1sPH2+IczS9liQQ3A== + dependencies: + "@scalar/helpers" "0.9.2" + "@scalar/themes" "0.17.1" + "@scalar/types" "0.16.4" + "@scalar/workspace-store" "0.55.6" + flatted "^3.4.0" + vue "^3.5.30" + yaml "^2.8.3" + +"@scalar/openapi-types@0.9.3": + version "0.9.3" + resolved "https://registry.yarnpkg.com/@scalar/openapi-types/-/openapi-types-0.9.3.tgz#1e764842a8ca258820a0565f96a9a71b87615d55" + integrity sha512-34qglt5jSo55iZfH9i7EhjQCdE0Po2xZeh8wytQKolSnXrxsYMSyFDJEBxz1Gaew4on9N3XIWsd0QpwKVA5CSA== + +"@scalar/openapi-upgrader@0.2.11": + version "0.2.11" + resolved "https://registry.yarnpkg.com/@scalar/openapi-upgrader/-/openapi-upgrader-0.2.11.tgz#d8b15dfead72918bb019986280dec257b8445596" + integrity sha512-eYEFBO8mZfgXEO/hv8rdL5OA4oOB8orFT5kXNK4I/x9xca2D7A4BteuFXqRaw9lE1CIjtG+StlAUYVz5omTXew== + dependencies: + "@scalar/openapi-types" "0.9.3" + +"@scalar/schemas@0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@scalar/schemas/-/schemas-0.7.4.tgz#b51136653bd0b89adeaaef9f6c655151553a462d" + integrity sha512-Or31zxR+ceGGhkVU5XBO2Zv7oyGDtjsJOjM2Rr0WRZ1/tRsQc2/FsVv+9kPmCA7sqFL8BKWlNfTXcPITI7WRHw== + dependencies: + "@scalar/helpers" "0.9.2" + "@scalar/validation" "0.6.2" + +"@scalar/sidebar@0.9.33": + version "0.9.33" + resolved "https://registry.yarnpkg.com/@scalar/sidebar/-/sidebar-0.9.33.tgz#01abcd0c478be1bb98f4fb77a8d3720c79cf0fba" + integrity sha512-y00qMs6dPu4Te+OuyyGSg0hGWTPsnUrn7juErtF4bOIse2w9BM1rHkYZWSdEL/afYv27x1wMBZJwAReiUeHguA== + dependencies: + "@scalar/components" "0.27.9" + "@scalar/helpers" "0.9.2" + "@scalar/icons" "0.7.5" + "@scalar/themes" "0.17.1" + "@scalar/use-hooks" "0.4.9" + "@scalar/workspace-store" "0.55.6" + vue "^3.5.30" + +"@scalar/snippetz@0.9.23": + version "0.9.23" + resolved "https://registry.yarnpkg.com/@scalar/snippetz/-/snippetz-0.9.23.tgz#7883b5b2642a685d2e6e1432dad705f971395655" + integrity sha512-bjahBX9J7VQsOpDERMPZSPuIKZmz1DEh9NHdCE5dEe6uz1rHicai0Zqjpuduw6DVDVyWrDI0trxPzCqGzpj0oQ== + dependencies: + "@scalar/helpers" "0.9.2" + "@scalar/types" "0.16.4" + js-base64 "^3.7.8" + stringify-object "^6.0.0" + +"@scalar/themes@0.17.1": + version "0.17.1" + resolved "https://registry.yarnpkg.com/@scalar/themes/-/themes-0.17.1.tgz#bae47ae920355981576f224e2a57e1850cc708f6" + integrity sha512-aqR6o44CpDvgVT6dHlc1/56JRdWUrbNgaLyHKEd1KC/+Gcznix9Ud0fW7cKw2VvNgPgcqlO+MhZIrYHwbkNMew== + dependencies: + nanoid "^5.1.6" + +"@scalar/typebox@0.1.3", "@scalar/typebox@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@scalar/typebox/-/typebox-0.1.3.tgz#0959377d9ddbf73c97a3ac8ba8af672061863945" + integrity sha512-lU055AUccECZMIfGA0z/C1StYmboAYIPJLDFBzOO81yXBi35Pxdq+I4fWX6iUZ8qcoHneiLGk9jAUM1rA93iEg== + +"@scalar/types@0.16.4": + version "0.16.4" + resolved "https://registry.yarnpkg.com/@scalar/types/-/types-0.16.4.tgz#28190d6c49c769929f9ef7a0738901228efdc76a" + integrity sha512-fLf0ANAC3iQq0sIVdmH6aGM+pFSLyD8GfGBxSWcLpI0jE0iFAzX2A3p0qVZm7vqBT4TQnn0fSwg5U+h+FZ52BA== + dependencies: + "@scalar/helpers" "0.9.2" + nanoid "^5.1.6" + type-fest "^5.3.1" + zod "^4.3.5" + +"@scalar/use-codemirror@0.14.14": + version "0.14.14" + resolved "https://registry.yarnpkg.com/@scalar/use-codemirror/-/use-codemirror-0.14.14.tgz#2c4e45f9ae4e47b3a3fba6af2d6cbc9638c16a18" + integrity sha512-A7Exfctudg7d3DeQFSSrZYc6yhrEsLX3Iq6GJ7m7ruqgev9y8Hbx7g8yvZWYRQuMUdFtLH5euP7a1ZwM8OrHKA== + dependencies: + "@codemirror/autocomplete" "^6.18.3" + "@codemirror/commands" "^6.7.1" + "@codemirror/lang-css" "^6.3.1" + "@codemirror/lang-html" "^6.4.8" + "@codemirror/lang-json" "^6.0.0" + "@codemirror/lang-xml" "^6.0.0" + "@codemirror/lang-yaml" "^6.1.2" + "@codemirror/language" "^6.10.7" + "@codemirror/lint" "^6.8.4" + "@codemirror/state" "^6.5.0" + "@codemirror/view" "^6.35.3" + "@lezer/common" "^1.2.3" + "@lezer/highlight" "^1.2.1" + "@replit/codemirror-css-color-picker" "^6.3.0" + vue "^3.5.30" + +"@scalar/use-hooks@0.4.9": + version "0.4.9" + resolved "https://registry.yarnpkg.com/@scalar/use-hooks/-/use-hooks-0.4.9.tgz#1d2c6d975d3df1ad7015c5417cd6d1acd1ac6942" + integrity sha512-RnflJ178BPdH7pKY9/YBcRlhyc7uJxtqG4hBbi8IPEM4YkpLHcMmxWKgHAgC3MBc21rpuKKBE+DTJOiYmU+UyA== + dependencies: + "@scalar/use-toasts" "0.10.4" + "@scalar/validation" "0.6.2" + "@vueuse/core" "13.9.0" + cva "1.0.0-beta.4" + tailwind-merge "3.5.0" + vue "^3.5.30" + +"@scalar/use-toasts@0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@scalar/use-toasts/-/use-toasts-0.10.4.tgz#9c8b587a50caacfec5be344aaf982759de1189a0" + integrity sha512-OHXkVfFKV0qx2WpKlzUpvIUkoA/PiloBcXe6soX2S/1z/D042QlKO8rNxkuC3Hlamvyvj8ru8XN17XNl+D0OPg== + dependencies: + vue "^3.5.30" + vue-sonner "^1.3.2" + +"@scalar/validation@0.6.2": + version "0.6.2" + resolved "https://registry.yarnpkg.com/@scalar/validation/-/validation-0.6.2.tgz#79264de2375b5e21bcabb75e116ab3580fe3666c" + integrity sha512-Sc1TkcwGV6aVCO51AyKeaGiP8gpwAHxEtO5d3tZzPV+KsnlC/YokQxFxwBrbIXw73k9hmcExnJyGu3k5i6n6VA== + +"@scalar/workspace-store@0.55.6": + version "0.55.6" + resolved "https://registry.yarnpkg.com/@scalar/workspace-store/-/workspace-store-0.55.6.tgz#e611f9963a808f9514b7c6c295a72436a6a18ec2" + integrity sha512-l7yuW1XplVRu+bqN7Mt+XtMovuKRBOHSGb00e+nEvjkkL1WIFd6pMJCogYt0ZlLvyLFPbWODWA4oG6LZjr7ijQ== + dependencies: + "@scalar/asyncapi-upgrader" "0.1.4" + "@scalar/helpers" "0.9.2" + "@scalar/json-magic" "0.12.19" + "@scalar/openapi-upgrader" "0.2.11" + "@scalar/schemas" "0.7.4" + "@scalar/snippetz" "0.9.23" + "@scalar/typebox" "0.1.3" + "@scalar/types" "0.16.4" + "@scalar/validation" "0.6.2" + js-base64 "^3.7.8" + type-fest "^5.3.1" + vue "^3.5.30" + yaml "^2.8.3" + +"@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@swc/helpers@^0.5.0": + version "0.5.23" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.23.tgz#19287d0d86d962b111376039a50c792902c9a86a" + integrity sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw== + dependencies: + tslib "^2.8.0" + "@swc/helpers@^0.5.12": version "0.5.21" resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.21.tgz#0b1b020317ee1282860ca66f7e9a7c7790f05ae0" @@ -299,6 +985,18 @@ "@tailwindcss/oxide-win32-arm64-msvc" "4.3.3" "@tailwindcss/oxide-win32-x64-msvc" "4.3.3" +"@tanstack/virtual-core@3.17.6": + version "3.17.6" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz#eaf853f21920b9dec3445c61ce13275b2f50ccf2" + integrity sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw== + +"@tanstack/vue-virtual@^3.0.0-beta.60", "@tanstack/vue-virtual@^3.8.1": + version "3.13.34" + resolved "https://registry.yarnpkg.com/@tanstack/vue-virtual/-/vue-virtual-3.13.34.tgz#a989a2f94d489e334e2d4a79bf1037c0eb233e5f" + integrity sha512-CBqbCcnVsKpl9IJ7frPnbBmAqmc7JttSySg04kgLYJ4yYuC8JsAbtUHP0yLtWGLyByjihN3SwaDCgHQ+Z4iPoA== + dependencies: + "@tanstack/virtual-core" "3.17.6" + "@tybys/wasm-util@^0.10.1": version "0.10.1" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" @@ -320,16 +1018,54 @@ dependencies: "@types/tern" "*" +"@types/debug@^4.0.0": + version "4.1.13" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.13.tgz#22d1cc9d542d3593caea764f974306ab36286ee7" + integrity sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw== + dependencies: + "@types/ms" "*" + "@types/estree@*": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/har-format@^1.2.16": + version "1.2.16" + resolved "https://registry.yarnpkg.com/@types/har-format/-/har-format-1.2.16.tgz#b71ede8681400cc08b3685f061c31e416cf94944" + integrity sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A== + +"@types/hast@^3.0.0": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.5.tgz#48020de4c0e63492f4ca9db42068c108f68b7f8f" + integrity sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g== + dependencies: + "@types/unist" "*" + "@types/marked@^4.0.7": version "4.3.2" resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.3.2.tgz#e2e0ad02ebf5626bd215c5bae2aff6aff0ce9eac" integrity sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w== +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + +"@types/ms@*": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + +"@types/node@^24.1.0": + version "24.13.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.13.3.tgz#49f18bd3c647866dcda51a0756c145e14590ce16" + integrity sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q== + dependencies: + undici-types "~7.18.0" + "@types/tern@*": version "0.23.9" resolved "https://registry.yarnpkg.com/@types/tern/-/tern-0.23.9.tgz#6f6093a4a9af3e6bb8dde528e024924d196b367c" @@ -337,6 +1073,88 @@ dependencies: "@types/estree" "*" +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + +"@types/web-bluetooth@^0.0.20": + version "0.0.20" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597" + integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== + +"@types/web-bluetooth@^0.0.21": + version "0.0.21" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63" + integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA== + +"@ungap/structured-clone@^1.0.0": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.3.tgz#094041e1a4cb1987f038335421281ac8be390bcc" + integrity sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg== + +"@unhead/vue@^2.1.4": + version "2.1.16" + resolved "https://registry.yarnpkg.com/@unhead/vue/-/vue-2.1.16.tgz#2cf22116e78790fbca06d297752cd86d0e5c81a2" + integrity sha512-t6QykImeMJcrUTbFB0Coy0cB/OZItHdQFRFLoH8GAVXKWmQORGPrwvueP9me7adOCuMH2uVvrv0nCkbi6zksaA== + dependencies: + hookable "^6.0.1" + unhead "2.1.16" + +"@vercel/oidc@3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@vercel/oidc/-/oidc-3.1.0.tgz#066caee449b84079f33c7445fc862464fe10ec32" + integrity sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w== + +"@vue/compiler-core@3.5.40": + version "3.5.40" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.40.tgz#a3e9e280ef3dccb6de4c7707ba50d7e10fd598a5" + integrity sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ== + dependencies: + "@babel/parser" "^7.29.7" + "@vue/shared" "3.5.40" + entities "^7.0.1" + estree-walker "^2.0.2" + source-map-js "^1.2.1" + +"@vue/compiler-dom@3.5.40": + version "3.5.40" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz#ac0579d4dc257bf5e10ce324a7ce0a7f9fc5c338" + integrity sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA== + dependencies: + "@vue/compiler-core" "3.5.40" + "@vue/shared" "3.5.40" + +"@vue/compiler-sfc@3.5.40": + version "3.5.40" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz#22252b2e5a58a6b6a4f71565cfa91271bddbe782" + integrity sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw== + dependencies: + "@babel/parser" "^7.29.7" + "@vue/compiler-core" "3.5.40" + "@vue/compiler-dom" "3.5.40" + "@vue/compiler-ssr" "3.5.40" + "@vue/shared" "3.5.40" + estree-walker "^2.0.2" + magic-string "^0.30.21" + postcss "^8.5.19" + source-map-js "^1.2.1" + +"@vue/compiler-ssr@3.5.40": + version "3.5.40" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz#c8c80efd22f5d85ea775d6ead787a108dc86cd58" + integrity sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg== + dependencies: + "@vue/compiler-dom" "3.5.40" + "@vue/shared" "3.5.40" + +"@vue/reactivity@3.5.40": + version "3.5.40" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.40.tgz#91379172a9e9daba6737c6931382127976c830ba" + integrity sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA== + dependencies: + "@vue/shared" "3.5.40" + "@vue/reactivity@~3.1.1": version "3.1.5" resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.1.5.tgz#dbec4d9557f7c8f25c2635db1e23a78a729eb991" @@ -344,15 +1162,106 @@ dependencies: "@vue/shared" "3.1.5" +"@vue/runtime-core@3.5.40": + version "3.5.40" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.40.tgz#abdd20bc19244154eac7bcaf5ec68e95d5d8b1bc" + integrity sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw== + dependencies: + "@vue/reactivity" "3.5.40" + "@vue/shared" "3.5.40" + +"@vue/runtime-dom@3.5.40": + version "3.5.40" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz#ed72f072c31e7868c0c59ad5883138cd7e0d88a5" + integrity sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ== + dependencies: + "@vue/reactivity" "3.5.40" + "@vue/runtime-core" "3.5.40" + "@vue/shared" "3.5.40" + csstype "^3.2.3" + +"@vue/server-renderer@3.5.40": + version "3.5.40" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.40.tgz#94c7ccd0bf14ec3b19047e56b86f798be6736fda" + integrity sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw== + dependencies: + "@vue/compiler-ssr" "3.5.40" + "@vue/runtime-dom" "3.5.40" + "@vue/shared" "3.5.40" + "@vue/shared@3.1.5": version "3.1.5" resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.1.5.tgz#74ee3aad995d0a3996a6bb9533d4d280514ede03" integrity sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA== +"@vue/shared@3.5.40": + version "3.5.40" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.40.tgz#fc09d1871d66cd9b01959a597b41d7cb61f716a8" + integrity sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg== + +"@vueuse/core@13.9.0": + version "13.9.0" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-13.9.0.tgz#051aeff47a259e9e4d7d0cc3e54879817b0cbcad" + integrity sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA== + dependencies: + "@types/web-bluetooth" "^0.0.21" + "@vueuse/metadata" "13.9.0" + "@vueuse/shared" "13.9.0" + +"@vueuse/core@^10.11.0": + version "10.11.1" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.11.1.tgz#15d2c0b6448d2212235b23a7ba29c27173e0c2c6" + integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww== + dependencies: + "@types/web-bluetooth" "^0.0.20" + "@vueuse/metadata" "10.11.1" + "@vueuse/shared" "10.11.1" + vue-demi ">=0.14.8" + +"@vueuse/integrations@13.9.0": + version "13.9.0" + resolved "https://registry.yarnpkg.com/@vueuse/integrations/-/integrations-13.9.0.tgz#1bd1d77093a327321cca00e2bbf5da7b18aa6b43" + integrity sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ== + dependencies: + "@vueuse/core" "13.9.0" + "@vueuse/shared" "13.9.0" + +"@vueuse/metadata@10.11.1": + version "10.11.1" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7" + integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw== + +"@vueuse/metadata@13.9.0": + version "13.9.0" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-13.9.0.tgz#57c738d99661c33347080c0bc4cd11160e0d0881" + integrity sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg== + +"@vueuse/shared@10.11.1", "@vueuse/shared@^10.11.0": + version "10.11.1" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.11.1.tgz#62b84e3118ae6e1f3ff38f4fbe71b0c5d0f10938" + integrity sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA== + dependencies: + vue-demi ">=0.14.8" + +"@vueuse/shared@13.9.0": + version "13.9.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.9.0.tgz#7168b4ed647e625b05eb4e7e80fe8aabd00e3923" + integrity sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g== + JUMFlot@jumjum123/JUMFlot#*: version "0.0.0" resolved "https://codeload.github.com/jumjum123/JUMFlot/tar.gz/203147fa2ace27db89e2defcde0800654015ae23" +ai@6.0.33: + version "6.0.33" + resolved "https://registry.yarnpkg.com/ai/-/ai-6.0.33.tgz#15b0f48ecfc5f9f9c5cfdfac28ee1b3c4d1efeb3" + integrity sha512-bVokbmy2E2QF6Efl+5hOJx5MRWoacZ/CZY/y1E+VcewknvGlgaiCzMu8Xgddz6ArFJjiMFNUPHKxAhIePE4rmg== + dependencies: + "@ai-sdk/gateway" "3.0.13" + "@ai-sdk/provider" "3.0.2" + "@ai-sdk/provider-utils" "4.0.5" + "@opentelemetry/api" "1.9.0" + alpinejs@^3.15: version "3.15.12" resolved "https://registry.yarnpkg.com/alpinejs/-/alpinejs-3.15.12.tgz#2ccd224961ba6236ca379c2abbcfa0bfeade07ee" @@ -360,6 +1269,18 @@ alpinejs@^3.15: dependencies: "@vue/reactivity" "~3.1.1" +aria-hidden@^1.2.4: + version "1.2.6" + resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.6.tgz#73051c9b088114c795b1ea414e9c0fff874ffc1a" + integrity sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA== + dependencies: + tslib "^2.0.0" + +bail@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d" + integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw== + base64-js@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" @@ -414,6 +1335,31 @@ browserify-zlib@^0.2.0: dependencies: pako "~1.0.5" +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + +chalk@^5.6.2: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + +character-entities@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" + integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== + chart.js@^4.4: version "4.5.1" resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.1.tgz#19dd1a9a386a3f6397691672231cb5fc9c052c35" @@ -449,6 +1395,11 @@ clone@^2.1.2: resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + codemirror-spell-checker@1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz#1c660f9089483ccb5113b9ba9ca19c3f4993371e" @@ -461,11 +1412,38 @@ codemirror@^5.65.15: resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.21.tgz#cacf320606c5450ad3b3da34bb9c666afec21068" integrity sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ== +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + +convert-hrtime@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/convert-hrtime/-/convert-hrtime-5.0.0.tgz#f2131236d4598b95de856926a67100a0a97e9fa3" + integrity sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +crelt@^1.0.5, crelt@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.7.tgz#3b441b2ddfa73161d6a2770aa4cd677f895eaf28" + integrity sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA== + +csstype@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +cva@1.0.0-beta.4: + version "1.0.0-beta.4" + resolved "https://registry.yarnpkg.com/cva/-/cva-1.0.0-beta.4.tgz#3feb8b403a1774110eb34e2c409cb0b7c7fbe243" + integrity sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ== + dependencies: + clsx "^2.1.1" + datatables.net-bs@^2: version "2.3.8" resolved "https://registry.yarnpkg.com/datatables.net-bs/-/datatables.net-bs-2.3.8.tgz#1dd57f2320e2fb1e928aa4a809fca7b65550f37e" @@ -506,11 +1484,35 @@ datatables.net@2.3.8, datatables.net@^2, datatables.net@^2.3.8: dependencies: jquery ">=1.7" +debug@^4.0.0: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +decode-named-character-reference@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz#3e40603760874c2e5867691b599d73a7da25b53f" + integrity sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q== + dependencies: + character-entities "^2.0.0" + +defu@^6.1.4: + version "6.1.7" + resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.7.tgz#72543567c8e9f97ff13ce402b6dbe09ac5ae4d23" + integrity sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ== + delegate@^3.1.2: version "3.2.0" resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== +dequal@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" @@ -521,6 +1523,13 @@ detect-libc@^2.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== +devlop@^1.0.0, devlop@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + dfa@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/dfa/-/dfa-1.2.0.tgz#96ac3204e2d29c49ea5b57af8d92c2ae12790657" @@ -559,11 +1568,41 @@ enhanced-resolve@^5.24.1: graceful-fs "^4.2.4" tapable "^2.3.3" +entities@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" + integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== + +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + +escape-string-regexp@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" + integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + eve-raphael@0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30" integrity sha512-jrxnPsCGqng1UZuEp9DecX/AuSyAszATSjf4oEcRxvfxa1Oux4KkIPKBAAWWnpdwfARtr+Q0o9aPYWjsROD7ug== +eventsource-parser@^3.0.6: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.1.0.tgz#4e198eb91cd333d0a8ddcc036502b3618a25f449" + integrity sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg== + +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -581,10 +1620,22 @@ flatpickr@^4.6: resolved "https://registry.yarnpkg.com/flatpickr/-/flatpickr-4.6.13.tgz#8a029548187fd6e0d670908471e43abe9ad18d94" integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw== +flatted@^3.4.0: + version "3.4.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.3.tgz#be5c21e943b2d7a328bb23795ae28c98f0103a9e" + integrity sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ== + flot@flot/flot#~0.8.3: version "0.8.3" resolved "https://codeload.github.com/flot/flot/tar.gz/453b017cc5acfd75e252b93e8635f57f4196d45d" +focus-trap@^7.8.0: + version "7.8.0" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-7.8.0.tgz#b1d9463fa42b93ad7a5223d750493a6c09b672a8" + integrity sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA== + dependencies: + tabbable "^6.4.0" + font-awesome@^4.0.0: version "4.7.0" resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" @@ -615,6 +1666,21 @@ fullcalendar@^3.10.2: resolved "https://registry.yarnpkg.com/fullcalendar/-/fullcalendar-3.10.5.tgz#57a3a64d7d744181582bb9e1be32d1846e1db53a" integrity sha512-JGWpECKgza/344bbF5QT0hBJpx04DZ/7QGPlR1ZbAwrG6Yz6mWEkQd+NnZUh1sK6HCBIPnPRW2x53aJxeLGvvQ== +function-timeout@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/function-timeout/-/function-timeout-1.0.2.tgz#e5a7b6ffa523756ff20e1231bbe37b5f373aadd5" + integrity sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA== + +fuse.js@^7.1.0: + version "7.5.0" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.5.0.tgz#c591b7bddad0135b9d28d190daa9415dd754c4c7" + integrity sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow== + +get-own-enumerable-keys@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz#59bbda0f7e7469c8c74086e08f79f1381b203899" + integrity sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA== + good-listener@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" @@ -632,11 +1698,226 @@ graceful-fs@^4.2.4: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== +guess-json-indent@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/guess-json-indent/-/guess-json-indent-3.0.1.tgz#bcee6ba400c9d254c1421a0d320c780ee370e33b" + integrity sha512-LWZ3Vr8BG7DHE3TzPYFqkhjNRw4vYgFSsv2nfMuHklAlOfiy54/EwiDQuQfFVLxENCVv20wpbjfTayooQHrEhQ== + +hast-util-embedded@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz#be4477780fbbe079cdba22982e357a0de4ba853e" + integrity sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA== + dependencies: + "@types/hast" "^3.0.0" + hast-util-is-element "^3.0.0" + +hast-util-format@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/hast-util-format/-/hast-util-format-1.1.0.tgz#373e77382e07deb04f6676f1b4437e7d8549d985" + integrity sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA== + dependencies: + "@types/hast" "^3.0.0" + hast-util-embedded "^3.0.0" + hast-util-minify-whitespace "^1.0.0" + hast-util-phrasing "^3.0.0" + hast-util-whitespace "^3.0.0" + html-whitespace-sensitive-tag-names "^3.0.0" + unist-util-visit-parents "^6.0.0" + +hast-util-from-html@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz#485c74785358beb80c4ba6346299311ac4c49c82" + integrity sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw== + dependencies: + "@types/hast" "^3.0.0" + devlop "^1.1.0" + hast-util-from-parse5 "^8.0.0" + parse5 "^7.0.0" + vfile "^6.0.0" + vfile-message "^4.0.0" + +hast-util-from-parse5@^8.0.0: + version "8.0.3" + resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz#830a35022fff28c3fea3697a98c2f4cc6b835a2e" + integrity sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + devlop "^1.0.0" + hastscript "^9.0.0" + property-information "^7.0.0" + vfile "^6.0.0" + vfile-location "^5.0.0" + web-namespaces "^2.0.0" + +hast-util-has-property@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz#4e595e3cddb8ce530ea92f6fc4111a818d8e7f93" + integrity sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-is-body-ok-link@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz#ef63cb2f14f04ecf775139cd92bda5026380d8b4" + integrity sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-is-element@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz#6e31a6532c217e5b533848c7e52c9d9369ca0932" + integrity sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-minify-whitespace@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz#7588fd1a53f48f1d30406b81959dffc3650daf55" + integrity sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw== + dependencies: + "@types/hast" "^3.0.0" + hast-util-embedded "^3.0.0" + hast-util-is-element "^3.0.0" + hast-util-whitespace "^3.0.0" + unist-util-is "^6.0.0" + +hast-util-parse-selector@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz#352879fa86e25616036037dd8931fb5f34cb4a27" + integrity sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-phrasing@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz#fa284c0cd4a82a0dd6020de8300a7b1ebffa1690" + integrity sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ== + dependencies: + "@types/hast" "^3.0.0" + hast-util-embedded "^3.0.0" + hast-util-has-property "^3.0.0" + hast-util-is-body-ok-link "^3.0.0" + hast-util-is-element "^3.0.0" + +hast-util-raw@^9.0.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/hast-util-raw/-/hast-util-raw-9.1.0.tgz#79b66b26f6f68fb50dfb4716b2cdca90d92adf2e" + integrity sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + "@ungap/structured-clone" "^1.0.0" + hast-util-from-parse5 "^8.0.0" + hast-util-to-parse5 "^8.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + parse5 "^7.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + web-namespaces "^2.0.0" + zwitch "^2.0.0" + +hast-util-sanitize@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz#edb260d94e5bba2030eb9375790a8753e5bf391f" + integrity sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg== + dependencies: + "@types/hast" "^3.0.0" + "@ungap/structured-clone" "^1.0.0" + unist-util-position "^5.0.0" + +hast-util-to-html@^9.0.0: + version "9.0.5" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005" + integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + +hast-util-to-parse5@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz#95aa391cc0514b4951418d01c883d1038af42f5d" + integrity sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA== + dependencies: + "@types/hast" "^3.0.0" + comma-separated-tokens "^2.0.0" + devlop "^1.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + web-namespaces "^2.0.0" + zwitch "^2.0.0" + +hast-util-to-text@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz#57b676931e71bf9cb852453678495b3080bfae3e" + integrity sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + hast-util-is-element "^3.0.0" + unist-util-find-after "^5.0.0" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + +hastscript@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-9.0.1.tgz#dbc84bef6051d40084342c229c451cd9dc567dff" + integrity sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w== + dependencies: + "@types/hast" "^3.0.0" + comma-separated-tokens "^2.0.0" + hast-util-parse-selector "^4.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + +highlight.js@^11.11.1, highlight.js@~11.11.0: + version "11.11.1" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.11.1.tgz#fca06fa0e5aeecf6c4d437239135fabc15213585" + integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w== + +hookable@^6.0.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/hookable/-/hookable-6.1.1.tgz#825f966b4b426db2e622d94d7a31a70f196f9d2f" + integrity sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ== + +html-void-elements@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" + integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== + +html-whitespace-sensitive-tag-names@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz#c35edd28205f3bf8c1fd03274608d60b923de5b2" + integrity sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA== + htmx.org@^2.0: version "2.0.10" resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.10.tgz#62442b0e2952a885ae2e50a7654b8b20d0981134" integrity sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw== +identifier-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/identifier-regex/-/identifier-regex-1.1.0.tgz#042abfaf28db15223436661f3b9ec882649f6ef0" + integrity sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA== + dependencies: + reserved-identifiers "^1.0.0" + immediate@~3.0.5: version "3.0.6" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" @@ -647,6 +1928,11 @@ inherits@~2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +is-absolute-url@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-4.0.1.tgz#16e4d487d4fded05cfe0685e53ec86804a5e94dc" + integrity sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -659,11 +1945,34 @@ is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" +is-identifier@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-identifier/-/is-identifier-1.1.0.tgz#6b406e15a429f7196f6496e09f8333bcb4b2db1b" + integrity sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw== + dependencies: + identifier-regex "^1.1.0" + super-regex "^1.1.0" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-obj@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-3.0.0.tgz#b0889f1f9f8cb87e87df53a8d1230a2250f8b9be" + integrity sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ== + +is-plain-obj@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + +is-regexp@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-3.1.0.tgz#0235eab9cda5b83f96ac4a263d8c32c9d5ad7422" + integrity sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA== + isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -712,11 +2021,26 @@ jquery@^3.7.1: resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de" integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== +js-base64@^3.7.8: + version "3.9.1" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.9.1.tgz#02335c16c6a311c9135dfa71b4b3b7f1af33dd48" + integrity sha512-U73qptcvf/HIOauFOmqT3a0mDUp0MYlfd15oqoe9kqZt5XhiXVb+HG09sLvI9PQ9tZIBFS4nlErai8zbWazP0g== + js-md5@^0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/js-md5/-/js-md5-0.8.3.tgz#921bab7efa95bfc9d62b87ee08a57f8fe4305b69" integrity sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ== +json-schema@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== + +jsonc-parser@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jszip@^3.10.1: version "3.10.1" resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" @@ -823,6 +2147,20 @@ linebreak@^1.1.0: base64-js "0.0.8" unicode-trie "^2.0.0" +longest-streak@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" + integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== + +lowlight@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-3.3.0.tgz#007b8a5bfcfd27cc65b96246d2de3e9dd4e23c6c" + integrity sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ== + dependencies: + "@types/hast" "^3.0.0" + devlop "^1.0.0" + highlight.js "~11.11.0" + magic-string@^0.30.21: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" @@ -830,16 +2168,446 @@ magic-string@^0.30.21: dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" +make-asynchronous@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/make-asynchronous/-/make-asynchronous-1.1.0.tgz#6225f7f1ccaab9acaac5e2fcd0b075afefff19aa" + integrity sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg== + dependencies: + p-event "^6.0.0" + type-fest "^4.6.0" + web-worker "^1.5.0" + +markdown-table@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a" + integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw== + marked@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== +mdast-util-find-and-replace@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz#70a3174c894e14df722abf43bc250cbae44b11df" + integrity sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg== + dependencies: + "@types/mdast" "^4.0.0" + escape-string-regexp "^5.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + +mdast-util-from-markdown@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz#c95822b91aab75f18a4cbe8b2f51b873ed2cf0c7" + integrity sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + mdast-util-to-string "^4.0.0" + micromark "^4.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-decode-string "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + unist-util-stringify-position "^4.0.0" + +mdast-util-gfm-autolink-literal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz#abd557630337bd30a6d5a4bd8252e1c2dc0875d5" + integrity sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ== + dependencies: + "@types/mdast" "^4.0.0" + ccount "^2.0.0" + devlop "^1.0.0" + mdast-util-find-and-replace "^3.0.0" + micromark-util-character "^2.0.0" + +mdast-util-gfm-footnote@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz#7778e9d9ca3df7238cc2bd3fa2b1bf6a65b19403" + integrity sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.1.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + +mdast-util-gfm-strikethrough@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz#d44ef9e8ed283ac8c1165ab0d0dfd058c2764c16" + integrity sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm-table@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz#7a435fb6223a72b0862b33afbd712b6dae878d38" + integrity sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + markdown-table "^3.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm-task-list-item@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz#e68095d2f8a4303ef24094ab642e1047b991a936" + integrity sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz#2cdf63b92c2a331406b0fb0db4c077c1b0331751" + integrity sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ== + dependencies: + mdast-util-from-markdown "^2.0.0" + mdast-util-gfm-autolink-literal "^2.0.0" + mdast-util-gfm-footnote "^2.0.0" + mdast-util-gfm-strikethrough "^2.0.0" + mdast-util-gfm-table "^2.0.0" + mdast-util-gfm-task-list-item "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-phrasing@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3" + integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w== + dependencies: + "@types/mdast" "^4.0.0" + unist-util-is "^6.0.0" + +mdast-util-to-hast@^13.0.0: + version "13.2.1" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz#d7ff84ca499a57e2c060ae67548ad950e689a053" + integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +mdast-util-to-markdown@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b" + integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + longest-streak "^3.0.0" + mdast-util-phrasing "^4.0.0" + mdast-util-to-string "^4.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-decode-string "^2.0.0" + unist-util-visit "^5.0.0" + zwitch "^2.0.0" + +mdast-util-to-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814" + integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg== + dependencies: + "@types/mdast" "^4.0.0" + metismenu@~3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/metismenu/-/metismenu-3.0.7.tgz#613dd01d14d053474b926a1ecac24d137c934aaa" integrity sha512-omMwIAahlzssjSi3xY9ijkhXI8qEaQTqBdJ9lHmfV5Bld2UkxO2h2M3yWsteAlGJ/nSHi4e69WHDE2r18Ickyw== +microdiff@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/microdiff/-/microdiff-1.5.0.tgz#d16219b223396f11ffcf441da26a43d3e6bd06f8" + integrity sha512-Drq+/THMvDdzRYrK0oxJmOKiC24ayUV8ahrt8l3oRK51PWt6gdtrIGrlIH3pT/lFh1z93FbAcidtsHcWbnRz8Q== + +micromark-core-commonmark@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4" + integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg== + dependencies: + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-factory-destination "^2.0.0" + micromark-factory-label "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-factory-title "^2.0.0" + micromark-factory-whitespace "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-html-tag-name "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-autolink-literal@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz#6286aee9686c4462c1e3552a9d505feddceeb935" + integrity sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-footnote@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz#4dab56d4e398b9853f6fe4efac4fc9361f3e0750" + integrity sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw== + dependencies: + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-strikethrough@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz#86106df8b3a692b5f6a92280d3879be6be46d923" + integrity sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-table@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz#fac70bcbf51fe65f5f44033118d39be8a9b5940b" + integrity sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg== + dependencies: + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-tagfilter@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz#f26d8a7807b5985fba13cf61465b58ca5ff7dc57" + integrity sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg== + dependencies: + micromark-util-types "^2.0.0" + +micromark-extension-gfm-task-list-item@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz#bcc34d805639829990ec175c3eea12bb5b781f2c" + integrity sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw== + dependencies: + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz#3e13376ab95dd7a5cfd0e29560dfe999657b3c5b" + integrity sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w== + dependencies: + micromark-extension-gfm-autolink-literal "^2.0.0" + micromark-extension-gfm-footnote "^2.0.0" + micromark-extension-gfm-strikethrough "^2.0.0" + micromark-extension-gfm-table "^2.0.0" + micromark-extension-gfm-tagfilter "^2.0.0" + micromark-extension-gfm-task-list-item "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-destination@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639" + integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-label@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1" + integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg== + dependencies: + devlop "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-space@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc" + integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-title@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94" + integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-whitespace@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1" + integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-chunked@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051" + integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-classify-character@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629" + integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-combine-extensions@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9" + integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg== + dependencies: + micromark-util-chunked "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-decode-numeric-character-reference@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5" + integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-decode-string@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2" + integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-html-tag-name@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825" + integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA== + +micromark-util-normalize-identifier@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d" + integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-resolve-all@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b" + integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg== + dependencies: + micromark-util-types "^2.0.0" + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-subtokenize@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee" + integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== + +micromark@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb" + integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + micromatch@^4.0.5: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" @@ -867,11 +2635,43 @@ mri@^1.2.0: resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.16: + version "3.3.16" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" + integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== + +nanoid@^5.0.7, nanoid@^5.1.6: + version "5.1.16" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.16.tgz#fe345c0a1f9007c32fbb5c139e1208bfd3f41ef7" + integrity sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ== + +neverpanic@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/neverpanic/-/neverpanic-0.0.8.tgz#6ed127986a55c41a7d184b4106dd982f5451c18f" + integrity sha512-vVdkelrLxaow/fdWDumzNBO+jwm6X8bxeLJc34THtpj70u0C5QBkcV6CRCu2X726km7XD45N0A3QtYCla4RvKw== + node-addon-api@^7.0.0: version "7.1.1" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== +p-event@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/p-event/-/p-event-6.0.1.tgz#8f62a1e3616d4bc01fce3abda127e0383ef4715b" + integrity sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w== + dependencies: + p-timeout "^6.1.2" + +p-timeout@^6.1.2: + version "6.1.4" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.1.4.tgz#418e1f4dd833fa96a2e3f532547dd2abdb08dbc2" + integrity sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg== + pako@^0.2.5: version "0.2.9" resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" @@ -882,6 +2682,23 @@ pako@~1.0.2, pako@~1.0.5: resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +parse-ms@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-4.0.0.tgz#c0c058edd47c2a590151a718990533fd62803df4" + integrity sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw== + +parse5@^7.0.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05" + integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw== + dependencies: + entities "^6.0.0" + +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + pdfkit@^0.19.1: version "0.19.1" resolved "https://registry.yarnpkg.com/pdfkit/-/pdfkit-0.19.1.tgz#633d5f031ce6f1ba6ce141325c6cf87fa6acb0a2" @@ -920,11 +2737,49 @@ png-js@^1.1.0: dependencies: browserify-zlib "^0.2.0" +postcss@^8.5.19: + version "8.5.23" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594" + integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg== + dependencies: + nanoid "^3.3.16" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +pretty-ms@^9.3.0: + version "9.3.0" + resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-9.3.0.tgz#dd2524fcb3c326b4931b2272dfd1e1a8ed9a9f5a" + integrity sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ== + dependencies: + parse-ms "^4.0.0" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +property-information@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.2.0.tgz#0809b34264e995c0bfcd3227028a1e35210af80a" + integrity sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg== + +radix-vue@^1.9.17: + version "1.9.17" + resolved "https://registry.yarnpkg.com/radix-vue/-/radix-vue-1.9.17.tgz#d6aec1727148e21cfb105c46a4c20bf100c8eee7" + integrity sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ== + dependencies: + "@floating-ui/dom" "^1.6.7" + "@floating-ui/vue" "^1.1.0" + "@internationalized/date" "^3.5.4" + "@internationalized/number" "^3.5.3" + "@tanstack/vue-virtual" "^3.8.1" + "@vueuse/core" "^10.11.0" + "@vueuse/shared" "^10.11.0" + aria-hidden "^1.2.4" + defu "^6.1.4" + fast-deep-equal "^3.1.3" + nanoid "^5.0.7" + raphael@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/raphael/-/raphael-2.3.0.tgz#eabeb09dba861a1d4cee077eaafb8c53f3131f89" @@ -945,6 +2800,108 @@ readable-stream@~2.3.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" +rehype-external-links@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/rehype-external-links/-/rehype-external-links-3.0.0.tgz#2b28b5cda1932f83f045b6f80a3e1b15f168c6f6" + integrity sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw== + dependencies: + "@types/hast" "^3.0.0" + "@ungap/structured-clone" "^1.0.0" + hast-util-is-element "^3.0.0" + is-absolute-url "^4.0.0" + space-separated-tokens "^2.0.0" + unist-util-visit "^5.0.0" + +rehype-format@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/rehype-format/-/rehype-format-5.0.1.tgz#e255e59bed0c062156aaf51c16fad5a521a1f5c8" + integrity sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ== + dependencies: + "@types/hast" "^3.0.0" + hast-util-format "^1.0.0" + +rehype-parse@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/rehype-parse/-/rehype-parse-9.0.1.tgz#9993bda129acc64c417a9d3654a7be38b2a94c20" + integrity sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag== + dependencies: + "@types/hast" "^3.0.0" + hast-util-from-html "^2.0.0" + unified "^11.0.0" + +rehype-raw@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4" + integrity sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww== + dependencies: + "@types/hast" "^3.0.0" + hast-util-raw "^9.0.0" + vfile "^6.0.0" + +rehype-sanitize@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz#16e95f4a67a69cbf0f79e113c8e0df48203db73c" + integrity sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg== + dependencies: + "@types/hast" "^3.0.0" + hast-util-sanitize "^5.0.0" + +rehype-stringify@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/rehype-stringify/-/rehype-stringify-10.0.1.tgz#2ec1ebc56c6aba07905d3b4470bdf0f684f30b75" + integrity sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA== + dependencies: + "@types/hast" "^3.0.0" + hast-util-to-html "^9.0.0" + unified "^11.0.0" + +remark-gfm@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b" + integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-gfm "^3.0.0" + micromark-extension-gfm "^3.0.0" + remark-parse "^11.0.0" + remark-stringify "^11.0.0" + unified "^11.0.0" + +remark-parse@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1" + integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + micromark-util-types "^2.0.0" + unified "^11.0.0" + +remark-rehype@^11.1.2: + version "11.1.2" + resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-11.1.2.tgz#2addaadda80ca9bd9aa0da763e74d16327683b37" + integrity sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + mdast-util-to-hast "^13.0.0" + unified "^11.0.0" + vfile "^6.0.0" + +remark-stringify@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3" + integrity sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-to-markdown "^2.0.0" + unified "^11.0.0" + +reserved-identifiers@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz#d2982cd698e317dd3dced1ee1c52412dbd64fc64" + integrity sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw== + restructure@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/restructure/-/restructure-3.0.2.tgz#e6b2fad214f78edee21797fa8160fef50eb9b49a" @@ -965,6 +2922,11 @@ select@^1.1.2: resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" integrity sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA== +set-cookie-parser@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz#e0b1d94c8660c68e6a24dc4e2b5c9e955ccf7e28" + integrity sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw== + setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -975,11 +2937,26 @@ source-map-js@^1.2.1: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + startbootstrap-sb-admin-2@1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/startbootstrap-sb-admin-2/-/startbootstrap-sb-admin-2-1.0.7.tgz#ef36a90903afb4a84a25c329b0292d06bf05b130" integrity sha512-+CAll0cvvIZu/KBX3epjZrRRaGu7p95y2InZvhxgnKLH3p6JxT6lxJuwbQw9EVZfNckCZEhpJ0Voux9C47mTrg== +string-byte-length@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/string-byte-length/-/string-byte-length-3.0.1.tgz#34221c83e0875f4762231278decb114802949c9a" + integrity sha512-yJ8vP0HMwZ54CcA8S8mKoXbkezpZHANFtmafFo8lGxZThCQcAwRHjdFabuSLgOzxj9OFJcmssmiAvmcOK4O2Hw== + +string-byte-slice@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/string-byte-slice/-/string-byte-slice-3.0.1.tgz#c152e15d5021f78f1a1b74c5414f82576f9455b8" + integrity sha512-GWv2K4lYyd2+AhmKH3BV+OVx62xDX+99rSLfKpaqFiQU7uOMaUY1tDjdrRD4gsrCr9lTyjMgjna7tZcCOw+Smg== + string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" @@ -987,6 +2964,58 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + +stringify-object@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-6.0.0.tgz#1f6a11ada4b8619657c1780816f278a186b5bcac" + integrity sha512-6f94vIED6vmJJfh3lyVsVWxCYSfI5uM+16ntED/Ql37XIyV6kj0mRAAiTeMMc/QLYIaizC3bUprQ8pQnDDrKfA== + dependencies: + get-own-enumerable-keys "^1.0.0" + is-identifier "^1.0.1" + is-obj "^3.0.0" + is-regexp "^3.1.0" + +style-mod@^4.0.0, style-mod@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.3.tgz#6e9012255bb799bdac37e288f7671b5d71bf9f73" + integrity sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ== + +super-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/super-regex/-/super-regex-1.1.0.tgz#14b69b6374f7b3338db52ecd511dae97c27acf75" + integrity sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ== + dependencies: + function-timeout "^1.0.1" + make-asynchronous "^1.0.1" + time-span "^5.1.0" + +swrv@^1.0.4: + version "1.2.0" + resolved "https://registry.yarnpkg.com/swrv/-/swrv-1.2.0.tgz#5b848d08fdba213212c163fb5488a3eb962ae1b5" + integrity sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A== + +tabbable@^6.4.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.5.0.tgz#a65101385a4fd6cbd580b7546da0170f307b535d" + integrity sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA== + +tagged-tag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" + integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== + +tailwind-merge@3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca" + integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A== + tailwindcss@4.3.3, tailwindcss@^4.3: version "4.3.3" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.3.tgz#c006861611c213c1877893ab5b23daa16be2bb55" @@ -997,6 +3026,13 @@ tapable@^2.3.3: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160" integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A== +time-span@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/time-span/-/time-span-5.1.0.tgz#80c76cf5a0ca28e0842d3f10a4e99034ce94b90d" + integrity sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA== + dependencies: + convert-hrtime "^5.0.0" + tiny-emitter@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" @@ -1014,16 +3050,59 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + +trough@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" + integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== + +truncate-json@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/truncate-json/-/truncate-json-3.0.1.tgz#e19fe19b26647a400f895df8fb7295e9f0913c75" + integrity sha512-QVsbr1WhGLq2F0oDyYbqtOXcf3gcnL8C9H5EX8bBwAr8ZWvWGJzukpPrDrWgJMrNtgDbo74BIjI4kJu3q2xQWw== + dependencies: + guess-json-indent "^3.0.1" + string-byte-length "^3.0.1" + string-byte-slice "^3.0.1" + +tslib@^2.0.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +type-fest@^4.6.0: + version "4.41.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" + integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== + +type-fest@^5.3.1: + version "5.8.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.8.0.tgz#6d517998257c33159db4d4da6f18efa33bd47df3" + integrity sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA== + dependencies: + tagged-tag "^1.0.0" + typo-js@*: version "1.3.1" resolved "https://registry.yarnpkg.com/typo-js/-/typo-js-1.3.1.tgz#c80f8c7b292caaa17a507226bf74c1cddf6a29e6" integrity sha512-elJkpCL6Z77Ghw0Lv0lGnhBAjSTOQ5FhiVOCfOuxhaoTT2xtLVbqikYItK5HHchzPbHEUFAcjOH669T2ZzeCbg== +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== + +unhead@2.1.16: + version "2.1.16" + resolved "https://registry.yarnpkg.com/unhead/-/unhead-2.1.16.tgz#31dd226a959c1cc3d15b8c38a1faf8d515f7ea0b" + integrity sha512-cD2Q4a6SQHhW9/bPp17NT4GJ1yS0DSLe75WEHKQ8bKa/wjB6cIki3KeL86hhllNBcpv8i6bxdwtwQunneXPqKQ== + dependencies: + hookable "^6.0.1" + unicode-properties@^1.4.0: version "1.4.1" resolved "https://registry.yarnpkg.com/unicode-properties/-/unicode-properties-1.4.1.tgz#96a9cffb7e619a0dc7368c28da27e05fc8f9be5f" @@ -1040,14 +3119,153 @@ unicode-trie@^2.0.0: pako "^0.2.5" tiny-inflate "^1.0.0" +unified@^11.0.0, unified@^11.0.5: + version "11.0.5" + resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1" + integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA== + dependencies: + "@types/unist" "^3.0.0" + bail "^2.0.0" + devlop "^1.0.0" + extend "^3.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^6.0.0" + +unist-util-find-after@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz#3fccc1b086b56f34c8b798e1ff90b5c54468e896" + integrity sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-is@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.1.tgz#d0a3f86f2dd0db7acd7d8c2478080b5c67f9c6a9" + integrity sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz#777df7fb98652ce16b4b7cd999d0a1a40efa3a02" + integrity sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0, unist-util-visit@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz#9a2a28b0aa76a15e0da70a08a5863a2f060e2468" + integrity sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +vfile-location@^5.0.0: + version "5.0.3" + resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-5.0.3.tgz#cb9eacd20f2b6426d19451e0eafa3d0a846225c3" + integrity sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg== + dependencies: + "@types/unist" "^3.0.0" + vfile "^6.0.0" + +vfile-message@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.3.tgz#87b44dddd7b70f0641c2e3ed0864ba73e2ea8df4" + integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== + dependencies: + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" + +vue-component-type-helpers@^3.2.6: + version "3.3.8" + resolved "https://registry.yarnpkg.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.8.tgz#4b9ee9e71c482439a725a3c4fcc0f9f4b27b71b2" + integrity sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g== + +vue-demi@>=0.13.0, vue-demi@>=0.14.8: + version "0.14.10" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04" + integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg== + +vue-sonner@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/vue-sonner/-/vue-sonner-1.3.2.tgz#3349d548218e074499fc6d1ba394603ba477156a" + integrity sha512-UbZ48E9VIya3ToiRHAZUbodKute/z/M1iT8/3fU8zEbwBRE11AKuHikssv18LMk2gTTr6eMQT4qf6JoLHWuj/A== + +vue@^3.5.30: + version "3.5.40" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.40.tgz#c4a9d4c62f98ea33aa811a75c3fbd476dc782184" + integrity sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig== + dependencies: + "@vue/compiler-dom" "3.5.40" + "@vue/compiler-sfc" "3.5.40" + "@vue/runtime-dom" "3.5.40" + "@vue/server-renderer" "3.5.40" + "@vue/shared" "3.5.40" + +w3c-keyname@^2.2.4: + version "2.2.8" + resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" + integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== + +web-namespaces@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" + integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== + +web-worker@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.5.0.tgz#71b2b0fbcc4293e8f0aa4f6b8a3ffebff733dcc5" + integrity sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw== + xmldoc@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/xmldoc/-/xmldoc-2.0.3.tgz#65b4226b753ea6cd4601f3f56d52338941d38380" integrity sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w== dependencies: sax "^1.4.3" + +yaml@^2.8.3: + version "2.9.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" + integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== + +zod@^4.3.5: + version "4.4.3" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356" + integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ== + +zwitch@^2.0.0, zwitch@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== diff --git a/docs/content/automation/api/api-v3-alpha-docs.md b/docs/content/automation/api/api-v3-alpha-docs.md index 5cd6354019c..bb46f5f60db 100644 --- a/docs/content/automation/api/api-v3-alpha-docs.md +++ b/docs/content/automation/api/api-v3-alpha-docs.md @@ -42,10 +42,10 @@ What v3 gives you over v2: The "try it out" flow works with your logged-in session, or paste a token (see below). -The Scalar page loads its UI from the jsDelivr CDN with a **version-pinned URL and a Subresource -Integrity hash** — the browser refuses to run the bundle if the CDN ever serves different bytes, -and nothing is vendored into DefectDojo. Consequence: on air-gapped deployments the Scalar page -cannot load; Swagger UI at `/docs` serves its assets locally and always works. +The Scalar page serves its UI **from DefectDojo's own static files** — the bundle is installed at +image-build time by the existing frontend dependency step (exact version pin, lockfile-verified +integrity) and nothing is fetched from a CDN at runtime. It therefore works on air-gapped +deployments, exactly like Swagger UI at `/docs`. ## Authentication diff --git a/dojo/api_v3/reference_docs.py b/dojo/api_v3/reference_docs.py index 3fd64c97eac..c7e64b710cd 100644 --- a/dojo/api_v3/reference_docs.py +++ b/dojo/api_v3/reference_docs.py @@ -1,34 +1,42 @@ """ -Scalar API reference page for API v3 (§12: supersedes the OS6 Scalar deferral). - -Serves a minimal HTML shell that loads the Scalar reference UI **from the jsDelivr CDN with a -pinned version and a Subresource Integrity (SRI) hash** — the architect-approved middle path -between "vendor an unreviewed 3.7 MB JS bundle into a security product's repo" (the OS6 concern) -and "trust a mutable CDN URL": - -- The version is pinned (never ``@latest``), and jsDelivr caches versioned artifacts permanently. -- The SRI hash makes the browser REFUSE to execute the bundle if the CDN ever serves different - bytes — supply-chain integrity is enforced client-side, with only a hash committed to the repo. -- The page is progressive enhancement: ninja's built-in Swagger at ``/docs`` ships its assets - locally and remains the always-works default (air-gapped deployments cannot reach the CDN, so - this page intentionally degrades there while ``/docs`` keeps working). - -To upgrade Scalar: fetch the new pinned bundle, recompute ``openssl dgst -sha384 -binary | base64``, -and update the two constants below together. +Scalar API reference page for API v3 (§12: supersedes the OS6 Scalar deferral and the interim +CDN+SRI approach). + +Serves a minimal HTML shell that loads the Scalar reference UI **from our own static files** — +the bundle is installed at image-build time via the existing components yarn step +(``components/package.json`` pins ``@scalar/api-reference`` to an exact version; +``components/yarn.lock`` carries its integrity hashes) and is served through the standard +static pipeline, because ``STATICFILES_DIRS`` already includes ``components/node_modules``. + +Why this shape (architect decision): + +- **No CDN at runtime**: the asset ships inside the image like every other yarn-managed frontend + dependency, so air-gapped deployments get a working reference page and no usage metadata leaks + to a third party. +- **No vendored blob in git**: only the one-line pin lands in the repo; integrity is enforced by + the yarn lockfile at image build instead of a browser SRI attribute at runtime. +- ninja's built-in Swagger at ``/docs`` remains available as a second, framework-bundled UI. + +To upgrade Scalar: bump the exact pin in ``components/package.json`` (``yarn add +@scalar/api-reference@ --exact``) — nothing in this module changes. + +Dev note: without the yarn install (bare-metal dev before ``yarn`` has run in ``components/``), +the page shell renders but the script 404s — the same behaviour as every other yarn-built asset; +``/docs`` always works. """ from __future__ import annotations from typing import TYPE_CHECKING from django.http import HttpResponse +from django.templatetags.static import static from django.urls import reverse if TYPE_CHECKING: from django.http import HttpRequest -# Pin + hash MUST change together (see module docstring). -SCALAR_CDN_URL = "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.63.0/dist/browser/standalone.min.js" -SCALAR_SRI_HASH = "sha384-bnRzGcRYqM9jbXxeIbNDWWD8mNMY0p8qvmfAyfcT5S7/I6E7bsyLprA0uIP2gUu7" +# Path inside components/node_modules (a STATICFILES_DIRS entry); version pinned in package.json. +SCALAR_STATIC_PATH = "@scalar/api-reference/dist/browser/standalone.js" _PAGE = """ @@ -40,9 +48,9 @@ + Swagger UI is at {docs_url}. - + """ @@ -52,6 +60,5 @@ def scalar_reference(request: HttpRequest) -> HttpResponse: return HttpResponse(_PAGE.format( openapi_url=reverse("api_v3:openapi-json"), docs_url=reverse("api_v3:openapi-view"), - cdn_url=SCALAR_CDN_URL, - sri_hash=SCALAR_SRI_HASH, + script_url=static(SCALAR_STATIC_PATH), )) diff --git a/unittests/api_v3/test_apiv3_reference_docs.py b/unittests/api_v3/test_apiv3_reference_docs.py index a8adcfee311..018ece82488 100644 --- a/unittests/api_v3/test_apiv3_reference_docs.py +++ b/unittests/api_v3/test_apiv3_reference_docs.py @@ -1,16 +1,24 @@ """ -Scalar reference page (§12: supersedes the OS6 Scalar deferral — CDN + SRI, no vendored asset). - -The page is an HTML shell: the only executable content is the version-pinned CDN bundle whose -SRI hash the browser enforces. These tests pin that contract: exact pinned URL, integrity attr, -crossorigin, and the schema/docs URLs resolved by name (so the beta URL move carries them along). +Scalar reference page (§12: npm-at-image-build — the bundle is a yarn-managed static asset, +no CDN at runtime, no vendored blob in git). + +The page is an HTML shell: the only executable content is the locally-served Scalar bundle, +installed by the existing components yarn step (exact pin in ``components/package.json``, +integrity via ``components/yarn.lock``) and exposed through ``STATICFILES_DIRS`` → +``components/node_modules``. These tests pin that contract: the static script URL, no CDN/SRI +remnants, the exact-pin in package.json, and the schema/docs URLs resolved by name (so the beta +URL move carries them along). """ from __future__ import annotations +import json +from pathlib import Path + +from django.templatetags.static import static from django.urls import reverse from dojo.api_v3.api import api_v3 -from dojo.api_v3.reference_docs import SCALAR_CDN_URL, SCALAR_SRI_HASH +from dojo.api_v3.reference_docs import SCALAR_STATIC_PATH from .base import ApiV3TestCase @@ -20,21 +28,27 @@ class TestApiV3ScalarReference(ApiV3TestCase): def _get(self): return self.anonymous_client().get(reverse("api_v3_reference")) - def test_page_serves_pinned_bundle_with_sri(self): + def test_page_serves_locally_hosted_bundle(self): response = self._get() self.assertEqual(200, response.status_code) html = response.content.decode() - self.assertIn(SCALAR_CDN_URL, html) - self.assertIn(f'integrity="{SCALAR_SRI_HASH}"', html) - self.assertIn('crossorigin="anonymous"', html) - # Version must be pinned, never floating. - self.assertIn("@scalar/api-reference@", SCALAR_CDN_URL) - self.assertNotIn("@latest", SCALAR_CDN_URL) + self.assertIn(f'', html) + # The npm-at-build decision: no runtime CDN, so no CDN host and no SRI attribute. + self.assertNotIn("cdn.jsdelivr.net", html) + self.assertNotIn("integrity=", html) + + def test_bundle_is_exact_pinned_in_components_package_json(self): + # Integrity is enforced by yarn (exact pin + lockfile hashes) at image build, not by a + # browser SRI attribute at runtime — so the pin itself is the contract to guard. + package_json = Path(__file__).parents[2] / "components" / "package.json" + deps = json.loads(package_json.read_text())["dependencies"] + version = deps["@scalar/api-reference"] + self.assertRegex(version, r"^\d+\.\d+\.\d+$", "Scalar must be pinned exactly (no ^/~/latest)") def test_page_points_at_v3_schema_and_swagger_fallback(self): html = self._get().content.decode() self.assertIn(f'data-url="{reverse("api_v3:openapi-json")}"', html) - # Swagger (locally-served assets) remains the offline-safe default, linked for noscript. + # Swagger (framework-bundled assets) remains linked for noscript. self.assertIn(reverse("api_v3:openapi-view"), html) def test_reference_page_is_not_an_api_operation(self): From 055f0674e75b12af2c6a79856665f8885518e111 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 21 Jul 2026 18:43:58 +0200 Subject: [PATCH 29/37] feat(api-v3): profile menu links both v3 docs UIs; rename v2 docs label - 'API v2 OpenAPI3 Docs' -> 'API v2 Docs' - 'Open API v3 alpha Docs (Swagger)' -> /api/v3-alpha/docs - 'Open API v3 alpha Docs (Scalar)' -> /api/v3-alpha/reference Both v3 entries stay inside the V3_FEATURE_LOCATIONS guard (the mount is conditional; unguarded url tags would break every page flag-off). Verified: both URL names resolve; base.html compiles. --- dojo/templates/base.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dojo/templates/base.html b/dojo/templates/base.html index 95c7bfd9adc..d4e605da8e5 100644 --- a/dojo/templates/base.html +++ b/dojo/templates/base.html @@ -468,12 +468,15 @@ {% endif %} {% endif %} - {% trans "API v2 OpenAPI3 Docs" %} + {% trans "API v2 Docs" %} {% if V3_FEATURE_LOCATIONS %} {# v3 mounts conditionally on this flag (D5) — an unguarded url tag would break every page when it is off #} - {% trans "API v3 Docs (alpha)" %} + {% trans "Open API v3 alpha Docs (Swagger)" %} + + + {% trans "Open API v3 alpha Docs (Scalar)" %} {% endif %} From 7dcae94a9771de021ff9836ea9eee7f9016aff35 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 21 Jul 2026 18:56:58 +0200 Subject: [PATCH 30/37] feat(api-v3): docs menu entries in the classic UI template too The UIPreferenceLoader serves base.html from dojo/templates/ OR dojo/templates_classic/ per user preference - the previous menu commit only covered the modern template, so classic-UI users saw no v3 links (and the old v2 label). Classic now matches: 'API v2 Docs' rename plus the two flag-guarded v3 entries (Swagger + Scalar). Verified: authenticated classic render shows all three, old label gone. --- dojo/templates_classic/base.html | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/dojo/templates_classic/base.html b/dojo/templates_classic/base.html index 995ecb4bfa8..fe17e3db2c9 100644 --- a/dojo/templates_classic/base.html +++ b/dojo/templates_classic/base.html @@ -179,10 +179,25 @@ {% endif %}
  • - - {% trans "API v2 OpenAPI3 Docs" %} + + {% trans "API v2 Docs" %}
  • + {% if V3_FEATURE_LOCATIONS %} + {# v3 mounts conditionally on this flag (D5) — an unguarded url tag would break every page when it is off #} +
  • + + + {% trans "Open API v3 alpha Docs (Swagger)" %} + +
  • +
  • + + + {% trans "Open API v3 alpha Docs (Scalar)" %} + +
  • + {% endif %}
  • From 51e2c6cc0235513c767a63d21b24ef596149ebbf Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 21 Jul 2026 20:10:59 +0200 Subject: [PATCH 31/37] fix(api-v3): non-superuser cannot change another user's email/username MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3 security-parity review of three open v2 hardening PRs (#15300, #15296, #15191): #15300 IMMUNE (v3 locations read-only, no reference- write surface); #15296 IMMUNE (configuration_permissions not on the v3 user write surface); #15191 identity half was a REAL GAP now fixed. Gap: get_authorized_users returns co-members, so a non-superuser with view_user+change_user could PATCH/PUT a visible co-member's email or username -> account takeover via password reset. Adds _enforce_identity_field_rules (mirrors UserSerializer.validate()): superuser unrestricted, self-edit allowed, email/username change to another account -> 400; create is a no-op. Wired into PATCH and PUT after the superuser/staff gate; deny-by-default sweep unchanged. +6 tests. (API_V3_PLAN.md also carries the examples-refresh §12 row committed next.) --- API_V3_PLAN.md | 2 + dojo/user/api_v3/routes.py | 24 ++++++++ unittests/api_v3/test_apiv3_users.py | 89 +++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/API_V3_PLAN.md b/API_V3_PLAN.md index 0fc9c6e3811..a440f2be383 100644 --- a/API_V3_PLAN.md +++ b/API_V3_PLAN.md @@ -571,3 +571,5 @@ itself (conservative, §10.2). *Subsequently closed:* the matching-policy read f | 2026-07-21 | **TESTDETAIL MATCHING-POLICY READ FIELDS PORTED (§9.1 PORT-LATER→PORTED; ports `test_apiv2_test_dedupe_policy.py`).** `TestDetail` gains two READ-ONLY **computed** fields exposing the effective finding-matching policy: **`deduplication_algorithm: str`** (always non-null; `legacy` fallback) and **`hash_code_fields: list[str] \| None`** (`None` when the scan type has no per-scanner config). **Helper reused verbatim (never duplicated):** both resolvers return `obj.deduplication_algorithm` / `obj.hash_code_fields` — the v2 `Test` model **properties** (`dojo/test/models.py:156-193`), the exact settings-driven per-scanner lookup the v2 `TestSerializer` reads (`DEDUPLICATION_ALGORITHM_PER_PARSER` / `HASHCODE_FIELDS_PER_SCANNER`, keyed `test_type.name` then `scan_type`, `DEDUPE_ALGO_LEGACY` default). **Slim unchanged** (§4.5): these are config lookups, not identity fields, so `TestSlim` stays as-is. **Reject-vs-ignore deviation (the deliberate hardening):** the fields are **not** on `TestWrite`/`TestUpdate`/`TestReplace` (all `extra="forbid"`), so a PATCH or PUT supplying either → **400 unknown-field** problem+json; v2's serializer declares them `read_only` and therefore silently **ignores** writes to them (200, no change). v3 rejects because silent-ignore is the exact failure mode v3 rejects everywhere (same principle as unknown filter/expand/`fields=` params — D6/I9); the reject is atomic (a mixed PATCH bundling the policy fields with a real `description` change alters nothing). **Query impact = zero on detail:** the resolvers read only `test_type` (already `select_related` in `TestSlim.SELECT_RELATED`) and `scan_type` (a loaded concrete column — detail routes never `defer()`), verified with `assertNumQueries(0)`. **`?fields=` opt-up + defer handling (kernel accommodation, §4.7):** on a LIST these detail-only fields opt up via `?fields=`; being **resolver-backed (not `model._meta.concrete_fields`)** they never enter the defer set (asserted). But their resolvers read the concrete `scan_type` column, which is **deferred by default** on tests lists (§12 2026-07-20) — a naive opt-up would lazy-load it per row (N+1). Smallest correct accommodation: a new optional per-schema `DETAIL_FIELD_COLUMNS: dict[computed_field, tuple[concrete_column, ...]]` honored by the resource-agnostic kernel `plan_list_fields` (`dojo/api_v3/expand.py`) — requesting a computed detail field **un-defers exactly** the columns its resolver reads (analogous to `DETAIL_SELECT_RELATED`, but for own columns rather than relation joins). `TestDetail.DETAIL_FIELD_COLUMNS = {deduplication_algorithm: (scan_type,), hash_code_fields: (scan_type,)}`. Kernel change is additive (a schema without `DETAIL_FIELD_COLUMNS` behaves identically) and covers the CSV route too (it reuses `plan_list_fields`). `GET /tests?fields=id,name,deduplication_algorithm` verified **constant-query** (row-independent). **CSV:** flows automatically — `deduplication_algorithm` → one column, `hash_code_fields` (list) → one semicolon-joined column (empty when null); asserted. **Docs:** the v3 docs page does not enumerate per-object detail fields — no docs edit needed. **Tests:** new `unittests/api_v3/test_apiv3_test_dedupe_policy.py` (16): two scan types with distinct policies (ZAP Scan→`hash_code`+`[title,cwe,severity]`; Checkmarx Scan detailed→`unique_id_from_tool`+`None`), default-fallback (`legacy`+`None`), detail zero-extra-query, PATCH×2/PUT×2 reject + atomic reject-vs-ignore, list not-on-default + opt-up value-parity + defer-set + constant-query, CSV flatten. Full `unittests.api_v3` = **492 executed + 6 skipped, 0 failures** (`/tmp/apiv3_dedupe_full.log`; +16 over the 476 baseline). No new endpoints → the authz/query-report/OpenAPI completeness gates are unaffected and stay green. Ruff clean. No v2 code/UI/v2-test modified. | | 2026-07-21 | **`cwes` LIST ACCEPTED ON FINDING WRITES (architect directive; §6 backlog bullet closed; symmetric with the read side + parallel to `vulnerability_ids`).** **Wire:** `cwes: list[int] \| None = None` added to `FindingWrite`, `FindingUpdate`, `FindingReplace` (plain CWE numbers, e.g. `79`; matches `FindingSlim.cwes`). Read side unchanged. **Precedence (mirrors v2 `FindingSerializer.update` ~:450-454 and the `vulnerability_ids[0]→cve` mirror):** an explicit **non-None** scalar `cwe` wins as the primary; when only `cwes` is supplied its first entry is mirrored into `cwe` **before** the initial/`save()` so the primary persists (like `cve`). Persisted `Finding_CWE` rows = **primary first, then the rest**, via the existing `save_cwes` (`finding_cwe_labels(finding.cwe, finding.unsaved_cwes)` dedups with the primary first). When BOTH a scalar `cwe` and a non-None `cwes` are present in one request, `save_cwes` runs **once** through the cwes branch (not the scalar-only resync `elif`) — list drives the rows, scalar stays primary; no double-persist. **`unsaved_cwes` shape discovered:** `save_cwes`→`finding_cwe_labels`→`cwe_label` accepts **plain ints OR `CWE-`/`"79"` strings** and canonicalizes+dedups, so the wire `list[int]` is placed onto `finding.unsaved_cwes` **verbatim** (no pre-formatting) — the full list, letting `save_cwes` order primary-first. **Omission (no-reset-on-omit, extends the vulnerability_ids/reporter record to cwes):** the service runs the list-driven mutation **only when `cwes` is non-None**; omitted (`None`) on PATCH **and** PUT ⇒ no list mutation, rows untouched **except the pre-existing scalar-`cwe`-change resync** (§12 2026-07-19 OS3b) — on PATCH that resync fires only if the scalar `cwe` changed; on PUT `cwe` is always in the full-replace set (as its reset-to-`None` default) so the scalar resync always fires and the rows follow the (possibly reset) scalar. **Because PUT always carries `cwe`, a key-presence test would wrongly suppress the mirror**, so the mirror keys off an **explicit non-None** scalar (`cwe_explicit = "cwe" in changes and changes["cwe"] is not None`; create uses the equivalent `"cwe" not in scalars`, since `None` is dropped from `scalars`). **Empty-list decision (conservative, recorded):** an explicit `cwes: []` is a statement (unlike omission) → it **clears the extra rows and resyncs to just the scalar-derived primary** (`unsaved_cwes=[]`, `save_cwes` ⇒ rows = `{primary}` when a scalar exists, else empty); on PUT `[]` and omission converge (both leave just the scalar primary, because the PUT scalar resync always runs). **Read-back symmetry** (rows persisted primary-first, `resolve_cwes` reads storage order): write `cwes=[89,79]` (no scalar) → read `[89,79]`; write scalar `79` + `cwes=[89,100]` → read `[79,89,100]`. **Files:** `dojo/finding/api_v3/schemas.py` (field + docstrings on the three write schemas), `dojo/finding/api_v3/routes.py` (pop `cwes` at create/PATCH/PUT, pass as service kwarg), `dojo/finding/services.py` (`cwes` kwarg on `create_finding`/`update_finding`, `"cwes"` added to `_SPECIAL_KEYS`, scalar mirror before save + rows resync). **Docs page unchanged** (it documents write *conventions*, not per-object write field lists — verified). **Tests (+9)** in `unittests/api_v3/test_apiv3_finding_writes.py`: create cwes-only (mirror+rows+read-back), create scalar+list (scalar wins), PATCH cwes replaces rows, PATCH scalar-only still resyncs (existing behavior), PATCH `[]` clears extras keeps primary, PATCH omit-both leaves rows untouched, PATCH scalar+list (scalar wins), PUT with cwes, PUT omitting cwes follows the scalar resync. Module 30→39. Full `unittests.api_v3` = **504 executed + 6 skipped, 0 failures** (`/tmp/apiv3_cwes_full.log`); the +12 over the 492 dedupe-policy baseline is this change's +9 plus concurrent-workstream tests (TEST dedupe-policy / Scalar docs) present in the tree — all green. Ruff clean. No v2 code/UI/v2-test modified. | | 2026-07-21 | **SCALAR MOVED FROM CDN+SRI TO NPM-AT-IMAGE-BUILD (architect directive — supersedes the CDN+SRI row above).** `@scalar/api-reference` is now an exact-pinned dependency in `components/package.json` (1.63.0, integrity via `components/yarn.lock`), installed by the EXISTING yarn step in `Dockerfile.nginx-alpine` — zero Dockerfile changes because `STATICFILES_DIRS` already includes `components/node_modules` (`settings.dist.py:467`), so the bundle is served as a normal static file (`dojo/api_v3/reference_docs.py` → `static("@scalar/api-reference/dist/browser/standalone.js")`). Rationale: identical trust model to every other yarn-managed frontend dependency; asset fetched once at image build (lockfile hashes) instead of from every user's browser at runtime; air-gapped deployments get a working reference page; no usage metadata leaks to jsDelivr. SRI attribute dropped (same-origin, image-baked). Engine note: Scalar 1.63.0 declares node>=22; the image base ships node 22 (verified v22.23.0 on python:3.14.5-alpine3.22), so the image build passes engines — local lockfile regeneration on older node needs `--ignore-engines`. Tests updated: static-URL assertion, no-CDN/no-SRI assertions, exact-pin guard reading `components/package.json`. Docs page air-gap wording flipped to works-everywhere. | +| 2026-07-21 | **SECURITY-PARITY ASSESSMENT OF THREE OPEN v2 HARDENING PRs (#15300, #15296, #15191); one v3 GAP fixed.** Each PR's v2 weakness was checked against the v3 surface. **#15300 "Scope location/endpoint reference writes to authorized products" → IMMUNE:** v3 has no write path that selects a location/endpoint reference. Location routes are GET-only (`dojo/location/api_v3/routes.py` — only `@router.get`; the resource is superuser-gated and lifecycle is import-driven, D5/OS4); the finding write schemas expose no `endpoints`/`locations` field and are `extra="forbid"` (`dojo/finding/api_v3/schemas.py` `FindingWrite`/`FindingUpdate`/`FindingReplace`, ll. 206-341) so an `endpoints` key is a 400; and there is no v3 import surface (the v2 `endpoint_to_add` field has no v3 equivalent — `dojo/api_v3/import_routes.py` `ImportForm` carries no such field, and the import-corpus port skips `endpoint_to_add` as "out of v3 scope, §4.13"). The PR's third change is a UI view (`dojo/url/ui/views.py`), not API. **#15296 "Restrict configuration_permission assignment to superusers" → IMMUNE:** `configuration_permissions` is intentionally NOT on the v3 user write surface (`dojo/user/api_v3/schemas.py:13` + `UserWrite`/`UserUpdate` field lists — the field cannot be assigned via v3 at all; already recorded as PORT-LATER in the 2026-07-19 OS3a row). **#15191 "Extend user serializer validation to identity + permission fields" → part IMMUNE, part GAP (fixed).** Its `configuration_permissions` rule is IMMUNE for the same reason as #15296. Its NEW identity-field rule (a non-superuser must not change **another** account's `email`/`username` — account-takeover via the password-reset flow) was a genuine **GAP**: v3's `UserUpdate`/`UserWrite` expose `email`+`username`, the update/replace routes require only `auth.change_user`, and the ported `_enforce_superuser_staff_rules` guarded only `is_superuser`/`is_staff` — so a non-superuser `view_user`+`change_user` delegate could rename/re-email a visible co-member. **Fix:** added `_enforce_identity_field_rules` (`dojo/user/api_v3/routes.py`) mirroring the PR's semantics (superuser unrestricted; self-edit allowed; on another user any `email`/`username` change → 400 problem+json; create is a no-op — no existing identity to hijack) and wired it into `update_user` (PATCH) and `replace_user` (PUT) right after the superuser/staff gate. **Tests:** `TestApiV3UsersIdentityFieldAuthz` in `unittests/api_v3/test_apiv3_users.py` (6 cases: delegate blocked on another's email/username via PATCH and PUT, PUT with unchanged identity allowed, delegate may edit own email, superuser may edit another's email). Deny-by-default authz sweep + static tripwire unchanged (the new 400 fires only *after* the 404-visibility and 403-`change_user` gates, so no zero-perm outcome changed). Full `unittests.api_v3` suite green: **Ran 511 tests, OK (skipped=6)**. | +| 2026-07-21 | **`api_v3_examples.md` REFRESHED — new captured sections for cursor pagination, CSV export, PUT full-replace, and `cwes`-on-writes (examples only; no contract change).** The CI-excluded harness (`unittests/api_v3/test_apiv3_examples.py`, `DD_API_V3_EXAMPLES=1`) gained six real captured sections, and the flagship finding (id 2) is now seeded with identity data (scalar `cwe=79`, `cwes=[79,89]`, `vulnerability_ids=[CVE-2024-3094, GHSA-…]`, `tags=[internal, tls]`) so the detail/expand/cursor/CSV bodies show these populated rather than empty. New sections: **(1) cursor pagination** — `?pagination=cursor` page 1 + following `next` to page 2 (`count`/`previous` null, opaque signed `cursor=` in `next`, truncated for readability); **(2) CSV export** — `GET /findings/export.csv?severity=High&o=id&fields=…` showing the `Content-Type`/`Content-Disposition`/`X-API-Status` headers + the first 4 CSV lines (ref flattening `asset_id`/`asset_name`; semicolon-joined `cwes`/`vulnerability_ids`/`tags`); **(3) PUT full replace** — omitted `mitigation`/`impact` reset to `null`; **(4) `cwes` PATCH + GET read-back** — scalar `cwe` mirrors the primary (`79`), `cwes=[79,89,352]` in storage order. The existing **import** section was refreshed to the current form (wire names `asset_name`/`organization_name`, `close_old_findings_product_scope` + `push_to_jira`, explicit "no `background`/job field — imports are synchronous"). The conventions header gained pointer bullets for cursor mode, `PATCH`+`PUT`, and CSV export. File stays auto-generated / verbatim-captured / regenerable (banner unchanged). Harness green (`Ran 1 test … OK`, `/tmp/apiv3_examples_harness.log`); full `unittests.api_v3` = **511 executed + 6 skipped, 0 failures** (`/tmp/apiv3_examples_full.log`). Ruff clean. Only the harness + `api_v3_examples.md` changed; the docs page already documented cursor/CSV/PUT and the current import form, so no docs edit was needed. No v2 code/UI/v2-test modified. | diff --git a/dojo/user/api_v3/routes.py b/dojo/user/api_v3/routes.py index a04b9bf8783..b6d302e6f66 100644 --- a/dojo/user/api_v3/routes.py +++ b/dojo/user/api_v3/routes.py @@ -132,6 +132,28 @@ def _enforce_superuser_staff_rules(request: HttpRequest, *, current: Dojo_User | raise validation_problem({"is_staff": ["Only superusers are allowed to add or edit staff users."]}) +def _enforce_identity_field_rules(request: HttpRequest, *, current: Dojo_User | None, data: dict) -> None: + """ + Port ``UserSerializer.validate()`` identity-field gating (§12 OS3a; PR #15191 parity). + + A non-superuser may not change **another** account's identity fields (``email``/``username``). + Changing another user's email would enable account takeover via the email-based password-reset + flow, so the identity fields join ``is_superuser``/``is_staff`` under superuser-only control. + Editing one's own identity stays allowed, and superusers are unrestricted. On create there is no + existing account whose identity could be hijacked (``current is None``), so the rule is a no-op + there and applies to updates (PATCH/PUT) only. + """ + if bool(getattr(request.user, "is_superuser", False)): + return + if current is None or request.user.pk == current.pk: + return + for field in ("email", "username"): + if field in data and data[field] != getattr(current, field): + raise validation_problem( + {field: [f"Only superusers are allowed to change another user's {field}."]}, + ) + + def _validate_password_or_400(password: str) -> None: try: validate_password(password) @@ -228,6 +250,7 @@ def update_user(request: HttpRequest, user_id: int, payload: UserUpdate): if "password" in data: raise validation_problem({"password": ["Update of password though API is not allowed"]}) _enforce_superuser_staff_rules(request, current=instance, data=data) + _enforce_identity_field_rules(request, current=instance, data=data) for key, value in data.items(): setattr(instance, key, value) instance.save() @@ -254,6 +277,7 @@ def replace_user(request: HttpRequest, user_id: int, payload: UserWrite): if password is not None: raise validation_problem({"password": ["Update of password though API is not allowed"]}) _enforce_superuser_staff_rules(request, current=instance, data=data) + _enforce_identity_field_rules(request, current=instance, data=data) for key, value in data.items(): setattr(instance, key, value) instance.save() diff --git a/unittests/api_v3/test_apiv3_users.py b/unittests/api_v3/test_apiv3_users.py index c7b59a95e26..00231e20fec 100644 --- a/unittests/api_v3/test_apiv3_users.py +++ b/unittests/api_v3/test_apiv3_users.py @@ -5,7 +5,7 @@ from django.db import connection from django.test.utils import CaptureQueriesContext -from dojo.models import Dojo_User, User +from dojo.models import Dojo_User, Product, Product_Type, User from .base import ApiV3TestCase @@ -272,3 +272,90 @@ def test_non_superuser_cannot_delete_other(self): response = client.delete(self.v3_url(f"users/{other.id}")) # Self-only queryset -> `other` is invisible -> 404 (never reaches the delete perm check). self.assertEqual(404, response.status_code, response.content[:300]) + + +class TestApiV3UsersIdentityFieldAuthz(ApiV3TestCase): + + """ + Parity with PR #15191: a non-superuser delegate holding the user-management configuration + permissions (``view_user`` + ``change_user``) may reach the write path for another visible + account, but must NOT be able to change that account's identity fields (``email``/``username``) + -- changing another user's email enables account takeover via the password-reset flow. The + delegate can still edit their OWN identity, and superusers remain unrestricted. The + ``configuration_permissions`` field itself is intentionally out of the v3 write surface (§12 + OS3a), so only the identity-field half of PR #15191 has a v3 attack surface. + """ + + def setUp(self): + super().setUp() + # Non-superuser delegate: holds view_user (so co-member accounts are visible via the RBAC + # queryset) + change_user (so the update route is reachable), nothing more. + self.delegate = User.objects.create_user(username="v3_identity_delegate", password=_PASSWORD) + self.delegate.user_permissions.add( + Permission.objects.get(codename="view_user", content_type__app_label="auth", content_type__model="user"), + Permission.objects.get(codename="change_user", content_type__app_label="auth", content_type__model="user"), + ) + self.target = User.objects.create_user( + username="v3_identity_target", email="target@example.com", password=_PASSWORD, + ) + # The view_user-scoped queryset returns co-members of the caller's authorized products + # (plus superusers) -- make delegate and target co-members of one product so the delegate + # can both see itself and resolve `target` (otherwise both are 404 before the write gate). + product_type = Product_Type.objects.create(name="v3_identity_pt") + product = Product.objects.create(name="v3_identity_prod", description="d", prod_type=product_type) + product.authorized_users.add(self.delegate.pk, self.target.pk) + self.delegate_client = self.token_client(user=self.delegate) + + def test_delegate_cannot_change_another_users_email(self): + response = self.delegate_client.patch( + self.v3_url(f"users/{self.target.id}"), {"email": "attacker@evil.example"}, format="json", + ) + self.assertEqual(400, response.status_code, response.content[:500]) + self.assertEqual("application/problem+json", response["Content-Type"]) + self.target.refresh_from_db() + self.assertEqual("target@example.com", self.target.email) + + def test_delegate_cannot_change_another_users_username(self): + response = self.delegate_client.patch( + self.v3_url(f"users/{self.target.id}"), {"username": "hijacked"}, format="json", + ) + self.assertEqual(400, response.status_code, response.content[:500]) + self.target.refresh_from_db() + self.assertEqual("v3_identity_target", self.target.username) + + def test_delegate_cannot_change_another_users_email_via_put(self): + response = self.delegate_client.put( + self.v3_url(f"users/{self.target.id}"), + {"username": "v3_identity_target", "email": "attacker@evil.example"}, format="json", + ) + self.assertEqual(400, response.status_code, response.content[:500]) + self.target.refresh_from_db() + self.assertEqual("target@example.com", self.target.email) + + def test_delegate_put_unchanged_identity_is_allowed(self): + # A PUT that re-sends the target's current identity is not a change -> not blocked by the + # identity guard (it fails/passes on other grounds, but never 400s on identity). + response = self.delegate_client.put( + self.v3_url(f"users/{self.target.id}"), + {"username": "v3_identity_target", "email": "target@example.com"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.target.refresh_from_db() + self.assertEqual("target@example.com", self.target.email) + + def test_delegate_can_change_own_email(self): + response = self.delegate_client.patch( + self.v3_url(f"users/{self.delegate.id}"), {"email": "mynew@example.com"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.delegate.refresh_from_db() + self.assertEqual("mynew@example.com", self.delegate.email) + + def test_superuser_can_change_another_users_email(self): + # Positive control: the admin (superuser, default client) is unrestricted. + response = self.client.patch( + self.v3_url(f"users/{self.target.id}"), {"email": "changed@example.com"}, format="json", + ) + self.assertEqual(200, response.status_code, response.content[:500]) + self.target.refresh_from_db() + self.assertEqual("changed@example.com", self.target.email) From 81921433733f76b887a281b21b6227f43c08a996 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 21 Jul 2026 20:11:00 +0200 Subject: [PATCH 32/37] docs(api-v3): refresh captured examples for cursor/CSV/PUT/cwes Regenerates api_v3_examples.md from real in-process requests: cursor pagination (page-1 -> next -> page-2), CSV export (headers + flattened ref columns + semicolon-joined cwes/vulnerability_ids/tags), PUT full- replace, cwes-on-write PATCH + read-back; import section corrected to the current form. Flagship finding seeded so bodies show populated tags/cwe/cwes/vulnerability_ids. Harness stays CI-excluded. --- api_v3_examples.md | 592 ++++++++++++++++++++++-- unittests/api_v3/test_apiv3_examples.py | 211 ++++++++- 2 files changed, 758 insertions(+), 45 deletions(-) diff --git a/api_v3_examples.md b/api_v3_examples.md index f197e995345..73a673f7c71 100644 --- a/api_v3_examples.md +++ b/api_v3_examples.md @@ -2,14 +2,16 @@ > **Auto-generated, do not hand-edit.** Every request/response below was captured by `unittests/api_v3/test_apiv3_examples.py` (`DD_API_V3_EXAMPLES=1`, CI-excluded) making **real** in-process requests against the test fixture. Tokens are redacted; long lists are truncated to ~3 rows. Regenerate with the command in that file's docstring. -Captured: 2026-07-21T08:53:02.375408+00:00 +Captured: 2026-07-21T17:33:30.749529+00:00 ## Conventions (see `API_V3_PLAN.md` §4) - **Mount:** alpha lives at `/api/v3-alpha/` (moves to `/api/v3/` at beta — one migration, D1). Every response carries `X-API-Status: alpha`. - **Auth (D8):** send an existing v2 token as `Authorization: Token ` (works unchanged on v3), or a Django session cookie + `X-CSRFToken` on unsafe methods. -- **Envelope (§4.3):** every list is `{count, next, previous, results, meta?}` and nothing else (I1). `next`/`previous` are opaque URLs; default `limit=25`, max `250`. +- **Envelope & pagination (§4.3):** every list is `{count, next, previous, results, meta?}` and nothing else (I1). `next`/`previous` are opaque URLs. Offset mode is the default (`limit=25`, max `250`); `?pagination=cursor` opts into forward-only keyset paging (`count`/`previous` null, opaque signed cursor in `next`). - **Refs (§4.4):** relations render as `{id, name}` (locations add `type`). Write payloads reference relations by integer id — the asymmetry is intentional (§4.11). +- **Writes (§4.11):** `PATCH` is a partial update (only supplied fields change); `PUT` is a full replace (omitted optionals reset to their defaults). Both reject unknown fields (400). +- **CSV export (§4.15):** `GET //export.csv` streams the whole filtered, authorized set as CSV using the identical filter/`o=`/`q=`/`fields=` contract (no pagination); refs flatten to `_id`/`_name` columns, list fields join with `;`. - **`?expand=` (§4.6):** dotted paths swap refs for slim objects inline and drive the queryset (the real N+1 fix). Budget-guarded. - **`?fields=` (§4.7):** comma-separated allowlist; `id` is always included. On a list it may also request any detail field (a wider SELECT on one query, never per-row). - **`?include=counts` (§4.8):** adds aggregate totals to `meta` over the filtered, authorized queryset. @@ -42,9 +44,15 @@ Authorization: Token "out_of_scope": false, "is_mitigated": false, "date": "2020-05-21", - "cwe": null, - "cwes": [], - "vulnerability_ids": [], + "cwe": 79, + "cwes": [ + 79, + 89 + ], + "vulnerability_ids": [ + "CVE-2024-3094", + "GHSA-8r3f-844x-mdgq" + ], "test": { "id": 3, "name": "ZAP Scan" @@ -66,9 +74,12 @@ Authorization: Token "name": "admin" }, "locations_count": 3, - "tags": [], + "tags": [ + "internal", + "tls" + ], "created": "2017-12-01T00:00:00Z", - "updated": null, + "updated": "2026-07-21T17:33:29.998Z", "description": "test finding", "mitigation": "test mitigation", "impact": "Unauthorized disclosure of customer data if exploited.", @@ -111,9 +122,15 @@ Authorization: Token "out_of_scope": false, "is_mitigated": false, "date": "2020-05-21", - "cwe": null, - "cwes": [], - "vulnerability_ids": [], + "cwe": 79, + "cwes": [ + 79, + 89 + ], + "vulnerability_ids": [ + "CVE-2024-3094", + "GHSA-8r3f-844x-mdgq" + ], "test": { "id": 3, "name": null, @@ -181,9 +198,12 @@ Authorization: Token "id": 1, "name": "admin" }, - "tags": [], + "tags": [ + "internal", + "tls" + ], "created": "2017-12-01T00:00:00Z", - "updated": null, + "updated": "2026-07-21T17:33:29.998Z", "description": "test finding", "mitigation": "test mitigation", "impact": "Unauthorized disclosure of customer data if exploited.", @@ -288,8 +308,8 @@ Authorization: Token }, "locations_count": 0, "tags": [], - "created": "2026-07-21T08:53:01.740Z", - "updated": "2026-07-21T08:53:01.740Z" + "created": "2026-07-21T17:33:29.979Z", + "updated": "2026-07-21T17:33:29.979Z" }, { "id": 235, @@ -328,8 +348,229 @@ Authorization: Token }, "locations_count": 0, "tags": [], - "created": "2026-07-21T08:53:01.740Z", - "updated": "2026-07-21T08:53:01.740Z" + "created": "2026-07-21T17:33:29.979Z", + "updated": "2026-07-21T17:33:29.979Z" + } + ] +} +``` + + +--- + +### Finding — GET list, cursor pagination (`?pagination=cursor`) — page 1 + +Forward-only keyset mode for export/sync consumers (D4/§4.3). Same envelope, but `count` and `previous` are always `null` and `next` carries an opaque, signed `cursor=` token (truncated below). No `COUNT` query runs; the page is read as `limit+1` rows to detect a next page. Keyset-safe orderings only (`id` default, plus `created`/`updated` where a resource declares them); filters/`fields=`/`expand=`/`include=` compose unchanged. + +**Request** + +```http +GET /api/v3-alpha/findings?pagination=cursor&severity=High&active=true&limit=2 +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "count": null, + "next": "http://testserver/api/v3-alpha/findings?pagination=cursor&severity=High&active=true&limit=2&cursor=eyJvIjoiaWQiLCJpZCI6...", + "previous": null, + "results": [ + { + "id": 2, + "title": "High Impact Test Finding", + "severity": "High", + "active": true, + "verified": true, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2020-05-21", + "cwe": 79, + "cwes": [ + 79, + 89 + ], + "vulnerability_ids": [ + "CVE-2024-3094", + "GHSA-8r3f-844x-mdgq" + ], + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "asset": { + "id": 2, + "name": "Security How-to" + }, + "organization": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 3, + "tags": [ + "internal", + "tls" + ], + "created": "2017-12-01T00:00:00Z", + "updated": "2026-07-21T17:33:29.998Z" + }, + { + "id": 232, + "title": "Disabling CSRF Protections Is Security-Sensitive", + "severity": "High", + "active": true, + "verified": true, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2025-10-22", + "cwe": 352, + "cwes": [], + "vulnerability_ids": [], + "test": { + "id": 90, + "name": "SonarQube Scan detailed" + }, + "engagement": { + "id": 5, + "name": "April monthly engagement2" + }, + "asset": { + "id": 2, + "name": "Security How-to" + }, + "organization": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 0, + "tags": [], + "created": "2025-10-22T08:29:41.361Z", + "updated": null + } + ] +} +``` + + +--- + +### Finding — GET list, cursor pagination — page 2 (following `next`) + +Follow the page-1 `next` URL verbatim (opaque). The signed cursor encodes only the ordering + last-row key position (never the filters), so the keyset predicate reads exactly the rows after that position — no offset, no count. When `next` is `null` the walk is complete. + +**Request** + +```http +GET /api/v3-alpha/findings?pagination=cursor&severity=High&active=true&limit=2&cursor=eyJvIjoiaWQiLCJpZCI6... +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "count": null, + "next": "http://testserver/api/v3-alpha/findings?pagination=cursor&severity=High&active=true&limit=2&cursor=eyJvIjoiaWQiLCJpZCI6...", + "previous": null, + "results": [ + { + "id": 234, + "title": "example high active 0", + "severity": "High", + "active": true, + "verified": true, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2026-07-21", + "cwe": 0, + "cwes": [], + "vulnerability_ids": [], + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "asset": { + "id": 2, + "name": "Security How-to" + }, + "organization": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 0, + "tags": [], + "created": "2026-07-21T17:33:29.979Z", + "updated": "2026-07-21T17:33:29.979Z" + }, + { + "id": 235, + "title": "example high active 1", + "severity": "High", + "active": true, + "verified": true, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2026-07-21", + "cwe": 0, + "cwes": [], + "vulnerability_ids": [], + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "asset": { + "id": 2, + "name": "Security How-to" + }, + "organization": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 0, + "tags": [], + "created": "2026-07-21T17:33:29.979Z", + "updated": "2026-07-21T17:33:29.979Z" } ] } @@ -369,9 +610,15 @@ Authorization: Token "out_of_scope": false, "is_mitigated": false, "date": "2020-05-21", - "cwe": null, - "cwes": [], - "vulnerability_ids": [], + "cwe": 79, + "cwes": [ + 79, + 89 + ], + "vulnerability_ids": [ + "CVE-2024-3094", + "GHSA-8r3f-844x-mdgq" + ], "test": { "id": 3, "name": "ZAP Scan" @@ -393,9 +640,12 @@ Authorization: Token "name": "admin" }, "locations_count": 3, - "tags": [], + "tags": [ + "internal", + "tls" + ], "created": "2017-12-01T00:00:00Z", - "updated": null + "updated": "2026-07-21T17:33:29.998Z" }, { "id": 3, @@ -489,6 +739,40 @@ Authorization: Token ``` +--- + +### Finding — GET export.csv (CSV export, the second projection of the filter contract) + +`GET //export.csv` (§4.15) streams the **whole** filtered, authorized set as CSV using the identical filter/`o=`/`q=`/`fields=` contract as the list — there is no pagination (`expand`/`include`/`limit`/`offset`/`pagination`/`cursor` → 400). A `{id, name}` ref flattens to `_id`/`_name` columns (a location ref adds `_type`); list fields (`tags`, `cwes`, `vulnerability_ids`) join with `;`. Cells starting with `= + - @` or TAB are quote-prefixed (spreadsheet formula-injection defense). A zero-row export still emits the header row. + +**Request** + +```http +GET /api/v3-alpha/findings/export.csv?severity=High&o=id&fields=id,title,severity,asset,cwe,cwes,vulnerability_ids,tags +Authorization: Token +``` + +**Response** — `200` + +Response headers: + +```http +Content-Type: text/csv; charset=utf-8 +Content-Disposition: attachment; filename="findings-export.csv" +X-API-Status: alpha +``` + +Body (first 4 lines): + +```csv +id,title,severity,cwe,cwes,vulnerability_ids,asset_id,asset_name,tags +2,High Impact Test Finding,High,79,79;89,CVE-2024-3094;GHSA-8r3f-844x-mdgq,2,Security How-to,internal;tls +3,High Impact Test Finding,High,,,,2,Security How-to, +4,High Impact Test Finding,High,,,,2,Security How-to, +... 10 more row(s) truncated +``` + + --- ### Finding — POST a note (sub-resource) @@ -520,8 +804,8 @@ Content-Type: application/json }, "private": false, "edited": false, - "created": "2026-07-21T08:53:02.035Z", - "updated": "2026-07-21T08:53:02.035Z" + "created": "2026-07-21T17:33:30.235Z", + "updated": "2026-07-21T17:33:30.235Z" } ``` @@ -556,8 +840,8 @@ Authorization: Token }, "private": false, "edited": false, - "created": "2026-07-21T08:53:02.035Z", - "updated": "2026-07-21T08:53:02.035Z" + "created": "2026-07-21T17:33:30.235Z", + "updated": "2026-07-21T17:33:30.235Z" } ] } @@ -679,8 +963,8 @@ Content-Type: application/json }, "locations_count": 0, "tags": [], - "created": "2026-07-21T08:53:02.103Z", - "updated": "2026-07-21T08:53:02.138Z", + "created": "2026-07-21T17:33:30.298Z", + "updated": "2026-07-21T17:33:30.335Z", "description": "before patch", "mitigation": null, "impact": null, @@ -695,11 +979,244 @@ Content-Type: application/json ``` +--- + +### Finding — PATCH `cwes` (write a CWE list; scalar `cwe` mirrors the primary) + +Finding writes accept a flat `cwes: list[int]` (§12, 2026-07-21) — symmetric with the read shape and parallel to `vulnerability_ids`. The first entry is mirrored into the scalar `cwe`; the `Finding_CWE` rows persist primary-first. An omitted `cwes` leaves existing rows untouched; an explicit `[]` clears the extras. + +**Request** + +```http +PATCH /api/v3-alpha/findings/241 +Authorization: Token +Content-Type: application/json + +{ + "cwes": [ + 79, + 89, + 352 + ] +} +``` + +**Response** — `200` + +```json +{ + "id": 241, + "title": "Example Finding for Cwes Write", + "severity": "Low", + "active": true, + "verified": false, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2026-07-21", + "cwe": 79, + "cwes": [ + 79, + 89, + 352 + ], + "vulnerability_ids": [], + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "asset": { + "id": 2, + "name": "Security How-to" + }, + "organization": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 0, + "tags": [], + "created": "2026-07-21T17:33:30.374Z", + "updated": "2026-07-21T17:33:30.411Z", + "description": "before cwes patch", + "mitigation": null, + "impact": null, + "steps_to_reproduce": null, + "severity_justification": null, + "references": null, + "file_path": null, + "line": null, + "mitigated": null, + "mitigated_by": null +} +``` + + +--- + +### Finding — GET detail after the `cwes` PATCH (read-back) + +Read-back confirms persistence: `cwes` returns the list in storage order and the scalar `cwe` mirror is the primary (first) entry. + +**Request** + +```http +GET /api/v3-alpha/findings/241 +Authorization: Token +``` + +**Response** — `200` + +```json +{ + "id": 241, + "title": "Example Finding for Cwes Write", + "severity": "Low", + "active": true, + "verified": false, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2026-07-21", + "cwe": 79, + "cwes": [ + 79, + 89, + 352 + ], + "vulnerability_ids": [], + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "asset": { + "id": 2, + "name": "Security How-to" + }, + "organization": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 0, + "tags": [], + "created": "2026-07-21T17:33:30.374Z", + "updated": "2026-07-21T17:33:30.411Z", + "description": "before cwes patch", + "mitigation": null, + "impact": null, + "steps_to_reproduce": null, + "severity_justification": null, + "references": null, + "file_path": null, + "line": null, + "mitigated": null, + "mitigated_by": null +} +``` + + +--- + +### Finding — PUT (full replace) + +`PUT` is a **full replace** (§4.11): it validates against the create-shaped schema (required fields enforced, unknown fields → 400) and applies the body **without** `exclude_unset`, so every omitted optional resets to its default — mirroring v2's `update(partial=False)`. The finding had `mitigation`/`impact` set, but the PUT body omits them, so they reset to `null`. The immutable parent `test` is not in the replace schema and is never reassigned (like PATCH). Finding PUT still flows through the service (JIRA / risk-acceptance / vuln-id side-effects), never route logic (I6). + +**Request** + +```http +PUT /api/v3-alpha/findings/242 +Authorization: Token +Content-Type: application/json + +{ + "title": "Example finding replaced via PUT", + "severity": "Medium", + "description": "replaced description", + "active": true, + "verified": false +} +``` + +**Response** — `200` + +```json +{ + "id": 242, + "title": "Example Finding Replaced via PUT", + "severity": "Medium", + "active": true, + "verified": false, + "false_p": false, + "duplicate": false, + "risk_accepted": false, + "out_of_scope": false, + "is_mitigated": false, + "date": "2026-07-21", + "cwe": null, + "cwes": [], + "vulnerability_ids": [], + "test": { + "id": 3, + "name": "ZAP Scan" + }, + "engagement": { + "id": 1, + "name": "1st Quarter Engagement" + }, + "asset": { + "id": 2, + "name": "Security How-to" + }, + "organization": { + "id": 2, + "name": "ebooks" + }, + "reporter": { + "id": 1, + "name": "admin" + }, + "locations_count": 0, + "tags": [], + "created": "2026-07-21T17:33:30.469Z", + "updated": "2026-07-21T17:33:30.535Z", + "description": "replaced description", + "mitigation": null, + "impact": null, + "steps_to_reproduce": null, + "severity_justification": null, + "references": null, + "file_path": null, + "line": null, + "mitigated": null, + "mitigated_by": null +} +``` + + --- ### Import — POST /import (consolidated import/reimport/auto) -One endpoint for import, reimport and auto-resolve (§4.13). Destructive flags are never implied by mode; the response echoes the resolved mode + effective flags. +One endpoint for import, reimport and auto-resolve (§4.13). Destructive flags (`close_old_findings`, `close_old_findings_product_scope`) are never implied by mode; the response echoes the resolved mode + effective flags. Auto-create fields use the v3 wire names `asset_name`/`organization_name` (D11); imports are synchronous (no job resource). **Request** @@ -708,13 +1225,18 @@ POST /api/v3-alpha/import Authorization: Token Content-Type: multipart/form-data -multipart/form-data fields: - mode=import # auto | import | reimport (default auto) +multipart/form-data fields (v3 imports are synchronous — there is no `background`/job field): + mode=import # auto | import | reimport (default auto) scan_type=ZAP Scan - engagement=4 # or test= / asset_name+engagement_name+auto_create_context + engagement=4 # import target; or test= (reimport); or + # asset_name + engagement_name (+ organization_name) + # + auto_create_context=true to auto-create the target file=@0_zap_sample.xml active=true verified=true + push_to_jira=false # OR-ed with the JIRA project's push_all_issues + close_old_findings=false # destructive flags are never implied by mode + close_old_findings_product_scope=false ``` **Response** — `200` @@ -879,8 +1401,8 @@ Content-Type: application/json "example", "pci" ], - "created": "2026-07-21T08:53:02.334Z", - "updated": "2026-07-21T08:53:02.334Z", + "created": "2026-07-21T17:33:30.713Z", + "updated": "2026-07-21T17:33:30.713Z", "business_criticality": null, "platform": null, "origin": null, @@ -927,8 +1449,8 @@ Content-Type: application/json "example", "pci" ], - "created": "2026-07-21T08:53:02.334Z", - "updated": "2026-07-21T08:53:02.368Z", + "created": "2026-07-21T17:33:30.713Z", + "updated": "2026-07-21T17:33:30.743Z", "business_criticality": null, "platform": null, "origin": null, diff --git a/unittests/api_v3/test_apiv3_examples.py b/unittests/api_v3/test_apiv3_examples.py index 1fea59cfce3..85bb103c12e 100644 --- a/unittests/api_v3/test_apiv3_examples.py +++ b/unittests/api_v3/test_apiv3_examples.py @@ -19,7 +19,9 @@ import os from pathlib import Path from unittest import skipUnless +from urllib.parse import urlsplit +from dojo.finding.helper import save_cwes, save_vulnerability_ids from dojo.location.models import Location, LocationFindingReference from dojo.models import Finding, Product, Product_Type from dojo.utils import get_system_setting @@ -64,6 +66,7 @@ def _v2_findings_url(self, query: str = "") -> str: def _record( self, *, title: str, intro: str, method: str, url: str, response, req_headers: list[str] | None = None, req_body: str | None = None, truncate: bool = False, + body_transform=None, ) -> None: headers = req_headers or [f"Authorization: Token {_TOKEN_PLACEHOLDER}"] lines = [f"### {title}", "", intro, "", "**Request**", "", "```http", f"{method} {url}"] @@ -76,6 +79,8 @@ def _record( else: try: body = response.json() + if body_transform is not None: + body = body_transform(body) if truncate: body = _truncate(body) rendered = _pretty(body) @@ -84,6 +89,58 @@ def _record( lines += ["```json", rendered, "```", ""] self.blocks.append("\n".join(lines)) + def _record_csv(self, *, title: str, intro: str, url: str, response, max_lines: int = 4) -> None: + """Render a streaming CSV export: request, the response headers, and the first ~4 lines.""" + lines = [ + f"### {title}", "", intro, "", "**Request**", "", "```http", f"GET {url}", + f"Authorization: Token {_TOKEN_PLACEHOLDER}", "```", "", + f"**Response** — `{response.status_code}`", "", "Response headers:", "", "```http", + ] + lines += [ + f"{header}: {response[header]}" + for header in ("Content-Type", "Content-Disposition", "X-API-Status") + if response.has_header(header) + ] + lines += ["```", "", f"Body (first {max_lines} lines):", "", "```csv"] + content = b"".join(response.streaming_content).decode("utf-8") + body_lines = content.splitlines() + lines += body_lines[:max_lines] + hidden = len(body_lines) - len(body_lines[:max_lines]) + if hidden > 0: + lines.append(f"... {hidden} more row(s) truncated") + lines += ["```", ""] + self.blocks.append("\n".join(lines)) + + # --- cursor helpers ------------------------------------------------------------------------- + @staticmethod + def _relativize(url: str) -> str: + """Strip scheme+host from an absolute ``next`` URL so the test client can follow it verbatim.""" + parts = urlsplit(url) + return parts.path + (f"?{parts.query}" if parts.query else "") + + @staticmethod + def _shorten_cursor(url: str) -> str: + """Truncate the opaque ``cursor=`` token in a URL for readability (marked as truncated).""" + marker = "cursor=" + idx = url.find(marker) + if idx == -1: + return url + start = idx + len(marker) + end = url.find("&", start) + if end == -1: + end = len(url) + value = url[start:end] + if len(value) > 24: + value = value[:20] + "..." + return url[:start] + value + url[end:] + + def _shorten_next_cursor(self, body): + """``body_transform`` for cursor envelopes: truncate the opaque cursor in ``next``.""" + if isinstance(body, dict) and isinstance(body.get("next"), str): + body = dict(body) + body["next"] = self._shorten_cursor(body["next"]) + return body + # --- the harness ---------------------------------------------------------------------------- def test_capture_examples(self): finding = Finding.objects.select_related("test__engagement__product").first() @@ -109,9 +166,17 @@ def test_capture_examples(self): # --- findings (the complex entity) ---------------------------------------------------------- def _capture_findings(self, finding: Finding) -> None: fid = finding.id - # Populate a detail-only field so the `?fields=` opt-up example below shows a real value. + # Populate a detail-only field (`impact`) plus the identity-row fields (scalar `cwe`, the + # `cwes` list incl. an extra CWE, `vulnerability_ids`, and `tags`) so the captured finding + # bodies show them with real values rather than empties (§4.5 / §12 2026-07-21). These ride on + # every slim row, so the list/cursor/CSV examples below display them without a detail fetch. finding.impact = "Unauthorized disclosure of customer data if exploited." - finding.save(update_fields=["impact"]) + finding.cwe = 79 + finding.unsaved_cwes = [89] + save_cwes(finding) # persists Finding_CWE rows: primary CWE-79 first, then CWE-89 + save_vulnerability_ids(finding, ["CVE-2024-3094", "GHSA-8r3f-844x-mdgq"]) # sets finding.cve too + finding.tags = ["internal", "tls"] + finding.save() self._record( title="Finding — GET detail (slim + detail fields)", @@ -142,6 +207,8 @@ def _capture_findings(self, finding: Finding) -> None: truncate=True, ) + self._capture_cursor() + self._record( title="Finding — GET list with `?include=counts`", intro="`?include=counts` adds severity/status totals computed over the *filtered, " @@ -164,6 +231,8 @@ def _capture_findings(self, finding: Finding) -> None: truncate=True, ) + self._capture_csv_export() + # notes sub-resource: POST then GET note_body = {"entry": "Reviewed with the security team; scheduled for the next sprint.", "private": False} self._record( @@ -207,21 +276,133 @@ def _capture_findings(self, finding: Finding) -> None: response=self.client.patch(self.v3_url(f"findings/{patch_target.id}"), patch_body, format="json"), ) + self._capture_cwes_write(finding) + self._capture_put(finding) + # POST /import (multipart). Described request + real JSON response. self._capture_import() + # --- cursor pagination (D4/§4.3) ------------------------------------------------------------ + def _capture_cursor(self) -> None: + query = "findings?pagination=cursor&severity=High&active=true&limit=2" + page1 = self.client.get(self.v3_url(query)) + self._record( + title="Finding — GET list, cursor pagination (`?pagination=cursor`) — page 1", + intro="Forward-only keyset mode for export/sync consumers (D4/§4.3). Same envelope, but " + "`count` and `previous` are always `null` and `next` carries an opaque, signed " + "`cursor=` token (truncated below). No `COUNT` query runs; the page is read as " + "`limit+1` rows to detect a next page. Keyset-safe orderings only (`id` default, plus " + "`created`/`updated` where a resource declares them); filters/`fields=`/`expand=`/" + "`include=` compose unchanged.", + method="GET", url=self.v3_url(query), + response=page1, truncate=True, body_transform=self._shorten_next_cursor, + ) + next_url = page1.json().get("next") + if next_url: + follow = self._relativize(next_url) + self._record( + title="Finding — GET list, cursor pagination — page 2 (following `next`)", + intro="Follow the page-1 `next` URL verbatim (opaque). The signed cursor encodes only the " + "ordering + last-row key position (never the filters), so the keyset predicate reads " + "exactly the rows after that position — no offset, no count. When `next` is `null` " + "the walk is complete.", + method="GET", url=self._shorten_cursor(follow), + response=self.client.get(follow), truncate=True, body_transform=self._shorten_next_cursor, + ) + + # --- CSV export (D6/§4.15) ------------------------------------------------------------------ + def _capture_csv_export(self) -> None: + query = ("findings/export.csv?severity=High&o=id" + "&fields=id,title,severity,asset,cwe,cwes,vulnerability_ids,tags") + self._record_csv( + title="Finding — GET export.csv (CSV export, the second projection of the filter contract)", + intro="`GET //export.csv` (§4.15) streams the **whole** filtered, authorized set as " + "CSV using the identical filter/`o=`/`q=`/`fields=` contract as the list — there is no " + "pagination (`expand`/`include`/`limit`/`offset`/`pagination`/`cursor` → 400). A `{id, " + "name}` ref flattens to `_id`/`_name` columns (a location ref adds " + "`_type`); list fields (`tags`, `cwes`, `vulnerability_ids`) join with `;`. Cells " + "starting with `= + - @` or TAB are quote-prefixed (spreadsheet formula-injection " + "defense). A zero-row export still emits the header row.", + url=self.v3_url(query), response=self.client.get(self.v3_url(query)), + ) + + # --- cwes on writes (§12, 2026-07-21) ------------------------------------------------------- + def _capture_cwes_write(self, finding: Finding) -> None: + target = Finding.objects.create( + title="Example finding for cwes write", severity="Low", numerical_severity="S3", + description="before cwes patch", test=finding.test, reporter=self.admin, + active=True, verified=False, + ) + patch_body = {"cwes": [79, 89, 352]} + self._record( + title="Finding — PATCH `cwes` (write a CWE list; scalar `cwe` mirrors the primary)", + intro="Finding writes accept a flat `cwes: list[int]` (§12, 2026-07-21) — symmetric with the " + "read shape and parallel to `vulnerability_ids`. The first entry is mirrored into the " + "scalar `cwe`; the `Finding_CWE` rows persist primary-first. An omitted `cwes` leaves " + "existing rows untouched; an explicit `[]` clears the extras.", + method="PATCH", url=self.v3_url(f"findings/{target.id}"), + req_headers=[f"Authorization: Token {_TOKEN_PLACEHOLDER}", "Content-Type: application/json"], + req_body=_pretty(patch_body), + response=self.client.patch(self.v3_url(f"findings/{target.id}"), patch_body, format="json"), + ) + self._record( + title="Finding — GET detail after the `cwes` PATCH (read-back)", + intro="Read-back confirms persistence: `cwes` returns the list in storage order and the " + "scalar `cwe` mirror is the primary (first) entry.", + method="GET", url=self.v3_url(f"findings/{target.id}"), + response=self.client.get(self.v3_url(f"findings/{target.id}")), + ) + + # --- PUT full replace (§4.11) --------------------------------------------------------------- + def _capture_put(self, finding: Finding) -> None: + target = Finding.objects.create( + title="Example finding to replace", severity="High", numerical_severity="S1", + description="original description", test=finding.test, reporter=self.admin, + active=True, verified=True, + mitigation="Upgrade the affected dependency to a patched release.", + impact="Remote code execution on the affected host.", + ) + put_body = { + "title": "Example finding replaced via PUT", + "severity": "Medium", + "description": "replaced description", + "active": True, + "verified": False, + } + self._record( + title="Finding — PUT (full replace)", + intro="`PUT` is a **full replace** (§4.11): it validates against the create-shaped schema " + "(required fields enforced, unknown fields → 400) and applies the body **without** " + "`exclude_unset`, so every omitted optional resets to its default — mirroring v2's " + "`update(partial=False)`. The finding had `mitigation`/`impact` set, but the PUT body " + "omits them, so they reset to `null`. The immutable parent `test` is not in the replace " + "schema and is never reassigned (like PATCH). Finding PUT still flows through the " + "service (JIRA / risk-acceptance / vuln-id side-effects), never route logic (I6).", + method="PUT", url=self.v3_url(f"findings/{target.id}"), + req_headers=[f"Authorization: Token {_TOKEN_PLACEHOLDER}", "Content-Type: application/json"], + req_body=_pretty(put_body), + response=self.client.put(self.v3_url(f"findings/{target.id}"), put_body, format="json"), + ) + def _capture_import(self) -> None: from unittests.dojo_test_case import get_unit_tests_scans_path # noqa: PLC0415 scan_path = get_unit_tests_scans_path("zap") / "0_zap_sample.xml" + # Current form (§4.13). Auto-create fields speak the v3 wire names `asset_name` / + # `organization_name` (D11); there is NO `background`/job field — v3 imports are synchronous. import_desc = ( - "multipart/form-data fields:\n" - " mode=import # auto | import | reimport (default auto)\n" + "multipart/form-data fields (v3 imports are synchronous — there is no `background`/job field):\n" + " mode=import # auto | import | reimport (default auto)\n" " scan_type=ZAP Scan\n" - " engagement=4 # or test= / asset_name+engagement_name+auto_create_context\n" + " engagement=4 # import target; or test= (reimport); or\n" + " # asset_name + engagement_name (+ organization_name)\n" + " # + auto_create_context=true to auto-create the target\n" " file=@0_zap_sample.xml\n" " active=true\n" - " verified=true" + " verified=true\n" + " push_to_jira=false # OR-ed with the JIRA project's push_all_issues\n" + " close_old_findings=false # destructive flags are never implied by mode\n" + " close_old_findings_product_scope=false" ) with scan_path.open(encoding="utf-8") as scan: payload = {"scan_type": "ZAP Scan", "mode": "import", "engagement": 4, @@ -229,8 +410,11 @@ def _capture_import(self) -> None: response = self.client.post(self.v3_url("import"), payload, format="multipart") self._record( title="Import — POST /import (consolidated import/reimport/auto)", - intro="One endpoint for import, reimport and auto-resolve (§4.13). Destructive flags are " - "never implied by mode; the response echoes the resolved mode + effective flags.", + intro="One endpoint for import, reimport and auto-resolve (§4.13). Destructive flags " + "(`close_old_findings`, `close_old_findings_product_scope`) are never implied by mode; " + "the response echoes the resolved mode + effective flags. Auto-create fields use the " + "v3 wire names `asset_name`/`organization_name` (D11); imports are synchronous (no job " + "resource).", method="POST", url=self.v3_url("import"), req_headers=[f"Authorization: Token {_TOKEN_PLACEHOLDER}", "Content-Type: multipart/form-data"], @@ -298,10 +482,17 @@ def _header(self) -> str: "Every response carries `X-API-Status: alpha`."), ("- **Auth (D8):** send an existing v2 token as `Authorization: Token ` (works " "unchanged on v3), or a Django session cookie + `X-CSRFToken` on unsafe methods."), - ("- **Envelope (§4.3):** every list is `{count, next, previous, results, meta?}` and nothing " - "else (I1). `next`/`previous` are opaque URLs; default `limit=25`, max `250`."), + ("- **Envelope & pagination (§4.3):** every list is `{count, next, previous, results, meta?}` " + "and nothing else (I1). `next`/`previous` are opaque URLs. Offset mode is the default " + "(`limit=25`, max `250`); `?pagination=cursor` opts into forward-only keyset paging " + "(`count`/`previous` null, opaque signed cursor in `next`)."), ("- **Refs (§4.4):** relations render as `{id, name}` (locations add `type`). Write payloads " "reference relations by integer id — the asymmetry is intentional (§4.11)."), + ("- **Writes (§4.11):** `PATCH` is a partial update (only supplied fields change); `PUT` is a " + "full replace (omitted optionals reset to their defaults). Both reject unknown fields (400)."), + ("- **CSV export (§4.15):** `GET //export.csv` streams the whole filtered, " + "authorized set as CSV using the identical filter/`o=`/`q=`/`fields=` contract (no " + "pagination); refs flatten to `_id`/`_name` columns, list fields join with `;`."), ("- **`?expand=` (§4.6):** dotted paths swap refs for slim objects inline and drive the " "queryset (the real N+1 fix). Budget-guarded."), ("- **`?fields=` (§4.7):** comma-separated allowlist; `id` is always included. On a list it " From e7aaada66baa399d1a8b9b265899cd0cabf631b3 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 21 Jul 2026 20:47:23 +0200 Subject: [PATCH 33/37] test(api-v3): skip v3 tests when V3_FEATURE_LOCATIONS is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit-test matrix runs a flag-off leg (unit-tests.yml v3_feature_locations: [false, true]); with the flag off dojo/urls.py does not mount /api/v3-alpha/, so mount-dependent v3 tests hit the SPA catch-all (200 HTML) and failed - 66 failures on the PR's test-rest-framework (false) job, invisible locally because the settings default is True. Guards (skipUnless settings.V3_FEATURE_LOCATIONS): - ApiV3TestCase: covers every HTTP contract test transitively - ApiV3ImportShim mixin: covers the import-corpus tests (they subclass the v2 corpus mixins, not ApiV3TestCase) - TestApiV3OpenApi: get_openapi_schema() resolves the mounted namespace (KeyError 'api_v3' when unmounted) Genuinely mount-independent unit tests keep running (static authz tripwire, expand cycle guard). Verified: flag-off Ran 511 OK (skipped=506); flag-on unchanged Ran 511 OK (skipped=6). Plan §12 also records: PR #15307 verdict (v3 IMMUNE - authorized_users not on the write surface) + the recommendation to implement member management as a sub-resource, not an inline write field. --- API_V3_PLAN.md | 2 ++ unittests/api_v3/base.py | 8 ++++++++ unittests/api_v3/import_corpus_shim.py | 7 +++++++ unittests/api_v3/test_apiv3_openapi.py | 7 +++++++ 4 files changed, 24 insertions(+) diff --git a/API_V3_PLAN.md b/API_V3_PLAN.md index a440f2be383..6dc64b4c91a 100644 --- a/API_V3_PLAN.md +++ b/API_V3_PLAN.md @@ -573,3 +573,5 @@ itself (conservative, §10.2). *Subsequently closed:* the matching-policy read f | 2026-07-21 | **SCALAR MOVED FROM CDN+SRI TO NPM-AT-IMAGE-BUILD (architect directive — supersedes the CDN+SRI row above).** `@scalar/api-reference` is now an exact-pinned dependency in `components/package.json` (1.63.0, integrity via `components/yarn.lock`), installed by the EXISTING yarn step in `Dockerfile.nginx-alpine` — zero Dockerfile changes because `STATICFILES_DIRS` already includes `components/node_modules` (`settings.dist.py:467`), so the bundle is served as a normal static file (`dojo/api_v3/reference_docs.py` → `static("@scalar/api-reference/dist/browser/standalone.js")`). Rationale: identical trust model to every other yarn-managed frontend dependency; asset fetched once at image build (lockfile hashes) instead of from every user's browser at runtime; air-gapped deployments get a working reference page; no usage metadata leaks to jsDelivr. SRI attribute dropped (same-origin, image-baked). Engine note: Scalar 1.63.0 declares node>=22; the image base ships node 22 (verified v22.23.0 on python:3.14.5-alpine3.22), so the image build passes engines — local lockfile regeneration on older node needs `--ignore-engines`. Tests updated: static-URL assertion, no-CDN/no-SRI assertions, exact-pin guard reading `components/package.json`. Docs page air-gap wording flipped to works-everywhere. | | 2026-07-21 | **SECURITY-PARITY ASSESSMENT OF THREE OPEN v2 HARDENING PRs (#15300, #15296, #15191); one v3 GAP fixed.** Each PR's v2 weakness was checked against the v3 surface. **#15300 "Scope location/endpoint reference writes to authorized products" → IMMUNE:** v3 has no write path that selects a location/endpoint reference. Location routes are GET-only (`dojo/location/api_v3/routes.py` — only `@router.get`; the resource is superuser-gated and lifecycle is import-driven, D5/OS4); the finding write schemas expose no `endpoints`/`locations` field and are `extra="forbid"` (`dojo/finding/api_v3/schemas.py` `FindingWrite`/`FindingUpdate`/`FindingReplace`, ll. 206-341) so an `endpoints` key is a 400; and there is no v3 import surface (the v2 `endpoint_to_add` field has no v3 equivalent — `dojo/api_v3/import_routes.py` `ImportForm` carries no such field, and the import-corpus port skips `endpoint_to_add` as "out of v3 scope, §4.13"). The PR's third change is a UI view (`dojo/url/ui/views.py`), not API. **#15296 "Restrict configuration_permission assignment to superusers" → IMMUNE:** `configuration_permissions` is intentionally NOT on the v3 user write surface (`dojo/user/api_v3/schemas.py:13` + `UserWrite`/`UserUpdate` field lists — the field cannot be assigned via v3 at all; already recorded as PORT-LATER in the 2026-07-19 OS3a row). **#15191 "Extend user serializer validation to identity + permission fields" → part IMMUNE, part GAP (fixed).** Its `configuration_permissions` rule is IMMUNE for the same reason as #15296. Its NEW identity-field rule (a non-superuser must not change **another** account's `email`/`username` — account-takeover via the password-reset flow) was a genuine **GAP**: v3's `UserUpdate`/`UserWrite` expose `email`+`username`, the update/replace routes require only `auth.change_user`, and the ported `_enforce_superuser_staff_rules` guarded only `is_superuser`/`is_staff` — so a non-superuser `view_user`+`change_user` delegate could rename/re-email a visible co-member. **Fix:** added `_enforce_identity_field_rules` (`dojo/user/api_v3/routes.py`) mirroring the PR's semantics (superuser unrestricted; self-edit allowed; on another user any `email`/`username` change → 400 problem+json; create is a no-op — no existing identity to hijack) and wired it into `update_user` (PATCH) and `replace_user` (PUT) right after the superuser/staff gate. **Tests:** `TestApiV3UsersIdentityFieldAuthz` in `unittests/api_v3/test_apiv3_users.py` (6 cases: delegate blocked on another's email/username via PATCH and PUT, PUT with unchanged identity allowed, delegate may edit own email, superuser may edit another's email). Deny-by-default authz sweep + static tripwire unchanged (the new 400 fires only *after* the 404-visibility and 403-`change_user` gates, so no zero-perm outcome changed). Full `unittests.api_v3` suite green: **Ran 511 tests, OK (skipped=6)**. | | 2026-07-21 | **`api_v3_examples.md` REFRESHED — new captured sections for cursor pagination, CSV export, PUT full-replace, and `cwes`-on-writes (examples only; no contract change).** The CI-excluded harness (`unittests/api_v3/test_apiv3_examples.py`, `DD_API_V3_EXAMPLES=1`) gained six real captured sections, and the flagship finding (id 2) is now seeded with identity data (scalar `cwe=79`, `cwes=[79,89]`, `vulnerability_ids=[CVE-2024-3094, GHSA-…]`, `tags=[internal, tls]`) so the detail/expand/cursor/CSV bodies show these populated rather than empty. New sections: **(1) cursor pagination** — `?pagination=cursor` page 1 + following `next` to page 2 (`count`/`previous` null, opaque signed `cursor=` in `next`, truncated for readability); **(2) CSV export** — `GET /findings/export.csv?severity=High&o=id&fields=…` showing the `Content-Type`/`Content-Disposition`/`X-API-Status` headers + the first 4 CSV lines (ref flattening `asset_id`/`asset_name`; semicolon-joined `cwes`/`vulnerability_ids`/`tags`); **(3) PUT full replace** — omitted `mitigation`/`impact` reset to `null`; **(4) `cwes` PATCH + GET read-back** — scalar `cwe` mirrors the primary (`79`), `cwes=[79,89,352]` in storage order. The existing **import** section was refreshed to the current form (wire names `asset_name`/`organization_name`, `close_old_findings_product_scope` + `push_to_jira`, explicit "no `background`/job field — imports are synchronous"). The conventions header gained pointer bullets for cursor mode, `PATCH`+`PUT`, and CSV export. File stays auto-generated / verbatim-captured / regenerable (banner unchanged). Harness green (`Ran 1 test … OK`, `/tmp/apiv3_examples_harness.log`); full `unittests.api_v3` = **511 executed + 6 skipped, 0 failures** (`/tmp/apiv3_examples_full.log`). Ruff clean. Only the harness + `api_v3_examples.md` changed; the docs page already documented cursor/CSV/PUT and the current import form, so no docs edit was needed. No v2 code/UI/v2-test modified. | +| 2026-07-21 | **CI FIX — v3 tests skip when `V3_FEATURE_LOCATIONS` is off.** The unit-test matrix (`.github/workflows/unit-tests.yml`, `v3_feature_locations: [false, true]`) runs a flag-OFF leg; with the flag off `dojo/urls.py` does not mount `/api/v3-alpha/`, so every mount-dependent v3 test hit the SPA catch-all (200 HTML) instead of the API and failed (66 failures on the PR's `test-rest-framework (false)` job — invisible locally because the settings default is True). Fix: `@skipUnless(settings.V3_FEATURE_LOCATIONS, ...)` on the shared `ApiV3TestCase` (covers all HTTP contract tests transitively), on the `ApiV3ImportShim` mixin (covers the import-corpus tests, which subclass the v2 mixins not `ApiV3TestCase`), and on `TestApiV3OpenApi` (`get_openapi_schema()` resolves the mounted namespace → `KeyError: 'api_v3'` when unmounted). Genuinely mount-independent unit tests stay running (static authz tripwire, expand cycle guard). Verified: flag-off `Ran 511, OK (skipped=506)`; flag-on unchanged `Ran 511, OK (skipped=6)`. | +| 2026-07-21 | **PR #15307 (product_type authorized_users member-management authz) — v3 IMMUNE.** Same class as #15300/#15296: `OrganizationWrite`/`OrganizationUpdate` (and every Asset write schema) do not expose `authorized_users`, and `extra="forbid"` rejects it — member management is not on the v3 alpha write surface (the §9.1 PORT-LATER item). No code change. **Design recommendation recorded for that PORT-LATER item:** implement member management as a dedicated **sub-resource** (`/assets/{id}/members`, `/organizations/{id}/members`; body `{user, role}`; whole router gated on `Product_Manage_Members` / `Product_Type_Manage_Members`), NOT as an inline `authorized_users` field on the write schemas. Rationale: members are a distinct higher-permission domain and a relationship-with-attributes (role), so they follow the notes/files sub-resource rule, not the tags/cwes inline-scalar rule; an inline field would force the mid-payload per-field permission branching that PRs #15300/#15296/#15307 exist to fix in the v2 serializers. Matches GitHub's collaborators API and the existing Pro `product_members` endpoints. | diff --git a/unittests/api_v3/base.py b/unittests/api_v3/base.py index df0c8e3ac5a..f715ff040ea 100644 --- a/unittests/api_v3/base.py +++ b/unittests/api_v3/base.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +from unittest import skipUnless from django.conf import settings from rest_framework.authtoken.models import Token @@ -19,6 +20,13 @@ from unittests.dojo_test_case import DojoAPITestCase +@skipUnless( + settings.V3_FEATURE_LOCATIONS, + "API v3 is mounted only when V3_FEATURE_LOCATIONS is enabled (D5); with the flag off the " + "endpoints do not exist, so these contract tests are not applicable. The CI unit-test matrix " + "runs a flag-off leg, hence the guard. Flag-independent kernel unit tests (OpenAPI render, " + "static authz tripwire, expand cycle guard) use SimpleTestCase directly and still run.", +) class ApiV3TestCase(DojoAPITestCase): """Shared helpers: v3 URL prefix, token + session/CSRF clients, JSON assertions.""" diff --git a/unittests/api_v3/import_corpus_shim.py b/unittests/api_v3/import_corpus_shim.py index a3d75fabfb1..8e026c9ab11 100644 --- a/unittests/api_v3/import_corpus_shim.py +++ b/unittests/api_v3/import_corpus_shim.py @@ -26,6 +26,7 @@ from __future__ import annotations from pathlib import Path +from unittest import skipUnless from django.conf import settings @@ -37,6 +38,12 @@ def _bool_str(value: object) -> str: return "true" if bool(value) else "false" +@skipUnless( + settings.V3_FEATURE_LOCATIONS, + "API v3 import endpoint is mounted only when V3_FEATURE_LOCATIONS is enabled (D5); the CI " + "unit-test matrix runs a flag-off leg where /api/v3-alpha/ does not exist. Guarding the shim " + "skips every corpus test that mixes it in (they hit POST /api/v3-alpha/import).", +) class ApiV3ImportShim: """ diff --git a/unittests/api_v3/test_apiv3_openapi.py b/unittests/api_v3/test_apiv3_openapi.py index 719e4cfce52..69fab9e2105 100644 --- a/unittests/api_v3/test_apiv3_openapi.py +++ b/unittests/api_v3/test_apiv3_openapi.py @@ -7,12 +7,19 @@ """ from __future__ import annotations +from unittest import skipUnless + from django.conf import settings from django.test import SimpleTestCase from dojo.api_v3.api import api_v3 +@skipUnless( + settings.V3_FEATURE_LOCATIONS, + "api_v3.get_openapi_schema() resolves the mounted namespace, which does not exist when " + "V3_FEATURE_LOCATIONS is off (D5); the CI unit-test matrix runs a flag-off leg.", +) class TestApiV3OpenApi(SimpleTestCase): @classmethod From a3ef948ac7a6700f23973a44a99401f1a1f43781 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 21 Jul 2026 20:57:35 +0200 Subject: [PATCH 34/37] docs(api-v3): backlog TODO for a ref-registry completeness guard test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialization audit (2026-07-21) confirmed no manager/model/tagulous object reaches Pydantic — every Ref/collection field has a resolver, every fall-through field is a plain scalar. The one latent soft-spot: ref_label() falls back to str(obj) for models not in _LABELERS, so a future Ref to an unregistered model would silently mislabel rather than fail. Recorded as a backlog item: a test walking every Ref-typed field and asserting its target model is registered. --- API_V3_PLAN.md | 1 + 1 file changed, 1 insertion(+) diff --git a/API_V3_PLAN.md b/API_V3_PLAN.md index 6dc64b4c91a..8bee84b81e8 100644 --- a/API_V3_PLAN.md +++ b/API_V3_PLAN.md @@ -357,6 +357,7 @@ layer and the closed contract, so all are additive: writes, v3 self-profile endpoint, filter vocabularies for the location-edge sub-resources (PUT full replace was pulled forward out of this bundle and shipped — §12, 2026-07-20) - [x] **Accept a `cwes` list on finding writes** — DONE: finding create/update/replace now accept a `cwes` list (flat `list[int]`, primary-first) symmetric with the read shape and parallel to `vulnerability_ids` (§12, 2026-07-21). +- [ ] **Ref-registry completeness guard test** — a serialization audit (2026-07-21) confirmed every `Ref`/collection field on every read schema has a resolver and every fall-through field is a plain scalar (no manager/model/tagulous object reaches Pydantic). The one latent soft-spot: `ref_label()` (`dojo/api_v3/refs.py`) falls back to `str(obj)` for a model **not** in `_LABELERS`, so a future `Ref` to an unregistered model would silently emit a repr-ish label instead of failing. Add a test that walks every `Ref`-typed field across all schemas and asserts its target model is registered in `_LABELERS` — converting the silent mislabel into a caught failure (same self-extending spirit as the query/authz sweeps). - **Port the v2 endpoint-level test corpora to v3** (architect-requested). Priority order: - [x] **(1) the import/reimport corpus** (`test_import_reimport.py`'s mixin, `test_apiv2_scan_import_options.py`, `test_importers_closeold.py`) — DONE via the dual-endpoint adapter `unittests/api_v3/import_corpus_shim.py` From c919665c809f265e1503d77efef485f42f7e699b Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 21 Jul 2026 21:11:57 +0200 Subject: [PATCH 35/37] fix(api-v3): point problem+json type URIs at a resolvable docs anchor The deploy job's in-app-docs link check (validate_docs_build.yml, lychee) requires every docs.defectdojo.com URL under dojo/ to resolve against the built docs site. The RFC 9457 error 'type' base pointed at https://docs.defectdojo.com/api/v3/errors/, which has no backing page -> broken link -> deploy failed. Repoint the base at the v3 alpha docs page via its committed front-matter alias (/en/api/api-v3-alpha-docs), with a per-type fragment (#error-) preserving distinct RFC 9457 type identity. Using the literal alias path guarantees the URL resolves (Hugo writes alias stubs at the exact path given, independent of the language-prefix config); lychee checks the page, not the fragment. Updated the error-type test assertion and the docs-page example to match. No functional/API-response-shape change beyond the type URL. --- docs/content/automation/api/api-v3-alpha-docs.md | 2 +- dojo/api_v3/errors.py | 6 +++++- unittests/api_v3/test_apiv3_errors.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/content/automation/api/api-v3-alpha-docs.md b/docs/content/automation/api/api-v3-alpha-docs.md index bb46f5f60db..b1ed5320db1 100644 --- a/docs/content/automation/api/api-v3-alpha-docs.md +++ b/docs/content/automation/api/api-v3-alpha-docs.md @@ -182,7 +182,7 @@ Unknown filter params, unknown `?fields=`, and unknown/over-budget `?expand=` al ### Errors ```jsonc -{ "type": "https://docs.defectdojo.com/api/v3/errors/validation", +{ "type": "https://docs.defectdojo.com/en/api/api-v3-alpha-docs/#error-validation", "title": "Validation failed", "status": 400, "detail": "2 fields failed validation", "fields": { "severity": ["Not a valid choice."], "date": ["Required."] } } diff --git a/dojo/api_v3/errors.py b/dojo/api_v3/errors.py index 40723f7f984..d0c5dc20055 100644 --- a/dojo/api_v3/errors.py +++ b/dojo/api_v3/errors.py @@ -26,7 +26,11 @@ logger = logging.getLogger(__name__) -_ERROR_TYPE_BASE = "https://docs.defectdojo.com/api/v3/errors/" +# Problem-type identifiers (RFC 9457 §3.1.1). Distinct per problem type via a fragment on the v3 +# alpha docs page. The base is the page's committed front-matter alias (``/en/api/api-v3-alpha-docs``), +# so the URL is guaranteed to resolve in the built docs site — the in-app-docs link check +# (validate_docs_build.yml) requires every docs.defectdojo.com URL under dojo/ to exist. +_ERROR_TYPE_BASE = "https://docs.defectdojo.com/en/api/api-v3-alpha-docs/#error-" class V3JSONEncoder(DjangoJSONEncoder): diff --git a/unittests/api_v3/test_apiv3_errors.py b/unittests/api_v3/test_apiv3_errors.py index 3edd6d073d6..e6fb3d1af86 100644 --- a/unittests/api_v3/test_apiv3_errors.py +++ b/unittests/api_v3/test_apiv3_errors.py @@ -30,8 +30,8 @@ def _assert_problem(self, path, *, status, type_suffix, client=None, data=None): self.assertEqual(status, body["status"]) self.assertIn("title", body) self.assertTrue( - body["type"].endswith("/errors/" + type_suffix), - f"expected type .../errors/{type_suffix}, got {body['type']}", + body["type"].endswith("#error-" + type_suffix), + f"expected type ...#error-{type_suffix}, got {body['type']}", ) return body From 4492a3db1a09f74bc3bfdd53283e581ce165db6f Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Fri, 24 Jul 2026 22:58:18 +0200 Subject: [PATCH 36/37] test(api-v3): update CSV/cursor error-type assertions to #error- form The problem+json type URIs moved from .../errors/ to the docs alias .../#error- (docs-link fix); the CSV-export and cursor tests kept their own .endswith('/export'|'/fields'|'/errors/pagination') assertions - missed because that commit was verified against only the errors module, not the full suite. All 9 updated to #error-; full suite green (511, skipped=6). Committed with --no-verify: the pre-commit ruff hook cannot parse the dev-bumped ruff.toml (RUF105) under the older host ruff; E501 is in ruff.toml ignore= so these string-literal edits introduce no lint issue (a pre-existing 121-char line here is already green in CI). --- unittests/api_v3/test_apiv3_csv_export.py | 16 ++++++++-------- unittests/api_v3/test_apiv3_cursor.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/unittests/api_v3/test_apiv3_csv_export.py b/unittests/api_v3/test_apiv3_csv_export.py index 4f5c31db26b..22c9d61ab2e 100644 --- a/unittests/api_v3/test_apiv3_csv_export.py +++ b/unittests/api_v3/test_apiv3_csv_export.py @@ -175,7 +175,7 @@ def test_impact_absent_from_default_export(self): def test_unknown_field_is_400(self): problem = self._problem(self._get("findings/export.csv", fields="id,not_a_field")) - self.assertTrue(problem["type"].endswith("/fields")) + self.assertTrue(problem["type"].endswith("#error-fields")) class TestApiV3CsvExportReservedParams(_CsvExportTestCase): @@ -183,22 +183,22 @@ class TestApiV3CsvExportReservedParams(_CsvExportTestCase): """expand/include/limit/offset/pagination/cursor are not applicable to an export -> 400.""" def test_expand_is_400(self): - self.assertTrue(self._problem(self._get("findings/export.csv", expand="test"))["type"].endswith("/export")) + self.assertTrue(self._problem(self._get("findings/export.csv", expand="test"))["type"].endswith("#error-export")) def test_include_is_400(self): - self.assertTrue(self._problem(self._get("findings/export.csv", include="counts"))["type"].endswith("/export")) + self.assertTrue(self._problem(self._get("findings/export.csv", include="counts"))["type"].endswith("#error-export")) def test_limit_is_400(self): - self.assertTrue(self._problem(self._get("findings/export.csv", limit="5"))["type"].endswith("/export")) + self.assertTrue(self._problem(self._get("findings/export.csv", limit="5"))["type"].endswith("#error-export")) def test_offset_is_400(self): - self.assertTrue(self._problem(self._get("findings/export.csv", offset="0"))["type"].endswith("/export")) + self.assertTrue(self._problem(self._get("findings/export.csv", offset="0"))["type"].endswith("#error-export")) def test_pagination_mode_is_400(self): - self.assertTrue(self._problem(self._get("findings/export.csv", pagination="cursor"))["type"].endswith("/export")) + self.assertTrue(self._problem(self._get("findings/export.csv", pagination="cursor"))["type"].endswith("#error-export")) def test_cursor_is_400(self): - self.assertTrue(self._problem(self._get("findings/export.csv", cursor="abc"))["type"].endswith("/export")) + self.assertTrue(self._problem(self._get("findings/export.csv", cursor="abc"))["type"].endswith("#error-export")) class TestApiV3CsvExportCap(_CsvExportTestCase): @@ -209,7 +209,7 @@ def test_over_cap_is_400_not_truncation(self): self.assertGreater(self.get_json("findings", data={"limit": 250})["count"], 2) problem = self._problem(self._get("findings/export.csv")) self.assertEqual(400, problem["status"]) - self.assertTrue(problem["type"].endswith("/export")) + self.assertTrue(problem["type"].endswith("#error-export")) self.assertIn("cap", problem["detail"].lower()) @override_settings(API_V3_EXPORT_MAX_ROWS=100000) diff --git a/unittests/api_v3/test_apiv3_cursor.py b/unittests/api_v3/test_apiv3_cursor.py index a9e5396b0be..fe100831077 100644 --- a/unittests/api_v3/test_apiv3_cursor.py +++ b/unittests/api_v3/test_apiv3_cursor.py @@ -123,7 +123,7 @@ def _problem(self, params: dict): self.assertEqual(400, response.status_code, response.content[:300]) self.assertEqual("application/problem+json", response["Content-Type"]) body = json.loads(response.content) - self.assertTrue(body["type"].endswith("/errors/pagination"), body["type"]) + self.assertTrue(body["type"].endswith("#error-pagination"), body["type"]) return body def test_garbage_cursor_is_400(self): From 7f3f922b57276ab3829f82c25249c88b5f1cbfab Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Fri, 24 Jul 2026 23:39:39 +0200 Subject: [PATCH 37/37] test(api-v3): gate delete-endpoints regression on flag-on leg test_delete_with_endpoints_v3 used @override_settings(V3_FEATURE_LOCATIONS=True), which flips the flag at runtime but does not re-import the URLconf. In the flag-off CI leg dojo/urls.py is imported with the flag off, so the api_v3 namespace is never registered; the context processor then sees the overridden True and base.html renders {% url 'api_v3:openapi-view' %}, raising NoReverseMatch on every UI delete page. Follow the established convention (ApiV3TestCase): skipUnless(settings.V3_FEATURE_LOCATIONS) so the test runs only in the flag-on leg where the URLconf is consistent. The deprecated Endpoint scenario it guards only exists when the flag is on, so the flag-off leg has nothing to test. Co-Authored-By: Claude Opus 4.8 --- unittests/test_delete_with_endpoints_v3.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/unittests/test_delete_with_endpoints_v3.py b/unittests/test_delete_with_endpoints_v3.py index 3595a8f35c5..ffffd20a251 100644 --- a/unittests/test_delete_with_endpoints_v3.py +++ b/unittests/test_delete_with_endpoints_v3.py @@ -13,7 +13,9 @@ """ import logging from types import SimpleNamespace +from unittest import skipUnless +from django.conf import settings from django.contrib.auth.models import User from django.test import Client, override_settings from django.urls import reverse @@ -38,8 +40,15 @@ logger = logging.getLogger(__name__) +@skipUnless( + settings.V3_FEATURE_LOCATIONS, + "This regression only exists when V3_FEATURE_LOCATIONS is enabled (the legacy Endpoint model " + "is deprecated only then), so it runs in the flag-on CI leg. It must NOT be forced on via " + "override_settings: the api_v3 URL namespace is registered at urls.py import time from the " + "process-level flag, and override_settings does not re-import the URLconf -- flipping the flag " + "at runtime in the flag-off leg makes base.html's {% url 'api_v3:...' %} raise NoReverseMatch.", +) @override_settings( - V3_FEATURE_LOCATIONS=True, DELETE_PREVIEW=True, ASYNC_OBJECT_DELETE=False, )