diff --git a/.github/workflows/_meta-build.yaml b/.github/workflows/_meta-build.yaml
index 4819ff67e5..40c11ecda2 100644
--- a/.github/workflows/_meta-build.yaml
+++ b/.github/workflows/_meta-build.yaml
@@ -67,13 +67,20 @@ jobs:
mvn -B -Pquick -Dservices.bom.merge.skip=false package
- name: Upload Artifacts
- uses: actions/upload-artifact@v4.6.2
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # tag=v4.6.2
with:
name: assembled-wars
path: |-
apiserver/target/*.jar
apiserver/target/bom.json
+ - name: Upload OpenAPI Spec
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # tag=v4.6.2
+ with:
+ name: openapi-spec
+ path: |-
+ api/target/classes/**/openapi.yaml
+
build-container:
runs-on: ubuntu-latest
timeout-minutes: 5
diff --git a/.github/workflows/ci-openapi.yaml b/.github/workflows/ci-openapi.yaml
new file mode 100644
index 0000000000..998a84538c
--- /dev/null
+++ b/.github/workflows/ci-openapi.yaml
@@ -0,0 +1,69 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+name: OpenAPI
+
+on:
+ pull_request:
+ paths:
+ - api/src/main/openapi/**
+ - api/src/main/spectral/**
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+permissions: { }
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ permissions:
+ checks: write
+ timeout-minutes: 5
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
+ - name: Lint OpenAPI Spec
+ uses: stoplightio/spectral-action@577bade2d6e0eeb50528c94182a5588bf961ae8f # tag=v0.8.12
+ with:
+ spectral_ruleset: "api/src/main/spectral/ruleset.yaml"
+ file_glob: "api/src/main/openapi/openapi.yaml"
+
+# TODO: Uncomment after the OpenAPI Spec is present in main.
+# breaking-changes:
+# name: Breaking Changes
+# runs-on: ubuntu-latest
+# timeout-minutes: 5
+# steps:
+# - name: Checkout Repository
+# uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
+# with:
+# fetch-depth: 0
+# - name: Prepare OpenAPI Spec Comparison
+# run: |-
+# cd api/src/main/resources/org/dependencytrack/api/v2
+# mv openapi.yaml openapi-after.yaml
+#
+# git checkout ${{ github.event.pull_request.base.sha }} -- openapi.yaml
+# mv openapi.yaml openapi-before.yaml
+# - name: Detect Breaking Changes in OpenAPI Spec
+# run: |-
+# docker run -i --rm \
+# -v "$(pwd)/api/src/main/resources/org/dependencytrack/api/v2:/work:ro" \
+# pb33f/openapi-changes summary --no-color --no-logo \
+# openapi-before.yaml openapi-after.yaml
\ No newline at end of file
diff --git a/.mvn/maven-build-cache-config.xml b/.mvn/maven-build-cache-config.xml
index 3eecd9f34c..761cac1a50 100644
--- a/.mvn/maven-build-cache-config.xml
+++ b/.mvn/maven-build-cache-config.xml
@@ -31,7 +31,7 @@
- {*.java,*.properties,*.proto,*.sql,*.xml}
+ {*.java,*.properties,*.proto,*.sql,*.xml,*.yaml}src/
diff --git a/alpine/alpine-server/src/main/java/alpine/server/filters/AuthenticationFilter.java b/alpine/alpine-server/src/main/java/alpine/server/filters/AuthenticationFilter.java
index 26d332b313..bb4de20380 100644
--- a/alpine/alpine-server/src/main/java/alpine/server/filters/AuthenticationFilter.java
+++ b/alpine/alpine-server/src/main/java/alpine/server/filters/AuthenticationFilter.java
@@ -20,8 +20,8 @@
import alpine.common.logging.Logger;
import alpine.model.ApiKey;
-import alpine.server.auth.ApiKeyAuthenticationService;
import alpine.server.auth.AllowApiKeyInQueryParameter;
+import alpine.server.auth.ApiKeyAuthenticationService;
import alpine.server.auth.JwtAuthenticationService;
import org.glassfish.jersey.server.ContainerRequest;
import org.owasp.security.logging.SecurityMarkers;
@@ -29,14 +29,15 @@
import jakarta.annotation.Priority;
import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
-import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.container.ResourceInfo;
import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.Response;
import javax.naming.AuthenticationException;
import java.io.IOException;
import java.security.Principal;
@@ -79,8 +80,7 @@ public void filter(ContainerRequestContext requestContext) {
}
} catch (AuthenticationException e) {
LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "Invalid API key asserted");
- requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
- return;
+ throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build());
}
}
@@ -90,13 +90,12 @@ public void filter(ContainerRequestContext requestContext) {
principal = jwtAuthService.authenticate();
} catch (AuthenticationException e) {
LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "Invalid JWT asserted");
- requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
- return;
+ throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build());
}
}
if (principal == null) {
- requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
+ throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build());
} else {
requestContext.setProperty("Principal", principal);
MDC.put("principal", principal.getName());
diff --git a/alpine/alpine-server/src/main/java/alpine/server/filters/AuthorizationFilter.java b/alpine/alpine-server/src/main/java/alpine/server/filters/AuthorizationFilter.java
index 1c7d6e2d78..d838bcff40 100644
--- a/alpine/alpine-server/src/main/java/alpine/server/filters/AuthorizationFilter.java
+++ b/alpine/alpine-server/src/main/java/alpine/server/filters/AuthorizationFilter.java
@@ -27,6 +27,7 @@
import org.owasp.security.logging.SecurityMarkers;
import jakarta.annotation.Priority;
+import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
@@ -62,8 +63,7 @@ public void filter(ContainerRequestContext requestContext) {
final Principal principal = (Principal) requestContext.getProperty("Principal");
if (principal == null) {
LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "A request was made without the assertion of a valid user principal");
- requestContext.abortWith(Response.status(Response.Status.FORBIDDEN).build());
- return;
+ throw new ForbiddenException(Response.status(Response.Status.FORBIDDEN).build());
}
final Set effectivePermissions;
@@ -97,7 +97,7 @@ public void filter(ContainerRequestContext requestContext) {
LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "Unauthorized access attempt made by %s to %s"
.formatted(requestPrincipal, requestUri));
- requestContext.abortWith(Response.status(Response.Status.FORBIDDEN).build());
+ throw new ForbiddenException(Response.status(Response.Status.FORBIDDEN).build());
} else {
requestContext.setProperty(EFFECTIVE_PERMISSIONS_PROPERTY, effectivePermissions);
}
diff --git a/api/README.md b/api/README.md
new file mode 100644
index 0000000000..7bce0e890d
--- /dev/null
+++ b/api/README.md
@@ -0,0 +1,15 @@
+# api
+
+Definition of Dependency-Track's REST API, in [OpenAPI v3.0] format.
+
+The API draws inspiration from [Zalando's RESTful API Guidelines].
+
+Conformance to API guidelines is enforced with [spectral] in CI.
+Validation may be performed locally using [`openapi-lint.sh`](../dev/scripts/openapi-lint.sh).
+
+Interfaces and model classes are generated as part of the build using [openapi-generator].
+
+[OpenAPI v3.0]: https://spec.openapis.org/oas/v3.0.3.html
+[Zalando's RESTful API Guidelines]: https://opensource.zalando.com/restful-api-guidelines/
+[openapi-generator]: https://github.com/OpenAPITools/openapi-generator
+[spectral]: https://github.com/stoplightio/spectral
\ No newline at end of file
diff --git a/api/pom.xml b/api/pom.xml
new file mode 100644
index 0000000000..15e172b66f
--- /dev/null
+++ b/api/pom.xml
@@ -0,0 +1,113 @@
+
+
+
+ 4.0.0
+
+ org.dependencytrack
+ dependency-track-parent
+ 5.6.0-SNAPSHOT
+
+
+ api
+ jar
+
+
+ ${project.basedir}/..
+ true
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ provided
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ provided
+
+
+
+ jakarta.annotation
+ jakarta.annotation-api
+ provided
+
+
+ jakarta.validation
+ jakarta.validation-api
+ provided
+
+
+ jakarta.ws.rs
+ jakarta.ws.rs-api
+ provided
+
+
+
+
+
+
+ org.openapitools
+ openapi-generator-maven-plugin
+ 7.13.0
+
+
+ generate-api-v2
+ generate-sources
+
+ generate
+
+
+ true
+ ${basedir}/src/main/openapi/openapi.yaml
+ ${project.build.directory}/classes/org/dependencytrack/api/v2/openapi
+ jaxrs-spec
+ false
+ false
+ false
+ true
+ REF_AS_PARENT_IN_ALLOF=true
+
+ org.dependencytrack.api.v2
+ org.dependencytrack.api.v2.model
+ true
+ true
+ true
+ true
+ java8
+ false
+ true
+ false
+ .
+
+ @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api/src/main/openapi/components/parameters/page-token.yaml b/api/src/main/openapi/components/parameters/page-token.yaml
new file mode 100644
index 0000000000..4082e81a02
--- /dev/null
+++ b/api/src/main/openapi/components/parameters/page-token.yaml
@@ -0,0 +1,21 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+name: page_token
+description: Opaque token pointing to a specific position in a collection
+in: query
+schema:
+ type: string
\ No newline at end of file
diff --git a/api/src/main/openapi/components/parameters/pagination-limit.yaml b/api/src/main/openapi/components/parameters/pagination-limit.yaml
new file mode 100644
index 0000000000..f0ee13e1f2
--- /dev/null
+++ b/api/src/main/openapi/components/parameters/pagination-limit.yaml
@@ -0,0 +1,25 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+name: limit
+description: Maximum number of items to retrieve from the collection
+in: query
+schema:
+ type: integer
+ format: int32
+ minimum: 1
+ maximum: 1000
+ default: 100
\ No newline at end of file
diff --git a/api/src/main/openapi/components/responses/generic-conflict-error.yaml b/api/src/main/openapi/components/responses/generic-conflict-error.yaml
new file mode 100644
index 0000000000..311467633e
--- /dev/null
+++ b/api/src/main/openapi/components/responses/generic-conflict-error.yaml
@@ -0,0 +1,26 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+description: Forbidden
+content:
+ application/problem+json:
+ schema:
+ $ref: "../schemas/problem-details.yaml"
+ example:
+ type: about:blank
+ status: 409
+ title: Conflict
+ detail: The resource already exists.
\ No newline at end of file
diff --git a/api/src/main/openapi/components/responses/generic-error.yaml b/api/src/main/openapi/components/responses/generic-error.yaml
new file mode 100644
index 0000000000..6795cdb471
--- /dev/null
+++ b/api/src/main/openapi/components/responses/generic-error.yaml
@@ -0,0 +1,21 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+description: Unexpected error
+content:
+ application/problem+json:
+ schema:
+ $ref: "../schemas/problem-details.yaml"
\ No newline at end of file
diff --git a/api/src/main/openapi/components/responses/generic-forbidden-error.yaml b/api/src/main/openapi/components/responses/generic-forbidden-error.yaml
new file mode 100644
index 0000000000..575f662105
--- /dev/null
+++ b/api/src/main/openapi/components/responses/generic-forbidden-error.yaml
@@ -0,0 +1,26 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+description: Forbidden
+content:
+ application/problem+json:
+ schema:
+ $ref: "../schemas/problem-details.yaml"
+ example:
+ type: about:blank
+ status: 403
+ title: Forbidden
+ detail: Not permitted to access the requested resource.
\ No newline at end of file
diff --git a/api/src/main/openapi/components/responses/generic-not-found-error.yaml b/api/src/main/openapi/components/responses/generic-not-found-error.yaml
new file mode 100644
index 0000000000..867797036e
--- /dev/null
+++ b/api/src/main/openapi/components/responses/generic-not-found-error.yaml
@@ -0,0 +1,26 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+description: Not found
+content:
+ application/problem+json:
+ schema:
+ $ref: "../schemas/problem-details.yaml"
+ example:
+ type: about:blank
+ status: 404
+ title: Not Found
+ detail: The requested resource could not be found.
\ No newline at end of file
diff --git a/api/src/main/openapi/components/responses/generic-unauthorized-error.yaml b/api/src/main/openapi/components/responses/generic-unauthorized-error.yaml
new file mode 100644
index 0000000000..d3d041abe6
--- /dev/null
+++ b/api/src/main/openapi/components/responses/generic-unauthorized-error.yaml
@@ -0,0 +1,26 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+description: Unauthorized
+content:
+ application/problem+json:
+ schema:
+ $ref: "../schemas/problem-details.yaml"
+ example:
+ type: about:blank
+ status: 401
+ title: Unauthorized
+ detail: Not authorized to access the requested resource.
\ No newline at end of file
diff --git a/api/src/main/openapi/components/responses/invalid-request-error.yaml b/api/src/main/openapi/components/responses/invalid-request-error.yaml
new file mode 100644
index 0000000000..252b4f40e4
--- /dev/null
+++ b/api/src/main/openapi/components/responses/invalid-request-error.yaml
@@ -0,0 +1,30 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+description: Bad request
+content:
+ application/problem+json:
+ schema:
+ $ref: "../schemas/invalid-request-problem-details.yaml"
+ example:
+ type: about:blank
+ status: 400
+ title: Bad Request
+ detail: The request could not be processed because it failed validation.
+ errors:
+ - path: foo.bar
+ value: baz
+ message: Must be a number
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/constraint-violation-error.yaml b/api/src/main/openapi/components/schemas/constraint-violation-error.yaml
new file mode 100644
index 0000000000..b6e4932240
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/constraint-violation-error.yaml
@@ -0,0 +1,29 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ path:
+ type: string
+ description: Path to the invalid field in the request
+ value:
+ type: string
+ description: The invalid value
+ message:
+ type: string
+ description: Message explaining the error
+required:
+- message
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/create-team-membership-request.yaml b/api/src/main/openapi/components/schemas/create-team-membership-request.yaml
new file mode 100644
index 0000000000..36fa40c478
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/create-team-membership-request.yaml
@@ -0,0 +1,29 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ team_name:
+ type: string
+ description: Name of the team
+ maxLength: 255
+ username:
+ type: string
+ description: Name of the user
+ maxLength: 255
+required:
+- team_name
+- username
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/create-team-request.yaml b/api/src/main/openapi/components/schemas/create-team-request.yaml
new file mode 100644
index 0000000000..8d2708fda2
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/create-team-request.yaml
@@ -0,0 +1,31 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ name:
+ type: string
+ description: Name of the team to create
+ maxLength: 255
+ permissions:
+ type: array
+ items:
+ type: string
+ maxLength: 255
+ uniqueItems: true
+ description: Permissions to assign to the team
+required:
+- name
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/get-team-response.yaml b/api/src/main/openapi/components/schemas/get-team-response.yaml
new file mode 100644
index 0000000000..ce421d6bd3
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/get-team-response.yaml
@@ -0,0 +1,31 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ name:
+ type: string
+ description: Name of the team
+ maxLength: 255
+ permissions:
+ type: array
+ items:
+ type: string
+ maxLength: 255
+ description: Permissions assigned to the team
+required:
+- name
+- permissions
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/invalid-request-problem-details.yaml b/api/src/main/openapi/components/schemas/invalid-request-problem-details.yaml
new file mode 100644
index 0000000000..0d7fa15e46
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/invalid-request-problem-details.yaml
@@ -0,0 +1,26 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+allOf:
+- $ref: "./problem-details.yaml"
+properties:
+ errors:
+ type: array
+ items:
+ $ref: "./constraint-violation-error.yaml"
+required:
+- errors
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/list-team-memberships-response-item.yaml b/api/src/main/openapi/components/schemas/list-team-memberships-response-item.yaml
new file mode 100644
index 0000000000..36fa40c478
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/list-team-memberships-response-item.yaml
@@ -0,0 +1,29 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ team_name:
+ type: string
+ description: Name of the team
+ maxLength: 255
+ username:
+ type: string
+ description: Name of the user
+ maxLength: 255
+required:
+- team_name
+- username
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/list-team-memberships-response.yaml b/api/src/main/openapi/components/schemas/list-team-memberships-response.yaml
new file mode 100644
index 0000000000..389ea33eff
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/list-team-memberships-response.yaml
@@ -0,0 +1,27 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+allOf:
+- $ref: "./paginated-response.yaml"
+properties:
+ memberships:
+ type: array
+ items:
+ $ref: "./list-team-memberships-response-item.yaml"
+required:
+- _pagination
+- memberships
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/list-teams-response-item.yaml b/api/src/main/openapi/components/schemas/list-teams-response-item.yaml
new file mode 100644
index 0000000000..793c020e8d
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/list-teams-response-item.yaml
@@ -0,0 +1,35 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ name:
+ type: string
+ description: Name of the team
+ maxLength: 255
+ api_keys:
+ type: integer
+ format: int32
+ minimum: 0
+ description: Number of API keys assigned to this team
+ members:
+ type: integer
+ format: int32
+ minimum: 0
+ description: Number of users that are member of this team
+required:
+- name
+- members
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/list-teams-response.yaml b/api/src/main/openapi/components/schemas/list-teams-response.yaml
new file mode 100644
index 0000000000..ac96641443
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/list-teams-response.yaml
@@ -0,0 +1,27 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+allOf:
+- $ref: "./paginated-response.yaml"
+properties:
+ teams:
+ type: array
+ items:
+ $ref: "./list-teams-response-item.yaml"
+required:
+- _pagination
+- teams
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response-item.yaml b/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response-item.yaml
new file mode 100644
index 0000000000..c435044d2d
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response-item.yaml
@@ -0,0 +1,34 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ year:
+ type: integer
+ format: int32
+ month:
+ type: integer
+ format: int32
+ count:
+ type: integer
+ format: int32
+ observed_at:
+ $ref: "./timestamp.yaml"
+required:
+- count
+- month
+- observed_at
+- year
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response.yaml b/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response.yaml
new file mode 100644
index 0000000000..f33b0be994
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response.yaml
@@ -0,0 +1,27 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+allOf:
+ - $ref: "./paginated-response.yaml"
+properties:
+ metrics:
+ type: array
+ items:
+ $ref: "./list-vulnerability-metrics-response-item.yaml"
+required:
+ - _pagination
+ - metrics
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/list-workflow-states-response-item.yaml b/api/src/main/openapi/components/schemas/list-workflow-states-response-item.yaml
new file mode 100644
index 0000000000..e61be261d2
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/list-workflow-states-response-item.yaml
@@ -0,0 +1,49 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ parent:
+ $ref: './list-workflow-states-response-item.yaml'
+ step:
+ type: string
+ enum:
+ - BOM_CONSUMPTION
+ - BOM_PROCESSING
+ - VULN_ANALYSIS
+ - REPO_META_ANALYSIS
+ - POLICY_EVALUATION
+ - METRICS_UPDATE
+ - POLICY_BUNDLE_SYNC
+ - PROJECT_CLONE
+ status:
+ type: string
+ enum:
+ - PENDING
+ - TIMED_OUT
+ - COMPLETED
+ - FAILED
+ - CANCELLED
+ - NOT_APPLICABLE
+ failure_reason:
+ type: string
+ token:
+ type: string
+ format: uuid
+ started_at:
+ $ref: "./timestamp.yaml"
+ updated_at:
+ $ref: "./timestamp.yaml"
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/list-workflow-states-response.yaml b/api/src/main/openapi/components/schemas/list-workflow-states-response.yaml
new file mode 100644
index 0000000000..ae57ddd26b
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/list-workflow-states-response.yaml
@@ -0,0 +1,22 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ states:
+ type: array
+ items:
+ $ref: "./list-workflow-states-response-item.yaml"
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/paginated-response.yaml b/api/src/main/openapi/components/schemas/paginated-response.yaml
new file mode 100644
index 0000000000..6675b43fa1
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/paginated-response.yaml
@@ -0,0 +1,45 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ _pagination:
+ title: Pagination Metadata
+ description: Metadata of paginated responses
+ type: object
+ properties:
+ links:
+ title: Pagination Links
+ description: Links to navigate through the collection
+ type: object
+ properties:
+ self:
+ type: string
+ format: uri
+ description: Link to the current page of the collection
+ next:
+ type: string
+ format: uri
+ description: >-
+ Link to the next page of the collection.
+ If not present, no more items exist.
+ required:
+ - self
+ required:
+ - links
+# Enable inheritance for schemas that extend this object via allOf.
+# https://github.com/OpenAPITools/openapi-generator/pull/14172
+x-parent: true
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/portfolio-metrics-response.yaml b/api/src/main/openapi/components/schemas/portfolio-metrics-response.yaml
new file mode 100644
index 0000000000..f938ec465f
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/portfolio-metrics-response.yaml
@@ -0,0 +1,142 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+properties:
+ critical:
+ type: integer
+ format: int32
+ high:
+ type: integer
+ format: int32
+ medium:
+ type: integer
+ format: int32
+ low:
+ type: integer
+ format: int32
+ unassigned:
+ type: integer
+ format: int32
+ vulnerabilities:
+ type: integer
+ format: int32
+ projects:
+ type: integer
+ format: int32
+ vulnerable_projects:
+ type: integer
+ format: int32
+ components:
+ type: integer
+ format: int32
+ vulnerable_components:
+ type: integer
+ format: int32
+ suppressed:
+ type: integer
+ format: int32
+ findings_total:
+ type: integer
+ format: int32
+ findings_audited:
+ type: integer
+ format: int32
+ findings_unaudited:
+ type: integer
+ format: int32
+ inherited_risk_score:
+ type: number
+ format: double
+ policy_violations_fail:
+ type: integer
+ format: int32
+ policy_violations_warn:
+ type: integer
+ format: int32
+ policy_violations_info:
+ type: integer
+ format: int32
+ policy_violations_total:
+ type: integer
+ format: int32
+ policy_violations_audited:
+ type: integer
+ format: int32
+ policy_violations_unaudited:
+ type: integer
+ format: int32
+ policy_violations_security_total:
+ type: integer
+ format: int32
+ policy_violations_security_audited:
+ type: integer
+ format: int32
+ policy_violations_security_unaudited:
+ type: integer
+ format: int32
+ policy_violations_license_total:
+ type: integer
+ format: int32
+ policy_violations_license_audited:
+ type: integer
+ format: int32
+ policy_violations_license_unaudited:
+ type: integer
+ format: int32
+ policy_violations_operational_total:
+ type: integer
+ format: int32
+ policy_violations_operational_audited:
+ type: integer
+ format: int32
+ policy_violations_operational_unaudited:
+ type: integer
+ format: int32
+ observed_at:
+ $ref: "./timestamp.yaml"
+required:
+- components
+- critical
+- findings_audited
+- findings_total
+- findings_unaudited
+- high
+- inherited_risk_score
+- low
+- medium
+- observed_at
+- policy_violations_audited
+- policy_violations_fail
+- policy_violations_info
+- policy_violations_license_audited
+- policy_violations_license_total
+- policy_violations_license_unaudited
+- policy_violations_operational_audited
+- policy_violations_operational_total
+- policy_violations_operational_unaudited
+- policy_violations_security_audited
+- policy_violations_security_total
+- policy_violations_security_unaudited
+- policy_violations_total
+- policy_violations_unaudited
+- policy_violations_warn
+- projects
+- suppressed
+- unassigned
+- vulnerabilities
+- vulnerable_components
+- vulnerable_projects
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/problem-details.yaml b/api/src/main/openapi/components/schemas/problem-details.yaml
new file mode 100644
index 0000000000..15c408e315
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/problem-details.yaml
@@ -0,0 +1,51 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: object
+description: An RFC 9457 problem object.
+externalDocs:
+ url: https://www.rfc-editor.org/rfc/rfc9457.html
+properties:
+ type:
+ type: string
+ format: uri
+ default: about:blank
+ description: A URI reference that identifies the problem type
+ status:
+ type: integer
+ format: int32
+ minimum: 400
+ maximum: 599
+ example: 500
+ description: HTTP status code generated by the origin server for this occurrence of the problem
+ title:
+ type: string
+ description: Short, human-readable summary of the problem type
+ maxLength: 255
+ detail:
+ type: string
+ description: Human-readable explanation specific to this occurrence of the problem
+ maxLength: 1024
+ instance:
+ type: string
+ format: uri
+ description: Reference URI that identifies the specific occurrence of the problem
+required:
+- title
+- detail
+# Enable inheritance for schemas that extend this object via allOf.
+# https://github.com/OpenAPITools/openapi-generator/pull/14172
+x-parent: true
\ No newline at end of file
diff --git a/api/src/main/openapi/components/schemas/timestamp.yaml b/api/src/main/openapi/components/schemas/timestamp.yaml
new file mode 100644
index 0000000000..6e0cec4047
--- /dev/null
+++ b/api/src/main/openapi/components/schemas/timestamp.yaml
@@ -0,0 +1,20 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+type: integer
+format: int64
+description: Epoch timestamp in milliseconds since January 1, 1970 UTC.
+example: 1752209050377
\ No newline at end of file
diff --git a/api/src/main/openapi/openapi.yaml b/api/src/main/openapi/openapi.yaml
new file mode 100644
index 0000000000..76a05264c3
--- /dev/null
+++ b/api/src/main/openapi/openapi.yaml
@@ -0,0 +1,66 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+openapi: 3.0.3
+info:
+ title: OWASP Dependency-Track
+ description: REST API of OWASP Dependency-Track
+ version: 2.0.0
+ contact:
+ name: The Dependency-Track Authors
+ email: dependencytrack@owasp.org
+ url: https://github.com/DependencyTrack/dependency-track
+ license:
+ name: Apache-2.0
+ url: https://www.apache.org/licenses/LICENSE-2.0.html
+security:
+- apiKeyAuth: [ ]
+- bearerAuth: [ ]
+servers:
+- url: /api/v2
+tags:
+- name: Metrics
+ description: Endpoints related to metrics
+- name: Teams
+ description: Endpoints related to teams
+- name: Workflows
+ description: Endpoints related to workflows
+
+paths:
+ /metrics/portfolio/current:
+ $ref: "./paths/metrics_portfolio_current.yaml"
+ /metrics/vulnerabilities:
+ $ref: "./paths/metrics_vulnerabilities.yaml"
+ /teams:
+ $ref: "./paths/teams.yaml"
+ /teams/{name}:
+ $ref: "./paths/teams__name_.yaml"
+ /team-memberships:
+ $ref: "./paths/team-memberships.yaml"
+ /workflows/{token}:
+ $ref: "./paths/workflows__token__.yaml"
+
+components:
+ securitySchemes:
+ apiKeyAuth:
+ name: X-Api-Key
+ description: Authentication via API key
+ type: apiKey
+ in: header
+ bearerAuth:
+ description: Authentication via Bearer token
+ type: http
+ scheme: Bearer
diff --git a/api/src/main/openapi/paths/metrics_portfolio_current.yaml b/api/src/main/openapi/paths/metrics_portfolio_current.yaml
new file mode 100644
index 0000000000..088642a4a2
--- /dev/null
+++ b/api/src/main/openapi/paths/metrics_portfolio_current.yaml
@@ -0,0 +1,35 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+get:
+ operationId: getPortfolioCurrentMetrics
+ summary: Returns current metrics for the entire portfolio.
+ description: Requires permission VIEW_PORTFOLIO
+ tags:
+ - Metrics
+ responses:
+ "200":
+ description: Current metrics for the entire portfolio
+ content:
+ application/json:
+ schema:
+ $ref: "../components/schemas/portfolio-metrics-response.yaml"
+ "401":
+ $ref: "../components/responses/generic-unauthorized-error.yaml"
+ "403":
+ $ref: "../components/responses/generic-forbidden-error.yaml"
+ default:
+ $ref: "../components/responses/generic-error.yaml"
\ No newline at end of file
diff --git a/api/src/main/openapi/paths/metrics_vulnerabilities.yaml b/api/src/main/openapi/paths/metrics_vulnerabilities.yaml
new file mode 100644
index 0000000000..a270bd02c5
--- /dev/null
+++ b/api/src/main/openapi/paths/metrics_vulnerabilities.yaml
@@ -0,0 +1,38 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+get:
+ operationId: getVulnerabilityMetrics
+ summary: Returns the sum of all vulnerabilities in the database by year and month.
+ description: Requires permission VIEW_PORTFOLIO
+ tags:
+ - Metrics
+ parameters:
+ - $ref: "../components/parameters/pagination-limit.yaml"
+ - $ref: "../components/parameters/page-token.yaml"
+ responses:
+ "200":
+ description: The sum of all vulnerabilities in the database by year and month
+ content:
+ application/json:
+ schema:
+ $ref: "../components/schemas/list-vulnerability-metrics-response.yaml"
+ "401":
+ $ref: "../components/responses/generic-unauthorized-error.yaml"
+ "403":
+ $ref: "../components/responses/generic-forbidden-error.yaml"
+ default:
+ $ref: "../components/responses/generic-error.yaml"
\ No newline at end of file
diff --git a/api/src/main/openapi/paths/team-memberships.yaml b/api/src/main/openapi/paths/team-memberships.yaml
new file mode 100644
index 0000000000..25b2fbf24e
--- /dev/null
+++ b/api/src/main/openapi/paths/team-memberships.yaml
@@ -0,0 +1,123 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+get:
+ operationId: listTeamMemberships
+ summary: List all team memberships
+ description: >-
+ Returns a paginated list of team memberships,
+ sorted by team name and username in ascending order
+ tags:
+ - Teams
+ parameters:
+ - name: team
+ in: query
+ description: Name of the team to filter by. Must be an exact match.
+ schema:
+ type: string
+ maxLength: 255
+ - name: user
+ in: query
+ description: Name of the user to filter by. Must be an exact match.
+ schema:
+ type: string
+ maxLength: 255
+ - $ref: "../components/parameters/pagination-limit.yaml"
+ - $ref: "../components/parameters/page-token.yaml"
+ responses:
+ "200":
+ description: Paginated list of team memberships
+ content:
+ application/json:
+ schema:
+ $ref: "../components/schemas/list-team-memberships-response.yaml"
+ "400":
+ $ref: "../components/responses/invalid-request-error.yaml"
+ "401":
+ $ref: "../components/responses/generic-unauthorized-error.yaml"
+ "403":
+ $ref: "../components/responses/generic-forbidden-error.yaml"
+ default:
+ $ref: "../components/responses/generic-error.yaml"
+
+post:
+ operationId: createTeamMembership
+ summary: Create team membership
+ description: Create a team membership
+ tags:
+ - Teams
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../components/schemas/create-team-membership-request.yaml"
+ responses:
+ "201":
+ description: Team membership created
+ "400":
+ description: Bad Request
+ content:
+ application/problem+json:
+ schema:
+ oneOf:
+ - $ref: "../components/schemas/invalid-request-problem-details.yaml"
+ - $ref: "../components/schemas/problem-details.yaml"
+ "401":
+ $ref: "../components/responses/generic-unauthorized-error.yaml"
+ "403":
+ $ref: "../components/responses/generic-forbidden-error.yaml"
+ "404":
+ $ref: "../components/responses/generic-not-found-error.yaml"
+ "409":
+ $ref: "../components/responses/generic-conflict-error.yaml"
+ default:
+ $ref: "../components/responses/generic-error.yaml"
+
+delete:
+ operationId: deleteTeamMembership
+ summary: Delete team membership
+ description: Delete a team membership
+ tags:
+ - Teams
+ parameters:
+ - name: team
+ in: query
+ required: true
+ description: Name of the team
+ schema:
+ type: string
+ maxLength: 255
+ - name: user
+ in: query
+ required: true
+ description: Name of the user
+ schema:
+ type: string
+ maxLength: 255
+ responses:
+ "204":
+ description: Team membership deleted
+ "400":
+ $ref: "../components/responses/invalid-request-error.yaml"
+ "401":
+ $ref: "../components/responses/generic-unauthorized-error.yaml"
+ "403":
+ $ref: "../components/responses/generic-forbidden-error.yaml"
+ "404":
+ $ref: "../components/responses/generic-not-found-error.yaml"
+ default:
+ $ref: "../components/responses/generic-error.yaml"
\ No newline at end of file
diff --git a/api/src/main/openapi/paths/teams.yaml b/api/src/main/openapi/paths/teams.yaml
new file mode 100644
index 0000000000..b81ce72663
--- /dev/null
+++ b/api/src/main/openapi/paths/teams.yaml
@@ -0,0 +1,72 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+get:
+ operationId: listTeams
+ summary: List all teams
+ description: Returns a paginated list of teams, sorted by name in ascending order
+ tags:
+ - Teams
+ parameters:
+ - $ref: "../components/parameters/pagination-limit.yaml"
+ - $ref: "../components/parameters/page-token.yaml"
+ responses:
+ "200":
+ description: Paginated list of teams
+ content:
+ application/json:
+ schema:
+ $ref: "../components/schemas/list-teams-response.yaml"
+ "400":
+ $ref: "../components/responses/invalid-request-error.yaml"
+ "401":
+ $ref: "../components/responses/generic-unauthorized-error.yaml"
+ "403":
+ $ref: "../components/responses/generic-forbidden-error.yaml"
+ default:
+ $ref: "../components/responses/generic-error.yaml"
+
+post:
+ operationId: createTeam
+ summary: Create team
+ description: Create a team
+ tags:
+ - Teams
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "../components/schemas/create-team-request.yaml"
+ responses:
+ "201":
+ description: Team Created
+ "400":
+ description: Bad Request
+ content:
+ application/problem+json:
+ schema:
+ oneOf:
+ - $ref: "../components/schemas/invalid-request-problem-details.yaml"
+ - $ref: "../components/schemas/problem-details.yaml"
+ "401":
+ $ref: "../components/responses/generic-unauthorized-error.yaml"
+ "403":
+ $ref: "../components/responses/generic-forbidden-error.yaml"
+ "409":
+ $ref: "../components/responses/generic-conflict-error.yaml"
+ default:
+ $ref: "../components/responses/generic-error.yaml"
\ No newline at end of file
diff --git a/api/src/main/openapi/paths/teams__name_.yaml b/api/src/main/openapi/paths/teams__name_.yaml
new file mode 100644
index 0000000000..5a3386cbcf
--- /dev/null
+++ b/api/src/main/openapi/paths/teams__name_.yaml
@@ -0,0 +1,73 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+get:
+ operationId: getTeam
+ summary: Get a team
+ description: Returns detailed information about a given team
+ tags:
+ - Teams
+ parameters:
+ - name: name
+ description: Name of the team
+ in: path
+ required: true
+ schema:
+ type: string
+ maxLength: 255
+ responses:
+ "200":
+ description: Team details
+ content:
+ application/json:
+ schema:
+ $ref: "../components/schemas/get-team-response.yaml"
+ "401":
+ $ref: "../components/responses/generic-unauthorized-error.yaml"
+ "403":
+ $ref: "../components/responses/generic-forbidden-error.yaml"
+ "404":
+ $ref: "../components/responses/generic-not-found-error.yaml"
+ default:
+ $ref: "../components/responses/generic-error.yaml"
+
+delete:
+ operationId: deleteTeam
+ summary: Delete team
+ description: Delete a team
+ tags:
+ - Teams
+ parameters:
+ - name: name
+ description: Name of the team
+ in: path
+ required: true
+ schema:
+ type: string
+ maxLength: 255
+ responses:
+ "204":
+ description: Team Deleted
+ "400":
+ $ref: "../components/responses/invalid-request-error.yaml"
+ "401":
+ $ref: "../components/responses/generic-unauthorized-error.yaml"
+ "403":
+ $ref: "../components/responses/generic-forbidden-error.yaml"
+ "404":
+ $ref: "../components/responses/generic-not-found-error.yaml"
+ default:
+ $ref: "../components/responses/generic-error.yaml"
\ No newline at end of file
diff --git a/api/src/main/openapi/paths/workflows__token__.yaml b/api/src/main/openapi/paths/workflows__token__.yaml
new file mode 100644
index 0000000000..a852f8c9a9
--- /dev/null
+++ b/api/src/main/openapi/paths/workflows__token__.yaml
@@ -0,0 +1,45 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+get:
+ operationId: getWorkflowStates
+ summary: Retrieves workflow states associated with the token received from bom upload.
+ description: Requires permission BOM_UPLOAD
+ tags:
+ - Workflows
+ parameters:
+ - name: token
+ in: path
+ description: The token to query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: A list of workflow states
+ content:
+ application/json:
+ schema:
+ $ref: "../components/schemas/list-workflow-states-response.yaml"
+ "401":
+ $ref: "../components/responses/generic-unauthorized-error.yaml"
+ "403":
+ $ref: "../components/responses/generic-forbidden-error.yaml"
+ "404":
+ $ref: "../components/responses/generic-not-found-error.yaml"
+ default:
+ $ref: "../components/responses/generic-error.yaml"
\ No newline at end of file
diff --git a/api/src/main/spectral/functions/is-object-schema.js b/api/src/main/spectral/functions/is-object-schema.js
new file mode 100644
index 0000000000..5d39a67d4c
--- /dev/null
+++ b/api/src/main/spectral/functions/is-object-schema.js
@@ -0,0 +1,49 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+'use strict';
+
+const assertObjectSchema = (schema) => {
+ if (schema.type !== 'object') {
+ throw 'Schema type is not `object`';
+ }
+ if (schema.additionalProperties) {
+ throw 'Schema is a map';
+ }
+};
+
+const check = (schema) => {
+ const combinedSchemas = [...(schema.anyOf || []), ...(schema.oneOf || []), ...(schema.allOf || [])];
+ if (combinedSchemas.length > 0) {
+ combinedSchemas.forEach(check);
+ } else {
+ assertObjectSchema(schema);
+ }
+};
+
+export default (targetValue) => {
+ try {
+ check(targetValue);
+ } catch (ex) {
+ return [
+ {
+ message: ex,
+ },
+ ];
+ }
+};
\ No newline at end of file
diff --git a/api/src/main/spectral/functions/is-problem-json-schema.js b/api/src/main/spectral/functions/is-problem-json-schema.js
new file mode 100644
index 0000000000..9e39d4e928
--- /dev/null
+++ b/api/src/main/spectral/functions/is-problem-json-schema.js
@@ -0,0 +1,110 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+'use strict';
+
+/*
+Minimal required problem json schema:
+
+type: object
+properties:
+ type:
+ type: string
+ format: uri
+ title:
+ type: string
+ status:
+ type: integer
+ format: int32
+ detail:
+ type: string
+ instance:
+ type: string
+*/
+
+const assertProblemSchema = (schema) => {
+ if (schema.type !== 'object') {
+ throw "Problem json must have type 'object'";
+ }
+ const type = (schema.properties || {}).type || {};
+ if (type.type !== 'string' || type.format !== 'uri') {
+ throw "Problem json must have property 'type' with type 'string' and format 'uri'";
+ }
+ const title = (schema.properties || {}).title || {};
+ if (title.type !== 'string') {
+ throw "Problem json must have property 'title' with type 'string'";
+ }
+ const status = (schema.properties || {}).status || {};
+ if (status.type !== 'integer' || status.format !== 'int32') {
+ throw "Problem json must have property 'status' with type 'integer' and format 'int32'";
+ }
+ const detail = (schema.properties || {}).detail || {};
+ if (detail.type !== 'string') {
+ throw "Problem json must have property 'detail' with type 'string'";
+ }
+ const instance = (schema.properties || {}).instance || {};
+ if (instance.type !== 'string') {
+ throw "Problem json must have property 'instance' with type 'string'";
+ }
+};
+
+/*
+ * Merge list of schema definitions of type = 'object'.
+ * Return object will have a super set of attributes 'properties' and 'required'.
+ */
+const mergeObjectDefinitions = (allOfTypes) => {
+ if (allOfTypes.filter((item) => item.type !== 'object').length !== 0) {
+ throw "All schema definitions must be of type 'object'";
+ }
+
+ return allOfTypes.reduce((acc, item) => {
+ return {
+ type: 'object',
+ properties: { ...(acc.properties || {}), ...(item.properties || {}) },
+ required: [...(acc.required || []), ...(item.required || [])],
+ };
+ }, {});
+};
+
+const check = (schema) => {
+ const combinedSchemas = [...(schema.anyOf || []), ...(schema.oneOf || [])];
+ if (schema.allOf) {
+ const mergedAllOf = mergeObjectDefinitions(schema.allOf);
+ if (mergedAllOf) {
+ combinedSchemas.push(mergedAllOf);
+ }
+ }
+
+ if (combinedSchemas.length > 0) {
+ combinedSchemas.forEach(check);
+ } else {
+ assertProblemSchema(schema);
+ }
+};
+
+export default (targetValue) => {
+ try {
+ check(targetValue);
+ } catch (ex) {
+ return [
+ {
+ message: ex,
+ },
+ ];
+ }
+};
\ No newline at end of file
diff --git a/api/src/main/spectral/ruleset.yaml b/api/src/main/spectral/ruleset.yaml
new file mode 100644
index 0000000000..a440830ee8
--- /dev/null
+++ b/api/src/main/spectral/ruleset.yaml
@@ -0,0 +1,21 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+extends:
+- ["spectral:oas", "all"]
+- ["./zalando.yaml", "all"]
+formats:
+- "oas3"
\ No newline at end of file
diff --git a/api/src/main/spectral/zalando.yaml b/api/src/main/spectral/zalando.yaml
new file mode 100644
index 0000000000..876159b3aa
--- /dev/null
+++ b/api/src/main/spectral/zalando.yaml
@@ -0,0 +1,215 @@
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+
+# Rules to assert conformance to a curated subset of Zalando's
+# RESTful API guidelines: https://opensource.zalando.com/restful-api-guidelines/#
+#
+# Credit to the folks at baloise for providing these spectral rules:
+# https://github.com/baloise-incubator/spectral-ruleset/blob/main/zalando.yml
+functions:
+- is-object-schema
+- is-problem-json-schema
+
+rules:
+ must-always-return-json-objects-as-top-level-data-structures:
+ message: 'Top-level data structure must be an object'
+ description: MUST always return JSON objects as top-level data structures [110]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#110
+ severity: error
+ given: "$.paths.*.*[responses,requestBody]..content[?(@property.match(/^application\\/([a-zA-Z0-9._-]+\\+)?json(;.*)?$/))]..schema"
+ then:
+ function: is-object-schema
+
+ must-use-semantic-versioning:
+ message: '{{error}}'
+ description: MUST use semantic versioning [116]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#116
+ severity: error
+ given: $.info.version
+ then:
+ function: schema
+ functionOptions:
+ schema:
+ type: string
+ pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$'
+
+ must-use-snake-case-for-property-names:
+ message: Property name has to be ASCII snake_case
+ description: MUST property names must be ASCII snake_case [118]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#118
+ severity: error
+ given: $.paths.*.*[responses,requestBody]..content..schema..properties.*~
+ then:
+ function: pattern
+ functionOptions:
+ match: ^[a-z_][a-z_0-9]*$
+
+ must-use-lowercase-with-hypens-for-path-segements:
+ message: Path segments have to be lowercase separate words with hyphens
+ description: MUST use lowercase separate words with hyphens for path segments [129]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#129
+ severity: error
+ given: $.paths.*~
+ then:
+ function: pattern
+ functionOptions:
+ match: ^(?=((([\/a-z][a-z0-9\-\/]*)?({[^}]*})?)+))\1$
+
+ must-use-snake-case-for-query-parameters:
+ message: Query parameters must be snake_case
+ description: MUST use snake_case (never camelCase) for query parameters [130]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#130
+ severity: error
+ given: $.paths.*.*.parameters[?(@ && @.in=='query')].name
+ then:
+ function: pattern
+ functionOptions:
+ match: ^[a-z][_a-z0-9]*$
+
+ must-use-normalized-paths-without-empty-path-segments:
+ message: Empty path segments are not allowed
+ description: MUST use normalized paths without empty path segments and trailing slashes [136]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#136
+ severity: error
+ given: $.paths.*~
+ then:
+ function: pattern
+ functionOptions:
+ notMatch: //
+
+ must-use-normalized-paths-without-trailing-slash:
+ message: Path with trailing slash is not allowed
+ description: MUST use normalized paths without empty path segments and trailing slashes [136]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#136
+ severity: error
+ given: $.paths.*~
+ then:
+ function: pattern
+ functionOptions:
+ notMatch: /$
+
+ must-specify-default-response:
+ message: Operation does not contain a default response
+ description: MUST specify success and error responses [151]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#151
+ severity: error
+ given: $.paths.*.*.responses
+ then:
+ field: default
+ function: truthy
+
+ must-use-standard-formats-for-date-and-time-properties-example:
+ message: "You should provide an example for {{property}}"
+ description: MUST use standard formats for date and time properties [169]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#169
+ severity: warn # Not an error as you only should provide an example to help your consumers
+ given: $.paths..[?(@.type === 'string' && (@.format === 'date-time' || @.format === 'date' || @.format === 'time' || @.format === 'duration' || @.format === 'period'))]
+ then:
+ field: example
+ function: truthy
+
+ must-use-standard-formats-for-date-and-time-properties-utc:
+ message: "You should UTC for {{property}}"
+ description: MUST use standard formats for date and time properties [169]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#169
+ severity: warn # Not an error as you only should provide an example to help your consumers
+ given: $.paths..[?(@.type === 'string' && @.format === 'date-time')]
+ then:
+ field: example
+ function: pattern
+ functionOptions:
+ match: "Z$"
+
+ must-use-problem-json-as-default-response:
+ message: Operation must use problem json as default response
+ description: MUST specify success and error responses [151]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#151
+ severity: error
+ given: $.paths.*.*.responses.default
+ then:
+ field: content.application/problem+json
+ function: truthy
+
+ must-define-a-format-for-number-types:
+ message: Numeric properties must have valid format specified
+ description: MUST define a format for number and integer types [171]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#171
+ severity: error
+ given: $.paths.*.*..schema..properties..[?(@ && @.type=='number')]
+ then:
+ - field: format
+ function: defined
+ - field: format
+ function: pattern
+ functionOptions:
+ match: ^(float|double|decimal)$
+
+ must-define-a-format-for-integer-types:
+ message: Numeric properties must have valid format specified
+ description: MUST define a format for number and integer types [171]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#171
+ severity: error
+ given: $.paths.*.*..schema..properties..[?(@ && @.type=='integer')]
+ then:
+ - field: format
+ function: defined
+ - field: format
+ function: pattern
+ functionOptions:
+ match: ^(int32|int64|bigint)$
+
+ should-prefer-standard-media-type-names:
+ message: Custom media types should only be used for versioning
+ description: SHOULD prefer standard media type name application/json [172]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#172
+ severity: warn
+ given: $.paths.*.*.responses.*.content.*~
+ then:
+ function: pattern
+ functionOptions:
+ match: ^application\/(problem\+)?json$|^[a-zA-Z0-9_]+\/[-+.a-zA-Z0-9_]+;(v|version)=[0-9]+$
+
+ must-use-problem-json-for-errors:
+ message: Error response must be application/problem+json
+ description: MUST support problem JSON [176]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#176
+ severity: error
+ given: $.paths.*.*.responses[?(@ && @property.match(/^(4|5)/))]
+ then:
+ field: content.application/problem+json
+ function: truthy
+
+ must-use-valid-problem-json-schema:
+ message: '{{error}}'
+ description: MUST support problem JSON [176]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#176
+ severity: error
+ given: $.paths.*.*.responses.*.content.application/problem+json
+ then:
+ field: schema
+ function: is-problem-json-schema
+
+ should-declare-enum-values-using-upper-snake-case-format:
+ message: 'Enum values should be in UPPER_SNAKE_CASE format'
+ description: SHOULD declare enum values using UPPER_SNAKE_CASE format [240]
+ documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#240
+ severity: warn
+ given: $.paths..[?(@ && @.type=='string')].[enum,x-extensible-enum].*
+ then:
+ function: pattern
+ functionOptions:
+ match: ^[A-Z][A-Z_0-9]*$
\ No newline at end of file
diff --git a/apiserver/pom.xml b/apiserver/pom.xml
index 193e50b849..975b981768 100644
--- a/apiserver/pom.xml
+++ b/apiserver/pom.xml
@@ -73,6 +73,10 @@
+
+ org.dependencytrack
+ api
+ org.dependencytrackdatanucleus-plugin
diff --git a/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsApplicationEventListener.java b/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsApplicationEventListener.java
index b580191d0a..cc8fa39014 100644
--- a/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsApplicationEventListener.java
+++ b/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsApplicationEventListener.java
@@ -23,12 +23,9 @@
import org.glassfish.jersey.micrometer.server.DefaultJerseyTagsProvider;
import org.glassfish.jersey.micrometer.server.MetricsApplicationEventListener;
-import jakarta.ws.rs.ext.Provider;
-
/**
* @since 5.5.0
*/
-@Provider
public class JerseyMetricsApplicationEventListener extends MetricsApplicationEventListener {
public JerseyMetricsApplicationEventListener() {
diff --git a/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsFeature.java b/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsFeature.java
new file mode 100644
index 0000000000..4843a94c03
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsFeature.java
@@ -0,0 +1,45 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.filters;
+
+import alpine.Config;
+
+import jakarta.ws.rs.core.Feature;
+import jakarta.ws.rs.core.FeatureContext;
+import jakarta.ws.rs.ext.Provider;
+
+import static alpine.Config.AlpineKey.METRICS_ENABLED;
+
+/**
+ * @since 5.6.0
+ */
+@Provider
+public class JerseyMetricsFeature implements Feature {
+
+ @Override
+ public boolean configure(final FeatureContext context) {
+ if (Config.getInstance().getPropertyAsBoolean(METRICS_ENABLED)) {
+ context.register(JerseyMetricsApplicationEventListener.class);
+ return true;
+ }
+
+ return false;
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/MetricsDao.java b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/MetricsDao.java
index 7ca86b39e6..413a68a92a 100644
--- a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/MetricsDao.java
+++ b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/MetricsDao.java
@@ -21,6 +21,9 @@
import org.dependencytrack.model.DependencyMetrics;
import org.dependencytrack.model.PortfolioMetrics;
import org.dependencytrack.model.ProjectMetrics;
+import org.dependencytrack.persistence.pagination.Page;
+import org.jdbi.v3.core.mapper.reflect.ConstructorMapper;
+import org.jdbi.v3.core.statement.Query;
import org.jdbi.v3.sqlobject.SqlObject;
import org.jdbi.v3.sqlobject.config.RegisterBeanMapper;
import org.jdbi.v3.sqlobject.customizer.Bind;
@@ -35,11 +38,59 @@
import java.util.Collection;
import java.util.List;
+import static org.dependencytrack.persistence.pagination.PageUtil.decodePageToken;
+import static org.dependencytrack.persistence.pagination.PageUtil.encodePageToken;
+
/**
* @since 5.6.0
*/
public interface MetricsDao extends SqlObject {
+ record ListVulnerabilityMetricsPageToken(int year, int month) {
+ }
+
+ record ListVulnerabilityMetricsRow(int year, int month, int count, Instant measuredAt) {
+ }
+
+ default Page getVulnerabilityMetrics(final int limit, final String pageToken) {
+ final var decodedPageToken = decodePageToken(getHandle(), pageToken, ListVulnerabilityMetricsPageToken.class);
+
+ final Query query = getHandle().createQuery(/* language=InjectedFreeMarker */ """
+ <#-- @ftlvariable name="year" type="Boolean" -->
+ <#-- @ftlvariable name="month" type="Boolean" -->
+ SELECT *
+ FROM "VULNERABILITYMETRICS"
+ WHERE TRUE
+ <#if year && month>
+ AND ("YEAR", "MONTH") > (:year, :month)
+ #if>
+ ORDER BY "YEAR" ASC, "MONTH" ASC
+ LIMIT :limit
+ """);
+
+ final List rows = query
+ .bind("year", decodedPageToken != null
+ ? decodedPageToken.year()
+ : null)
+ .bind("month", decodedPageToken != null
+ ? decodedPageToken.month()
+ : null)
+ .bind("limit", limit + 1)
+ .defineNamedBindings()
+ .map(ConstructorMapper.of(ListVulnerabilityMetricsRow.class))
+ .list();
+
+ final List resultRows = rows.size() > 1
+ ? rows.subList(0, Math.min(rows.size(), limit))
+ : rows;
+
+ final ListVulnerabilityMetricsPageToken nextPageToken = rows.size() > limit
+ ? new ListVulnerabilityMetricsPageToken(resultRows.getLast().year, resultRows.getLast().month)
+ : null;
+
+ return new Page<>(resultRows, encodePageToken(getHandle(), nextPageToken));
+ }
+
/**
* Note that generate_series is invoked with integers rather
* than dates, because the query planner tends to overestimate
diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/TeamDao.java b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/TeamDao.java
index dced37e427..9a054557d7 100644
--- a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/TeamDao.java
+++ b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/TeamDao.java
@@ -18,10 +18,138 @@
*/
package org.dependencytrack.persistence.jdbi;
+import org.dependencytrack.persistence.pagination.Page;
+import org.jdbi.v3.core.mapper.reflect.ConstructorMapper;
+import org.jdbi.v3.core.statement.Query;
+import org.jdbi.v3.core.statement.Update;
+import org.jdbi.v3.sqlobject.SqlObject;
import org.jdbi.v3.sqlobject.customizer.Bind;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
-public interface TeamDao {
+import java.util.Collection;
+import java.util.List;
+
+import static org.dependencytrack.persistence.pagination.PageUtil.decodePageToken;
+import static org.dependencytrack.persistence.pagination.PageUtil.encodePageToken;
+
+public interface TeamDao extends SqlObject {
+
+ record ListTeamsPageToken(String lastName) {
+ }
+
+ record ListTeamsRow(String name, int apiKeys, int members) {
+ }
+
+ default Page listTeams(final int limit, final String pageToken) {
+ final var decodedPageToken = decodePageToken(getHandle(), pageToken, ListTeamsPageToken.class);
+
+ final Query query = getHandle().createQuery(/* language=InjectedFreeMarker */ """
+ <#-- @ftlvariable name="lastName" type="Boolean" -->
+ SELECT "NAME" AS name
+ , (SELECT COUNT(*) FROM "APIKEYS_TEAMS" WHERE "TEAM_ID" = "TEAM"."ID") AS api_keys
+ , (SELECT COUNT(*) FROM "USERS_TEAMS" WHERE "TEAM_ID" = "TEAM"."ID") AS members
+ FROM "TEAM"
+ WHERE TRUE
+ <#if lastName>
+ AND "NAME" > :lastName
+ #if>
+ ORDER BY "NAME"
+ LIMIT :limit
+ """);
+
+ final List rows = query
+ .bind("lastName", decodedPageToken != null
+ ? decodedPageToken.lastName()
+ : null)
+ .bind("limit", limit + 1)
+ .defineNamedBindings()
+ .map(ConstructorMapper.of(ListTeamsRow.class))
+ .list();
+
+ final List resultRows = rows.size() > 1
+ ? rows.subList(0, Math.min(rows.size(), limit))
+ : rows;
+
+ final ListTeamsPageToken nextPageToken = rows.size() > limit
+ ? new ListTeamsPageToken(resultRows.getLast().name())
+ : null;
+
+ return new Page<>(resultRows, encodePageToken(getHandle(), nextPageToken));
+ }
+
+ record ListTeamMembershipsPageToken(String lastTeamName, String lastUsername) {
+ }
+
+ record ListTeamMembershipsRow(String teamName, String username) {
+ }
+
+ default Page listTeamMembers(
+ final String teamName,
+ final String username,
+ final int limit,
+ final String pageToken) {
+ final var decodedPageToken = decodePageToken(getHandle(), pageToken, ListTeamMembershipsPageToken.class);
+
+ final Query query = getHandle().createQuery(/* language=InjectedFreeMarker */ """
+ <#-- @ftlvariable name="teamName" type="Boolean" -->
+ <#-- @ftlvariable name="username" type="Boolean" -->
+ <#-- @ftlvariable name="lastTeamName" type="Boolean" -->
+ <#-- @ftlvariable name="lastUsername" type="Boolean" -->
+ SELECT t."NAME" AS team_name
+ , u."USERNAME" AS username
+ FROM "USERS_TEAMS" AS ut
+ INNER JOIN "TEAM" AS t
+ ON t."ID" = ut."TEAM_ID"
+ INNER JOIN "USER" AS u
+ ON u."ID" = ut."USER_ID"
+ WHERE TRUE
+ <#if teamName>
+ AND t."NAME" = :teamName
+ #if>
+ <#if username>
+ AND u."USERNAME" = :username
+ #if>
+ <#if lastTeamName && lastUsername>
+ AND (t."NAME", u."USERNAME") > (:lastTeamName, :lastUsername)
+ #if>
+ ORDER BY t."NAME", u."USERNAME"
+ LIMIT :limit
+ """);
+
+ final List rows = query
+ .bind("teamName", teamName)
+ .bind("username", username)
+ .bind("lastTeamName", decodedPageToken != null
+ ? decodedPageToken.lastTeamName()
+ : null)
+ .bind("lastUsername", decodedPageToken != null
+ ? decodedPageToken.lastUsername()
+ : null)
+ .bind("limit", limit + 1)
+ .defineNamedBindings()
+ .map(ConstructorMapper.of(ListTeamMembershipsRow.class))
+ .list();
+
+ final List resultRows = rows.size() > 1
+ ? rows.subList(0, Math.min(rows.size(), limit))
+ : rows;
+
+ final ListTeamMembershipsPageToken nextPageToken = rows.size() > limit
+ ? new ListTeamMembershipsPageToken(
+ resultRows.getLast().teamName(),
+ resultRows.getLast().username())
+ : null;
+
+ return new Page<>(resultRows, encodePageToken(getHandle(), nextPageToken));
+ }
+
+ @SqlUpdate("""
+ DELETE
+ FROM "USERS_TEAMS"
+ WHERE "TEAM_ID" = (SELECT "ID" FROM "TEAM" WHERE "NAME" = :teamName)
+ AND "USER_ID" = (SELECT "ID" FROM "USER" WHERE "USERNAME" = :username)
+ """)
+ boolean deleteTeamMembership(@Bind String teamName, @Bind String username);
@SqlUpdate("""
DELETE
@@ -29,4 +157,19 @@ public interface TeamDao {
WHERE "ID" = :teamId
""")
int deleteTeam(@Bind final long teamId);
+
+ default List deleteTeamsByName(final Collection names) {
+ final Update update = getHandle().createUpdate("""
+ DELETE
+ FROM "TEAM"
+ WHERE "NAME" = ANY(:names)
+ RETURNING "NAME"
+ """);
+
+ return update
+ .bindArray("names", String.class, names)
+ .executeAndReturnGeneratedKeys()
+ .mapTo(String.class)
+ .list();
+ }
}
diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/pagination/InvalidPageTokenException.java b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/InvalidPageTokenException.java
new file mode 100644
index 0000000000..359722fd4a
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/InvalidPageTokenException.java
@@ -0,0 +1,27 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.persistence.pagination;
+
+public class InvalidPageTokenException extends IllegalArgumentException {
+
+ public InvalidPageTokenException(final Throwable cause) {
+ super(cause);
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/pagination/Page.java b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/Page.java
new file mode 100644
index 0000000000..dd62e1c52e
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/Page.java
@@ -0,0 +1,24 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.persistence.pagination;
+
+import java.util.List;
+
+public record Page(List items, String nextPageToken) {
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/pagination/PageUtil.java b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/PageUtil.java
new file mode 100644
index 0000000000..983a0fcd49
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/PageUtil.java
@@ -0,0 +1,88 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.persistence.pagination;
+
+import alpine.security.crypto.DataEncryption;
+import org.dependencytrack.api.v2.model.PaginationLinks;
+import org.dependencytrack.api.v2.model.PaginationMetadata;
+import org.jdbi.v3.core.Handle;
+import org.jdbi.v3.json.JsonConfig;
+import org.jdbi.v3.json.JsonMapper;
+
+import jakarta.ws.rs.core.UriInfo;
+import java.util.Base64;
+
+/**
+ * @since 5.6.0
+ */
+public final class PageUtil {
+
+ private PageUtil() {}
+
+ public static T decodePageToken(final Handle handle, final String encodedToken, final Class tokenClass) {
+ if (encodedToken == null) {
+ return null;
+ }
+
+ final JsonMapper.TypedJsonMapper jsonMapper = handle
+ .getConfig(JsonConfig.class)
+ .getJsonMapper()
+ .forType(tokenClass, handle.getConfig());
+
+ try {
+ final byte[] encryptedTokenBytes = Base64.getUrlDecoder().decode(encodedToken);
+ final byte[] decryptedToken = DataEncryption.decryptAsBytes(encryptedTokenBytes);
+ return (T) jsonMapper.fromJson(new String(decryptedToken), handle.getConfig());
+ } catch (Exception e) {
+ throw new InvalidPageTokenException(e);
+ }
+ }
+
+ public static String encodePageToken(final Handle handle, final T pageToken) {
+ if (pageToken == null) {
+ return null;
+ }
+
+ final JsonMapper.TypedJsonMapper jsonMapper = handle
+ .getConfig(JsonConfig.class)
+ .getJsonMapper()
+ .forType(Object.class, handle.getConfig());
+
+ try {
+ final String tokenJson = jsonMapper.toJson(pageToken, handle.getConfig());
+ final byte[] encryptedTokenBytes = DataEncryption.encryptAsBytes(tokenJson);
+ return Base64.getUrlEncoder().encodeToString(encryptedTokenBytes);
+ } catch (Exception e) {
+ throw new InvalidPageTokenException(e);
+ }
+ }
+
+ public static PaginationMetadata createPaginationMetadata(final UriInfo uriInfo, final Page> page) {
+ return PaginationMetadata.builder()
+ .links(PaginationLinks.builder()
+ .self(uriInfo.getRequestUri())
+ .next(page.nextPageToken() != null ?
+ uriInfo.getRequestUriBuilder()
+ .queryParam("page_token", page.nextPageToken())
+ .build()
+ : null)
+ .build())
+ .build();
+ }
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/OpenApiResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/OpenApiResource.java
similarity index 97%
rename from apiserver/src/main/java/org/dependencytrack/resources/OpenApiResource.java
rename to apiserver/src/main/java/org/dependencytrack/resources/v1/OpenApiResource.java
index f67af48a7b..9c49cd3db6 100644
--- a/apiserver/src/main/java/org/dependencytrack/resources/OpenApiResource.java
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/OpenApiResource.java
@@ -16,7 +16,7 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright (c) OWASP Foundation. All Rights Reserved.
*/
-package org.dependencytrack.resources;
+package org.dependencytrack.resources.v1;
import alpine.server.auth.AuthenticationNotRequired;
import io.swagger.v3.jaxrs2.integration.resources.BaseOpenApiResource;
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/MetricsResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/MetricsResource.java
new file mode 100644
index 0000000000..5539130dc1
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/MetricsResource.java
@@ -0,0 +1,109 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2;
+
+import alpine.server.auth.PermissionRequired;
+import alpine.server.resources.AlpineResource;
+import org.dependencytrack.api.v2.MetricsApi;
+import org.dependencytrack.api.v2.model.ListVulnerabilityMetricsResponse;
+import org.dependencytrack.api.v2.model.ListVulnerabilityMetricsResponseItem;
+import org.dependencytrack.api.v2.model.PortfolioMetricsResponse;
+import org.dependencytrack.auth.Permissions;
+import org.dependencytrack.model.PortfolioMetrics;
+import org.dependencytrack.persistence.jdbi.MetricsDao;
+import org.dependencytrack.persistence.pagination.Page;
+
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.UriInfo;
+
+import static org.dependencytrack.persistence.jdbi.JdbiFactory.inJdbiTransaction;
+import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle;
+import static org.dependencytrack.persistence.pagination.PageUtil.createPaginationMetadata;
+
+public class MetricsResource extends AlpineResource implements MetricsApi {
+
+ @Context
+ private UriInfo uriInfo;
+
+ @Override
+ @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO)
+ public Response getPortfolioCurrentMetrics() {
+ PortfolioMetrics metrics = withJdbiHandle(
+ getAlpineRequest(),
+ handle -> handle.attach(MetricsDao.class).getMostRecentPortfolioMetrics());
+ final var response = PortfolioMetricsResponse.builder()
+ .components(metrics.getComponents())
+ .critical(metrics.getCritical())
+ .findingsAudited(metrics.getFindingsAudited())
+ .findingsTotal(metrics.getFindingsTotal())
+ .findingsUnaudited(metrics.getFindingsUnaudited())
+ .high(metrics.getHigh())
+ .inheritedRiskScore(metrics.getInheritedRiskScore())
+ .observedAt(metrics.getLastOccurrence().getTime())
+ .low(metrics.getLow())
+ .medium(metrics.getMedium())
+ .policyViolationsAudited(metrics.getPolicyViolationsAudited())
+ .policyViolationsFail(metrics.getPolicyViolationsFail())
+ .policyViolationsInfo(metrics.getPolicyViolationsInfo())
+ .policyViolationsLicenseAudited(metrics.getPolicyViolationsLicenseAudited())
+ .policyViolationsLicenseTotal(metrics.getPolicyViolationsLicenseTotal())
+ .policyViolationsLicenseUnaudited(metrics.getPolicyViolationsLicenseUnaudited())
+ .policyViolationsOperationalAudited(metrics.getPolicyViolationsOperationalAudited())
+ .policyViolationsOperationalTotal(metrics.getPolicyViolationsOperationalTotal())
+ .policyViolationsOperationalUnaudited(metrics.getPolicyViolationsOperationalUnaudited())
+ .policyViolationsSecurityAudited(metrics.getPolicyViolationsSecurityAudited())
+ .policyViolationsSecurityTotal(metrics.getPolicyViolationsSecurityTotal())
+ .policyViolationsSecurityUnaudited(metrics.getPolicyViolationsSecurityUnaudited())
+ .policyViolationsTotal(metrics.getPolicyViolationsTotal())
+ .policyViolationsUnaudited(metrics.getPolicyViolationsUnaudited())
+ .policyViolationsWarn(metrics.getPolicyViolationsWarn())
+ .projects(metrics.getProjects())
+ .suppressed(metrics.getSuppressed())
+ .unassigned(metrics.getUnassigned())
+ .vulnerabilities(metrics.getVulnerabilities())
+ .vulnerableComponents(metrics.getVulnerableComponents())
+ .vulnerableProjects(metrics.getVulnerableProjects())
+ .build();
+ return Response.ok(response).build();
+ }
+
+ @Override
+ @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO)
+ public Response getVulnerabilityMetrics(Integer limit, String pageToken) {
+ final Page metricsPage = inJdbiTransaction(
+ getAlpineRequest(),
+ handle -> handle.attach(MetricsDao.class).getVulnerabilityMetrics(limit, pageToken));
+
+ final var response = ListVulnerabilityMetricsResponse.builder()
+ .metrics(metricsPage.items().stream()
+ .map(
+ metricRow -> ListVulnerabilityMetricsResponseItem.builder()
+ .year(metricRow.year())
+ .month(metricRow.month())
+ .count(metricRow.count())
+ .observedAt(metricRow.measuredAt().getEpochSecond())
+ .build())
+ .toList())
+ .pagination(createPaginationMetadata(uriInfo, metricsPage))
+ .build();
+
+ return Response.ok(response).build();
+ }
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/OpenApiResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/OpenApiResource.java
new file mode 100644
index 0000000000..19d4c9f79c
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/OpenApiResource.java
@@ -0,0 +1,86 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2;
+
+import alpine.server.auth.AuthenticationNotRequired;
+import io.swagger.v3.oas.annotations.Operation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.ServerErrorException;
+import jakarta.ws.rs.core.Response;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URISyntaxException;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import static java.util.Objects.requireNonNull;
+
+@Path("/openapi.yaml")
+public class OpenApiResource {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiResource.class);
+ private static final ReadWriteLock LOCK = new ReentrantReadWriteLock();
+ private static String OPENAPI_YAML;
+
+ @GET
+ @Produces("application/yaml")
+ @Operation(hidden = true)
+ @AuthenticationNotRequired
+ public String getOpenApi() {
+ LOCK.readLock().lock();
+ try {
+ if (OPENAPI_YAML == null) {
+ LOCK.readLock().unlock();
+
+ LOCK.writeLock().lock();
+ try {
+ if (OPENAPI_YAML == null) {
+ OPENAPI_YAML = loadOpenapiYaml();
+ }
+
+ LOCK.readLock().lock();
+ } catch (URISyntaxException | IOException e) {
+ LOGGER.error("Failed to load OpenAPI spec YAML", e);
+ throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);
+ } finally {
+ LOCK.writeLock().unlock();
+ }
+ }
+
+ return OPENAPI_YAML;
+ } finally {
+ LOCK.readLock().unlock();
+ }
+ }
+
+ private static String loadOpenapiYaml() throws URISyntaxException, IOException {
+ try (final InputStream inputStream =
+ OpenApiResource.class.getResourceAsStream(
+ "/org/dependencytrack/api/v2/openapi.yaml")) {
+ requireNonNull(inputStream, "inputStream must not be null");
+ return new String(inputStream.readAllBytes());
+ }
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/ResourceConfig.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/ResourceConfig.java
new file mode 100644
index 0000000000..6f5741f580
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/ResourceConfig.java
@@ -0,0 +1,59 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2;
+
+import alpine.server.filters.ApiFilter;
+import alpine.server.filters.AuthenticationFeature;
+import alpine.server.filters.AuthorizationFeature;
+import alpine.server.filters.GZipInterceptor;
+import alpine.server.filters.HeaderFilter;
+import alpine.server.filters.RequestIdFilter;
+import alpine.server.filters.RequestMdcEnrichmentFilter;
+import org.dependencytrack.filters.JerseyMetricsFeature;
+import org.glassfish.jersey.jackson.JacksonFeature;
+import org.glassfish.jersey.media.multipart.MultiPartFeature;
+
+import static org.glassfish.jersey.server.ServerProperties.PROVIDER_PACKAGES;
+import static org.glassfish.jersey.server.ServerProperties.PROVIDER_SCANNING_RECURSIVE;
+
+/**
+ * @since 5.6.0
+ */
+public final class ResourceConfig extends org.glassfish.jersey.server.ResourceConfig {
+
+ public ResourceConfig() {
+ // Only scan the v2 package for providers, register everything else manually.
+ // This gives us more flexibility to pick-and-choose, and potentially configure
+ // specific features that do not necessarily overlap with v1.
+ property(PROVIDER_PACKAGES, getClass().getPackageName());
+ property(PROVIDER_SCANNING_RECURSIVE, true);
+
+ register(ApiFilter.class);
+ register(AuthenticationFeature.class);
+ register(AuthorizationFeature.class);
+ register(GZipInterceptor.class);
+ register(HeaderFilter.class);
+ register(JacksonFeature.withoutExceptionMappers());
+ register(JerseyMetricsFeature.class);
+ register(MultiPartFeature.class);
+ register(RequestIdFilter.class);
+ register(RequestMdcEnrichmentFilter.class);
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/TeamsResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/TeamsResource.java
new file mode 100644
index 0000000000..c047d62ff0
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/TeamsResource.java
@@ -0,0 +1,216 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2;
+
+import alpine.model.Permission;
+import alpine.model.Team;
+import alpine.model.User;
+import alpine.server.auth.PermissionRequired;
+import org.dependencytrack.api.v2.TeamsApi;
+import org.dependencytrack.api.v2.model.CreateTeamMembershipRequest;
+import org.dependencytrack.api.v2.model.CreateTeamRequest;
+import org.dependencytrack.api.v2.model.GetTeamResponse;
+import org.dependencytrack.api.v2.model.ListTeamMembershipsResponse;
+import org.dependencytrack.api.v2.model.ListTeamMembershipsResponseItem;
+import org.dependencytrack.api.v2.model.ListTeamsResponse;
+import org.dependencytrack.api.v2.model.ListTeamsResponseItem;
+import org.dependencytrack.auth.Permissions;
+import org.dependencytrack.persistence.QueryManager;
+import org.dependencytrack.persistence.jdbi.TeamDao;
+import org.dependencytrack.persistence.jdbi.TeamDao.ListTeamMembershipsRow;
+import org.dependencytrack.persistence.jdbi.TeamDao.ListTeamsRow;
+import org.dependencytrack.persistence.pagination.Page;
+import org.owasp.security.logging.SecurityMarkers;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import jakarta.ws.rs.ClientErrorException;
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.UriInfo;
+import jakarta.ws.rs.ext.Provider;
+import java.util.List;
+
+import static org.dependencytrack.persistence.jdbi.JdbiFactory.inJdbiTransaction;
+import static org.dependencytrack.persistence.pagination.PageUtil.createPaginationMetadata;
+import static org.dependencytrack.util.PersistenceUtil.isUniqueConstraintViolation;
+
+@Provider
+public class TeamsResource implements TeamsApi {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TeamsResource.class);
+
+ @Context
+ private UriInfo uriInfo;
+
+ @Override
+ @PermissionRequired(Permissions.Constants.ACCESS_MANAGEMENT)
+ public Response listTeams(final Integer limit, final String pageToken) {
+ final Page teamsPage = inJdbiTransaction(
+ handle -> handle.attach(TeamDao.class).listTeams(limit, pageToken));
+
+ final var response = ListTeamsResponse.builder()
+ .teams(teamsPage.items().stream()
+ .map(
+ teamRow -> ListTeamsResponseItem.builder()
+ .name(teamRow.name())
+ .apiKeys(teamRow.apiKeys())
+ .members(teamRow.members())
+ .build())
+ .toList())
+ .pagination(createPaginationMetadata(uriInfo, teamsPage))
+ .build();
+
+ return Response.ok(response).build();
+ }
+
+ @Override
+ @PermissionRequired(Permissions.Constants.ACCESS_MANAGEMENT)
+ public Response getTeam(final String name) {
+ try (final var qm = new QueryManager()) {
+ final Team team = qm.getTeam(name);
+ if (team == null) {
+ throw new NotFoundException();
+ }
+
+ final var response = GetTeamResponse.builder()
+ .name(name)
+ .permissions(
+ team.getPermissions().stream()
+ .map(Permission::getName)
+ .sorted()
+ .toList())
+ .build();
+
+ return Response.ok(response).build();
+ }
+ }
+
+ @Override
+ @PermissionRequired(Permissions.Constants.ACCESS_MANAGEMENT)
+ public Response createTeam(final CreateTeamRequest request) {
+ try (final var qm = new QueryManager()) {
+ qm.runInTransaction(() -> {
+ final List permissions =
+ qm.getPermissionsByName(request.getPermissions());
+
+ final var team = new Team();
+ team.setName(request.getName());
+ team.setPermissions(permissions);
+ qm.persist(team);
+ });
+ } catch (RuntimeException e) {
+ if (isUniqueConstraintViolation(e)) {
+ throw new ClientErrorException(Response.Status.CONFLICT);
+ }
+
+ throw e;
+ }
+
+ LOGGER.info(
+ SecurityMarkers.SECURITY_AUDIT,
+ "Team created: {}", request.getName());
+ return Response
+ .created(uriInfo.getBaseUriBuilder()
+ .path("/teams")
+ .path(request.getName())
+ .build())
+ .build();
+ }
+
+ @Override
+ @PermissionRequired(Permissions.Constants.ACCESS_MANAGEMENT)
+ public Response deleteTeam(final String name) {
+ final List deletedTeamNames = inJdbiTransaction(
+ handle -> handle.attach(TeamDao.class).deleteTeamsByName(List.of(name)));
+ if (deletedTeamNames.isEmpty()) {
+ throw new NotFoundException();
+ }
+
+ LOGGER.info(SecurityMarkers.SECURITY_AUDIT, "Team deleted: {}", name);
+ return Response.noContent().build();
+ }
+
+ @Override
+ public Response listTeamMemberships(final String team, final String user, final Integer limit, final String pageToken) {
+ final Page membershipsPage = inJdbiTransaction(
+ handle -> handle.attach(TeamDao.class).listTeamMembers(team, user, limit, pageToken));
+
+ final var response = ListTeamMembershipsResponse.builder()
+ .memberships(membershipsPage.items().stream()
+ .map(
+ membershipRow -> ListTeamMembershipsResponseItem.builder()
+ .teamName(membershipRow.teamName())
+ .username(membershipRow.username())
+ .build())
+ .toList())
+ .pagination(createPaginationMetadata(uriInfo, membershipsPage))
+ .build();
+
+ return Response.ok(response).build();
+ }
+
+ @Override
+ public Response createTeamMembership(final CreateTeamMembershipRequest request) {
+ try (final var qm = new QueryManager()) {
+ qm.runInTransaction(() -> {
+ final Team team = qm.getTeam(request.getTeamName());
+ if (team == null) {
+ throw new NotFoundException();
+ }
+
+ final User user = qm.getUser(request.getUsername());
+ if (user == null) {
+ throw new NotFoundException();
+ }
+
+ team.getUsers().add(user);
+ user.getTeams().add(team);
+ });
+ } catch (RuntimeException e) {
+ if (isUniqueConstraintViolation(e)) {
+ throw new ClientErrorException(Response.Status.CONFLICT);
+ }
+
+ throw e;
+ }
+
+ LOGGER.info(
+ SecurityMarkers.SECURITY_AUDIT,
+ "Team membership created: team={}, user={}",
+ request.getTeamName(),
+ request.getUsername());
+ return Response.created(null).build();
+ }
+
+ @Override
+ public Response deleteTeamMembership(final String team, final String user) {
+ final boolean deleted = inJdbiTransaction(
+ handle -> handle.attach(TeamDao.class).deleteTeamMembership(team, user));
+ if (!deleted) {
+ throw new NotFoundException();
+ }
+
+ LOGGER.info(
+ SecurityMarkers.SECURITY_AUDIT,
+ "Team membership deleted: team={}, user={}", team, user);
+ return Response.noContent().build();
+ }
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java
new file mode 100644
index 0000000000..e6be2c66d7
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java
@@ -0,0 +1,73 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2;
+
+import alpine.server.auth.PermissionRequired;
+import org.dependencytrack.api.v2.WorkflowsApi;
+import org.dependencytrack.api.v2.model.ListWorkflowStatesResponse;
+import org.dependencytrack.api.v2.model.ListWorkflowStatesResponseItem;
+import org.dependencytrack.auth.Permissions;
+import org.dependencytrack.model.WorkflowState;
+import org.dependencytrack.persistence.QueryManager;
+
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.Provider;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+@Provider
+public class WorkflowsResource implements WorkflowsApi {
+
+ @Override
+ @PermissionRequired(Permissions.Constants.BOM_UPLOAD)
+ public Response getWorkflowStates(final UUID token) {
+ List workflowStates;
+ try (final var qm = new QueryManager()) {
+ workflowStates = qm.getAllWorkflowStatesForAToken(token);
+ if (workflowStates.isEmpty()) {
+ throw new NotFoundException();
+ }
+ }
+ List states = workflowStates.stream()
+ .map(this::mapWorkflowStateResponse)
+ .collect(Collectors.toList());
+ return Response.ok(ListWorkflowStatesResponse.builder().states(states).build()).build();
+ }
+
+ private ListWorkflowStatesResponseItem mapWorkflowStateResponse(WorkflowState workflowState) {
+ var mappedState = ListWorkflowStatesResponseItem.builder()
+ .token(workflowState.getToken())
+ .status(ListWorkflowStatesResponseItem.StatusEnum.fromString(workflowState.getStatus().name()))
+ .step(ListWorkflowStatesResponseItem.StepEnum.fromString(workflowState.getStep().name()))
+ .failureReason(workflowState.getFailureReason())
+ .build();
+ if (workflowState.getParent() != null) {
+ mappedState.setParent(mapWorkflowStateResponse(workflowState.getParent()));
+ }
+ if (workflowState.getStartedAt() != null) {
+ mappedState.setStartedAt(workflowState.getStartedAt().getTime());
+ }
+ if (workflowState.getUpdatedAt() != null) {
+ mappedState.setUpdatedAt(workflowState.getUpdatedAt().getTime());
+ }
+ return mappedState;
+ }
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ClientErrorExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ClientErrorExceptionMapper.java
new file mode 100644
index 0000000000..b01a155a64
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ClientErrorExceptionMapper.java
@@ -0,0 +1,50 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2.exception;
+
+import org.dependencytrack.api.v2.model.ProblemDetails;
+
+import jakarta.ws.rs.ClientErrorException;
+import jakarta.ws.rs.ext.Provider;
+import java.util.Map;
+
+/**
+ * @since 5.6.0
+ */
+@Provider
+public final class ClientErrorExceptionMapper extends ProblemDetailsExceptionMapper {
+
+ private static final Map DETAIL_BY_STATUS = Map.ofEntries(
+ Map.entry(401, "Not authorized to access the requested resource."),
+ Map.entry(403, "Not permitted to access the requested resource."),
+ Map.entry(404, "The requested resource could not be found."),
+ Map.entry(409, "The resource already exists."));
+
+ @Override
+ public ProblemDetails map(final ClientErrorException exception) {
+ return ProblemDetails.builder()
+ .status(exception.getResponse().getStatus())
+ .title(exception.getResponse().getStatusInfo().getReasonPhrase())
+ .detail(DETAIL_BY_STATUS.getOrDefault(
+ exception.getResponse().getStatus(),
+ exception.getMessage()))
+ .build();
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapper.java
new file mode 100644
index 0000000000..a9313b19b3
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapper.java
@@ -0,0 +1,62 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2.exception;
+
+import org.dependencytrack.api.v2.model.ConstraintViolationError;
+import org.dependencytrack.api.v2.model.InvalidRequestProblemDetails;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.ConstraintViolationException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.Provider;
+import java.util.ArrayList;
+import java.util.Set;
+
+/**
+ * @since 5.6.0
+ */
+@Provider
+public final class ConstraintViolationExceptionMapper extends ProblemDetailsExceptionMapper {
+
+ @Override
+ public InvalidRequestProblemDetails map(final ConstraintViolationException exception) {
+ final Set> violations = exception.getConstraintViolations();
+
+ final var errors = new ArrayList(violations.size());
+
+ for (final ConstraintViolation> violation : violations) {
+ errors.add(
+ ConstraintViolationError.builder()
+ .path(violation.getPropertyPath().toString())
+ .value(violation.getInvalidValue() != null
+ ? violation.getInvalidValue().toString()
+ : null)
+ .message(violation.getMessage())
+ .build());
+ }
+
+ return InvalidRequestProblemDetails.builder()
+ .status(Response.Status.BAD_REQUEST.getStatusCode())
+ .title("Bad Request")
+ .detail("The request could not be processed because it failed validation.")
+ .errors(errors)
+ .build();
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapper.java
new file mode 100644
index 0000000000..01f372f4c1
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapper.java
@@ -0,0 +1,30 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2.exception;
+
+import org.dependencytrack.api.v2.model.ProblemDetails;
+
+import jakarta.ws.rs.ext.Provider;
+
+/**
+ * @since 5.6.0
+ */
+@Provider
+public final class DefaultExceptionMapper extends LoggingProblemDetailsExceptionMapper {
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapper.java
new file mode 100644
index 0000000000..95698148f0
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapper.java
@@ -0,0 +1,48 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2.exception;
+
+import com.fasterxml.jackson.core.JsonGenerationException;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
+import org.dependencytrack.api.v2.model.ProblemDetails;
+
+import jakarta.ws.rs.ext.Provider;
+
+/**
+ * @since 5.6.0
+ */
+@Provider
+public class JsonProcessingExceptionMapper extends LoggingProblemDetailsExceptionMapper {
+
+ @Override
+ ProblemDetails map(final JsonProcessingException exception) {
+ if (exception instanceof InvalidDefinitionException
+ || exception instanceof JsonGenerationException) {
+ return super.map(exception);
+ }
+
+ return ProblemDetails.builder()
+ .status(400)
+ .title("JSON Processing Failed")
+ .detail("The provided JSON could not be processed.")
+ .build();
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/LoggingProblemDetailsExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/LoggingProblemDetailsExceptionMapper.java
new file mode 100644
index 0000000000..4b1dfc08f9
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/LoggingProblemDetailsExceptionMapper.java
@@ -0,0 +1,50 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2.exception;
+
+import org.dependencytrack.api.v2.model.ProblemDetails;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import jakarta.ws.rs.ServerErrorException;
+
+/**
+ * @since 5.6.0
+ */
+abstract class LoggingProblemDetailsExceptionMapper extends ProblemDetailsExceptionMapper {
+
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ @Override
+ @SuppressWarnings("unchecked")
+ P map(final E exception) {
+ logger.error("Uncaught exception occurred during request processing", exception);
+
+ final int status = exception instanceof final ServerErrorException see
+ ? see.getResponse().getStatus()
+ : 500;
+
+ return (P) ProblemDetails.builder()
+ .status(status)
+ .title("Unexpected error")
+ .detail("An error occurred that was not anticipated.")
+ .build();
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ProblemDetailsExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ProblemDetailsExceptionMapper.java
new file mode 100644
index 0000000000..4912108c77
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ProblemDetailsExceptionMapper.java
@@ -0,0 +1,44 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2.exception;
+
+import org.dependencytrack.api.v2.model.ProblemDetails;
+
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.ExceptionMapper;
+
+/**
+ * @since 5.6.0
+ */
+abstract class ProblemDetailsExceptionMapper implements ExceptionMapper {
+
+ abstract P map(E exception);
+
+ @Override
+ public Response toResponse(final E exception) {
+ final P problemDetails = map(exception);
+
+ return Response
+ .status(problemDetails.getStatus())
+ .header("Content-Type", "application/problem+json")
+ .entity(problemDetails)
+ .build();
+ }
+
+}
diff --git a/apiserver/src/main/webapp/WEB-INF/web.xml b/apiserver/src/main/webapp/WEB-INF/web.xml
index 645caec27c..052fe67a42 100644
--- a/apiserver/src/main/webapp/WEB-INF/web.xml
+++ b/apiserver/src/main/webapp/WEB-INF/web.xml
@@ -116,7 +116,12 @@
alpine.server.AlpineServletjersey.config.server.provider.packages
- alpine.server.filters,alpine.server.resources,org.dependencytrack.resources,org.dependencytrack.filters
+
+ alpine.server.filters,
+ alpine.server.resources,
+ org.dependencytrack.filters,
+ org.dependencytrack.resources.v1
+ jersey.config.server.provider.classnames
@@ -132,6 +137,19 @@
DependencyTrack/api/*
+
+
+ REST-API-v2
+ org.glassfish.jersey.servlet.ServletContainer
+
+ jakarta.ws.rs.Application
+ org.dependencytrack.resources.v2.ResourceConfig
+
+
+
+ REST-API-v2
+ /api/v2/*
+ Health
diff --git a/apiserver/src/test/java/org/dependencytrack/JerseyTestRule.java b/apiserver/src/test/java/org/dependencytrack/JerseyTestRule.java
index 8bffa30f48..0dd53ea86b 100644
--- a/apiserver/src/test/java/org/dependencytrack/JerseyTestRule.java
+++ b/apiserver/src/test/java/org/dependencytrack/JerseyTestRule.java
@@ -18,7 +18,9 @@
*/
package org.dependencytrack;
-import org.dependencytrack.resources.v1.exception.ClientErrorExceptionMapper;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.utils.URLEncodedUtils;
+import org.dependencytrack.resources.v2.OpenApiValidationClientResponseFilter;
import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.grizzly.connector.GrizzlyConnectorProvider;
import org.glassfish.jersey.server.ResourceConfig;
@@ -32,6 +34,9 @@
import org.junit.rules.ExternalResource;
import jakarta.ws.rs.client.WebTarget;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
/**
* @since 4.11.0
@@ -41,6 +46,7 @@ public class JerseyTestRule extends ExternalResource {
private final JerseyTest jerseyTest;
public JerseyTestRule(final ResourceConfig resourceConfig) {
+ final boolean isV2 = isV2(resourceConfig);
this.jerseyTest = new JerseyTest() {
@Override
@@ -54,14 +60,25 @@ protected void configureClient(final ClientConfig config) {
// using the default HttpUrlConnection connector provider.
// See https://github.com/eclipse-ee4j/jersey/issues/4825
config.connectorProvider(new GrizzlyConnectorProvider());
+
+ if (isV2) {
+ config.register(OpenApiValidationClientResponseFilter.class);
+ }
}
@Override
protected DeploymentContext configureDeployment() {
forceSet(TestProperties.CONTAINER_PORT, "0");
- return ServletDeploymentContext.forServlet(new ServletContainer(
- // Ensure exception mappers are registered.
- resourceConfig.packages(ClientErrorExceptionMapper.class.getPackageName()))).build();
+
+ // Ensure exception mappers are registered.
+ if (isV2) {
+ resourceConfig.packages("org.dependencytrack.resources.v2.exception");
+ } else {
+ resourceConfig.packages("org.dependencytrack.resources.v1.exception");
+ }
+
+ return ServletDeploymentContext.forServlet(
+ new ServletContainer(resourceConfig)).build();
}
};
@@ -89,4 +106,28 @@ public final WebTarget target(final String path) {
return jerseyTest.target(path);
}
+ public final WebTarget target(final URI uri) {
+ WebTarget target = jerseyTest.target(uri.getPath());
+
+ if (uri.getQuery() != null) {
+ final List uriQueryParams =
+ URLEncodedUtils.parse(uri, StandardCharsets.UTF_8);
+ for (final NameValuePair queryParam : uriQueryParams) {
+ target = target.queryParam(queryParam.getName(), queryParam.getValue());
+ }
+ }
+
+ return target;
+ }
+
+ private boolean isV2(final ResourceConfig resourceConfig) {
+ for (final Class> clazz : resourceConfig.getClasses()) {
+ if (clazz.getPackageName().startsWith("org.dependencytrack.resources.v2")) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
}
\ No newline at end of file
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/OpenApiResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/OpenApiResourceTest.java
similarity index 98%
rename from apiserver/src/test/java/org/dependencytrack/resources/OpenApiResourceTest.java
rename to apiserver/src/test/java/org/dependencytrack/resources/v1/OpenApiResourceTest.java
index c7fda53cbd..46f24cecac 100644
--- a/apiserver/src/test/java/org/dependencytrack/resources/OpenApiResourceTest.java
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/OpenApiResourceTest.java
@@ -16,12 +16,11 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright (c) OWASP Foundation. All Rights Reserved.
*/
-package org.dependencytrack.resources;
+package org.dependencytrack.resources.v1;
import alpine.server.filters.ApiFilter;
import io.swagger.parser.OpenAPIParser;
import io.swagger.v3.parser.core.models.SwaggerParseResult;
-import jakarta.ws.rs.core.Response;
import org.dependencytrack.JerseyTestRule;
import org.dependencytrack.ResourceTest;
import org.glassfish.jersey.client.ClientProperties;
@@ -29,6 +28,7 @@
import org.junit.ClassRule;
import org.junit.Test;
+import jakarta.ws.rs.core.Response;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/MetricsResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/MetricsResourceTest.java
new file mode 100644
index 0000000000..21ee0afb5f
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/MetricsResourceTest.java
@@ -0,0 +1,273 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2;
+
+import alpine.server.filters.ApiFilter;
+import alpine.server.filters.AuthenticationFeature;
+import net.javacrumbs.jsonunit.core.Option;
+import org.dependencytrack.JerseyTestRule;
+import org.dependencytrack.ResourceTest;
+import org.dependencytrack.auth.Permissions;
+import org.dependencytrack.model.Project;
+import org.dependencytrack.model.ProjectMetrics;
+import org.dependencytrack.model.VulnerabilityMetrics;
+import org.dependencytrack.persistence.jdbi.MetricsTestDao;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import jakarta.json.JsonObject;
+import jakarta.ws.rs.core.Response;
+import java.net.URI;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Date;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.dependencytrack.persistence.jdbi.JdbiFactory.useJdbiHandle;
+
+public class MetricsResourceTest extends ResourceTest {
+
+ @ClassRule
+ public static JerseyTestRule jersey = new JerseyTestRule(
+ new ResourceConfig(MetricsResource.class)
+ .register(ApiFilter.class)
+ .register(AuthenticationFeature.class));
+
+ @Test
+ public void getCurrentPortfolioMetricsEmptyTest() {
+ initializeWithPermissions(Permissions.VIEW_PORTFOLIO);
+ enablePortfolioAccessControl();
+
+ final Response response = jersey
+ .target("/metrics/portfolio/current")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThatJson(getPlainTextBody(response))
+ .isEqualTo(/* language=JSON */ """
+ {
+ "components": 0,
+ "critical": 0,
+ "findings_audited": 0,
+ "findings_total": 0,
+ "findings_unaudited": 0,
+ "high": 0,
+ "inherited_risk_score": 0.0,
+ "low": 0,
+ "medium": 0,
+ "observed_at": "${json-unit.any-number}",
+ "policy_violations_audited": 0,
+ "policy_violations_fail": 0,
+ "policy_violations_info": 0,
+ "policy_violations_license_audited": 0,
+ "policy_violations_license_total": 0,
+ "policy_violations_license_unaudited": 0,
+ "policy_violations_operational_audited": 0,
+ "policy_violations_operational_total": 0,
+ "policy_violations_operational_unaudited": 0,
+ "policy_violations_security_audited": 0,
+ "policy_violations_security_total": 0,
+ "policy_violations_security_unaudited": 0,
+ "policy_violations_total": 0,
+ "policy_violations_unaudited": 0,
+ "policy_violations_warn": 0,
+ "projects": 0,
+ "suppressed": 0,
+ "unassigned": 0,
+ "vulnerabilities": 0,
+ "vulnerable_components": 0,
+ "vulnerable_projects": 0
+ }
+ """);
+ }
+
+ @Test
+ public void getCurrentPortfolioMetricsAclTest() {
+ initializeWithPermissions(Permissions.VIEW_PORTFOLIO);
+ enablePortfolioAccessControl();
+
+ final var accessibleProjectA = new Project();
+ accessibleProjectA.setName("acme-app-a");
+ accessibleProjectA.addAccessTeam(super.team);
+ qm.persist(accessibleProjectA);
+
+ final var accessibleProjectB = new Project();
+ accessibleProjectB.setName("acme-app-b");
+ accessibleProjectB.addAccessTeam(super.team);
+ qm.persist(accessibleProjectB);
+
+ final var inactiveAccessibleProject = new Project();
+ inactiveAccessibleProject.setName("acme-app-inactive");
+ inactiveAccessibleProject.setInactiveSince(new Date());
+ inactiveAccessibleProject.addAccessTeam(super.team);
+ qm.persist(inactiveAccessibleProject);
+
+ final var inaccessibleProject = new Project();
+ inaccessibleProject.setName("acme-app-inaccessible");
+ qm.persist(inaccessibleProject);
+
+ final var today = LocalDate.now();
+
+ useJdbiHandle(handle -> {
+ var dao = handle.attach(MetricsTestDao.class);
+
+ dao.createMetricsPartitionsForDate("PROJECTMETRICS", today);
+ dao.createMetricsPartitionsForDate("PROJECTMETRICS", today.minusDays(1));
+ dao.createMetricsPartitionsForDate("PROJECTMETRICS", today.minusDays(2));
+
+ {
+ // Create metrics for "yesterday".
+
+ var accessibleProjectAMetrics = new ProjectMetrics();
+ accessibleProjectAMetrics.setProjectId(accessibleProjectA.getId());
+ accessibleProjectAMetrics.setComponents(2);
+ accessibleProjectAMetrics.setFirstOccurrence(Date.from(today.minusDays(1).atTime(1, 1).atZone(ZoneId.systemDefault()).toInstant()));
+ accessibleProjectAMetrics.setLastOccurrence(accessibleProjectAMetrics.getFirstOccurrence());
+ dao.createProjectMetrics(accessibleProjectAMetrics);
+ }
+
+ {
+ // Create metrics for "today".
+
+ // Do not create metrics for accessibleProjectA.
+ // Its metrics from "yesterday" are supposed to carry over to "today".
+
+ var accessibleProjectBMetrics = new ProjectMetrics();
+ accessibleProjectBMetrics.setProjectId(accessibleProjectB.getId());
+ accessibleProjectBMetrics.setComponents(1);
+ accessibleProjectBMetrics.setFirstOccurrence(Date.from(today.atTime(1, 1).atZone(ZoneId.systemDefault()).toInstant()));
+ accessibleProjectBMetrics.setLastOccurrence(accessibleProjectBMetrics.getFirstOccurrence());
+ dao.createProjectMetrics(accessibleProjectBMetrics);
+
+ // Metrics of inactive projects must not be considered.
+ var inactiveAccessibleProjectMetrics = new ProjectMetrics();
+ inactiveAccessibleProjectMetrics.setProjectId(inactiveAccessibleProject.getId());
+ inactiveAccessibleProjectMetrics.setComponents(111);
+ inactiveAccessibleProjectMetrics.setFirstOccurrence(Date.from(today.atTime(2, 2).atZone(ZoneId.systemDefault()).toInstant()));
+ inactiveAccessibleProjectMetrics.setLastOccurrence(inactiveAccessibleProjectMetrics.getFirstOccurrence());
+ dao.createProjectMetrics(inactiveAccessibleProjectMetrics);
+
+ // Metrics of inaccessible projects must not be considered.
+ var inaccessibleProjectMetrics = new ProjectMetrics();
+ inaccessibleProjectMetrics.setProjectId(inaccessibleProject.getId());
+ inaccessibleProjectMetrics.setComponents(666);
+ inaccessibleProjectMetrics.setFirstOccurrence(Date.from(today.atTime(3, 3).atZone(ZoneId.systemDefault()).toInstant()));
+ inaccessibleProjectMetrics.setLastOccurrence(inaccessibleProjectMetrics.getFirstOccurrence());
+ dao.createProjectMetrics(inaccessibleProjectMetrics);
+ }
+ });
+
+ final Response response = jersey
+ .target("/metrics/portfolio/current")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThatJson(getPlainTextBody(response))
+ .withOptions(Option.IGNORING_EXTRA_FIELDS)
+ .isEqualTo(/* language=JSON */ """
+ {
+ "projects": 2,
+ "components": 3
+ }
+ """);
+ }
+
+ @Test
+ public void getVulnerabilityMetricsPaginated() {
+ initializeWithPermissions(Permissions.VIEW_PORTFOLIO);
+ enablePortfolioAccessControl();
+
+ for (int i = 1; i < 4; i++) {
+ var metrics = new VulnerabilityMetrics();
+ metrics.setYear(2025);
+ metrics.setMonth(i);
+ metrics.setCount(i);
+ metrics.setMeasuredAt(Date.from(Instant.now()));
+ qm.persist(metrics);
+ }
+
+ Response response = jersey.target("/metrics/vulnerabilities")
+ .queryParam("limit", 2)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ final JsonObject responseJson = parseJsonObject(response);
+ assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """
+ {
+ "metrics" :
+ [
+ {
+ "observed_at" : "${json-unit.any-number}",
+ "year" : 2025,
+ "month" : 1,
+ "count" : 1
+ },
+ {
+ "observed_at" : "${json-unit.any-number}",
+ "year" : 2025,
+ "month" : 2,
+ "count" : 2
+ }
+ ],
+ "_pagination" : {
+ "links" : {
+ "self" : "${json-unit.any-string}",
+ "next": "${json-unit.any-string}"
+ }
+ }
+ }
+ """);
+
+ final var nextPageUri = URI.create(
+ responseJson
+ .getJsonObject("_pagination")
+ .getJsonObject("links")
+ .getString("next"));
+
+ response = jersey.target(nextPageUri)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "metrics" :
+ [
+ {
+ "observed_at" : "${json-unit.any-number}",
+ "year" : 2025,
+ "month" : 3,
+ "count" : 3
+ }
+ ],
+ "_pagination": {
+ "links": {
+ "self": "${json-unit.any-string}"
+ }
+ }
+ }
+ """);
+ }
+}
\ No newline at end of file
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiResourceTest.java
new file mode 100644
index 0000000000..d6bbc772ac
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiResourceTest.java
@@ -0,0 +1,54 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2;
+
+import io.swagger.parser.OpenAPIParser;
+import io.swagger.v3.parser.core.models.SwaggerParseResult;
+import org.dependencytrack.JerseyTestRule;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import jakarta.ws.rs.core.Response;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.dependencytrack.resources.v2.OpenApiValidationClientResponseFilter.DISABLE_OPENAPI_VALIDATION;
+
+public class OpenApiResourceTest {
+
+ @ClassRule
+ public static JerseyTestRule jersey = new JerseyTestRule(new ResourceConfig());
+
+ @Test
+ public void shouldReturnSpecYaml() {
+ final Response response = jersey.target("/openapi.yaml")
+ .request()
+ .property(DISABLE_OPENAPI_VALIDATION, "true")
+ .get(Response.class);
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/yaml");
+
+ final String openApiYaml = response.readEntity(String.class);
+ final SwaggerParseResult parseResult = new OpenAPIParser().readContents(openApiYaml, null, null);
+
+ final List validationMessages = parseResult.getMessages();
+ assertThat(validationMessages).isEmpty();
+ }
+
+}
\ No newline at end of file
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiValidationClientResponseFilter.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiValidationClientResponseFilter.java
new file mode 100644
index 0000000000..5a228716fd
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiValidationClientResponseFilter.java
@@ -0,0 +1,186 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.networknt.schema.InputFormat;
+import com.networknt.schema.JsonMetaSchema;
+import com.networknt.schema.JsonSchema;
+import com.networknt.schema.JsonSchemaFactory;
+import com.networknt.schema.NonValidationKeyword;
+import com.networknt.schema.SpecVersion;
+import com.networknt.schema.ValidationMessage;
+import com.networknt.schema.oas.OpenApi30;
+import io.swagger.parser.OpenAPIParser;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.PathItem;
+import io.swagger.v3.oas.models.media.MediaType;
+import io.swagger.v3.oas.models.responses.ApiResponse;
+import io.swagger.v3.parser.ObjectMapperFactory;
+import io.swagger.v3.parser.core.models.ParseOptions;
+import org.junit.Assert;
+
+import jakarta.ws.rs.client.ClientRequestContext;
+import jakarta.ws.rs.client.ClientResponseContext;
+import jakarta.ws.rs.client.ClientResponseFilter;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Set;
+
+import static java.util.Objects.requireNonNull;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @since 5.6.0
+ */
+public class OpenApiValidationClientResponseFilter implements ClientResponseFilter {
+
+ public static final String DISABLE_OPENAPI_VALIDATION = "disable-openapi-validation";
+
+ private static final JsonSchemaFactory SCHEMA_FACTORY =
+ JsonSchemaFactory.getInstance(
+ SpecVersion.VersionFlag.V4,
+ builder -> builder
+ .metaSchema(JsonMetaSchema.builder(OpenApi30.getInstance())
+ .keyword(new NonValidationKeyword("exampleSetFlag"))
+ .keyword(new NonValidationKeyword("extensions"))
+ .keyword(new NonValidationKeyword("types"))
+ .build())
+ .defaultMetaSchemaIri(OpenApi30.getInstance().getIri()));
+
+ private final OpenAPI openApiSpec;
+ private final ObjectMapper objectMapper;
+
+ public OpenApiValidationClientResponseFilter() {
+ this.openApiSpec = loadOpenApiSpec();
+ this.objectMapper = ObjectMapperFactory.createJson();
+ }
+
+ @Override
+ public void filter(
+ final ClientRequestContext requestContext,
+ final ClientResponseContext responseContext) throws IOException {
+ if (requestContext.hasProperty(DISABLE_OPENAPI_VALIDATION)) {
+ return;
+ }
+
+ final Operation operationDef = findOpenApiOperation(requestContext);
+ if (operationDef == null) {
+ // Undocumented request?
+ Assert.fail("No OpenAPI operation found for %s %s".formatted(
+ requestContext.getMethod(), requestContext.getUri()));
+ }
+
+ // Read the response content and assign it back to the response context.
+ // Without this, clients won't be able to read the response anymore.
+ final byte[] responseBytes = responseContext.getEntityStream().readAllBytes();
+ responseContext.setEntityStream(new ByteArrayInputStream(responseBytes));
+
+ // Identity the correct response object in the spec based on the status.
+ final String responseStatus = String.valueOf(responseContext.getStatus());
+ assertThat(operationDef.getResponses().keySet()).contains(responseStatus);
+ final ApiResponse responseDef = operationDef.getResponses().get(responseStatus);
+
+ // If the spec does not define a response, ensure that the actual
+ // response is also empty.
+ if (responseDef.getContent() == null) {
+ assertThat(responseBytes).asString().isEmpty();
+ return;
+ }
+
+ // Identity the correct media type in the spec response.
+ final String responseContentType = responseContext.getHeaderString("Content-Type");
+ assertThat(responseDef.getContent().keySet()).contains(responseContentType);
+ final MediaType mediaType = responseDef.getContent().get(responseContentType);
+
+ // Serialize the response schema to JSON so it can be used for validation.
+ // NB: The schema already has all $refs resolved so can be handled "standalone".
+ final String schemaJson = objectMapper.writeValueAsString(mediaType.getSchema());
+ final JsonSchema schema = SCHEMA_FACTORY.getSchema(schemaJson);
+
+ final Set messages = schema.validate(
+ new String(responseBytes), InputFormat.JSON);
+ assertThat(messages).isEmpty();
+ }
+
+ private Operation findOpenApiOperation(final ClientRequestContext requestContext) {
+ final String requestPath = requestContext.getUri().getPath();
+
+ for (final String specPath : openApiSpec.getPaths().keySet()) {
+ if (!pathsMatch(requestPath, specPath)) {
+ continue;
+ }
+
+ final PathItem pathItem = openApiSpec.getPaths().get(specPath);
+ return switch (requestContext.getMethod()) {
+ case "DELETE" -> pathItem.getDelete();
+ case "GET" -> pathItem.getGet();
+ case "HEAD" -> pathItem.getHead();
+ case "OPTIONS" -> pathItem.getOptions();
+ case "PATCH" -> pathItem.getPatch();
+ case "POST" -> pathItem.getPost();
+ case "PUT" -> pathItem.getPut();
+ case "TRACE" -> pathItem.getTrace();
+ default -> null;
+ };
+ }
+
+ return null;
+ }
+
+ private boolean pathsMatch(final String requestPath, final String specPath) {
+ final String[] requestPathSegments = requestPath.split("/");
+ final String[] specPathSegments = specPath.split("/");
+
+ if (requestPathSegments.length != specPathSegments.length) {
+ return false;
+ }
+
+ for (int i = 0; i < requestPathSegments.length; i++) {
+ final String requestPathSegment = requestPathSegments[i];
+ final String specPathSegment = specPathSegments[i];
+
+ if (!requestPathSegment.equals(specPathSegment) && !specPathSegment.startsWith("{")) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static OpenAPI loadOpenApiSpec() {
+ try (final InputStream specInputStream =
+ OpenApiValidationClientResponseFilter.class.getResourceAsStream(
+ "/org/dependencytrack/api/v2/openapi.yaml")) {
+ requireNonNull(specInputStream);
+ final String specString = new String(specInputStream.readAllBytes());
+
+ final var parseOptions = new ParseOptions();
+ parseOptions.setResolve(true);
+ parseOptions.setResolveFully(true);
+
+ return new OpenAPIParser().readContents(specString, null, parseOptions).getOpenAPI();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to load OpenAPI spec", e);
+ }
+ }
+
+}
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/TeamsResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/TeamsResourceTest.java
new file mode 100644
index 0000000000..ad9f50b2e3
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/TeamsResourceTest.java
@@ -0,0 +1,589 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2;
+
+import alpine.model.Permission;
+import alpine.model.Team;
+import alpine.model.User;
+import org.dependencytrack.JerseyTestRule;
+import org.dependencytrack.ResourceTest;
+import org.dependencytrack.auth.Permissions;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import jakarta.json.JsonObject;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Response;
+import java.net.URI;
+import java.util.List;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class TeamsResourceTest extends ResourceTest {
+
+ @ClassRule
+ public static JerseyTestRule jersey = new JerseyTestRule(new ResourceConfig());
+
+ @Test
+ public void listTeamsShouldReturnPaginatedTeams() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ qm.createTeam("team0");
+ qm.createTeam("team1");
+
+ Response response = jersey.target("/teams")
+ .queryParam("limit", 2)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ final JsonObject responseJson = parseJsonObject(response);
+ assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """
+ {
+ "teams": [
+ {
+ "name": "Test Users",
+ "api_keys": 1,
+ "members": 0
+ },
+ {
+ "name": "team0",
+ "api_keys": 0,
+ "members": 0
+ }
+ ],
+ "_pagination": {
+ "links": {
+ "self": "${json-unit.any-string}",
+ "next": "${json-unit.any-string}"
+ }
+ }
+ }
+ """);
+
+ final var nextPageUri = URI.create(
+ responseJson
+ .getJsonObject("_pagination")
+ .getJsonObject("links")
+ .getString("next"));
+
+ response = jersey.target(nextPageUri)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "teams": [
+ {
+ "name": "team1",
+ "api_keys": 0,
+ "members": 0
+ }
+ ],
+ "_pagination": {
+ "links": {
+ "self": "${json-unit.any-string}"
+ }
+ }
+ }
+ """);
+ }
+
+ @Test
+ public void createTeamShouldCreateTeam() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ qm.createPermission(
+ Permissions.VIEW_PORTFOLIO.name(),
+ Permissions.VIEW_PORTFOLIO.getDescription());
+
+ final Response response = jersey.target("/teams")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .post(Entity.json(/* language=JSON */ """
+ {
+ "name": "foo",
+ "permissions": [
+ "VIEW_PORTFOLIO"
+ ]
+ }
+ """));
+ assertThat(response.getStatus()).isEqualTo(201);
+ assertThat(response.getLocation()).hasPath("/teams/foo");
+ assertThat(getPlainTextBody(response)).isEmpty();
+
+ qm.getPersistenceManager().evictAll();
+
+ final Team team = qm.getTeam("foo");
+ assertThat(team).isNotNull();
+ assertThat(team.getPermissions()).extracting(Permission::getName).containsOnly("VIEW_PORTFOLIO");
+ }
+
+ @Test
+ public void createTeamShouldReturnConflictWhenAlreadyExists() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ qm.createTeam("foo");
+
+ final Response response = jersey.target("/teams")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .post(Entity.json(/* language=JSON */ """
+ {
+ "name": "foo",
+ "permissions": [
+ "VIEW_PORTFOLIO"
+ ]
+ }
+ """));
+ assertThat(response.getStatus()).isEqualTo(409);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "type":"about:blank",
+ "status":409,
+ "title": "Conflict",
+ "detail": "The resource already exists."
+ }
+ """);
+
+ qm.getPersistenceManager().evictAll();
+
+ final Team team = qm.getTeam("foo");
+ assertThat(team).isNotNull();
+ assertThat(team.getPermissions()).isEmpty();
+ }
+
+ @Test
+ public void getTeamShouldReturnTeam() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ final Team team = qm.createTeam("foo");
+ team.setPermissions(List.of(
+ qm.createPermission(
+ Permissions.VIEW_PORTFOLIO.name(),
+ Permissions.VIEW_PORTFOLIO.getDescription()),
+ qm.createPermission(
+ Permissions.VIEW_VULNERABILITY.name(),
+ Permissions.VIEW_VULNERABILITY.getDescription())));
+
+ final Response response = jersey.target("/teams/foo")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "name": "foo",
+ "permissions": [
+ "VIEW_PORTFOLIO",
+ "VIEW_VULNERABILITY"
+ ]
+ }
+ """);
+ }
+
+ @Test
+ public void getTeamShouldReturnNotFoundWhenTeamDoesNotExist() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ final Response response = jersey.target("/teams/foo")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(404);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "type":"about:blank",
+ "status": 404,
+ "title": "Not Found",
+ "detail": "The requested resource could not be found."
+ }
+ """);
+ }
+
+ @Test
+ public void deleteTeamShouldDeleteTeam() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ qm.createTeam("foo");
+
+ final Response response = jersey.target("/teams/foo")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .delete();
+ assertThat(response.getStatus()).isEqualTo(204);
+ assertThat(getPlainTextBody(response)).isEmpty();
+
+ qm.getPersistenceManager().evictAll();
+
+ assertThat(qm.getTeam("foo")).isNull();
+ }
+
+ @Test
+ public void deleteTeamsShouldReturnNotFoundWhenNotExists() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ final Response response = jersey.target("/teams/does-not-exist")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .delete();
+ assertThat(response.getStatus()).isEqualTo(404);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "type": "about:blank",
+ "status": 404,
+ "title": "Not Found",
+ "detail": "The requested resource could not be found."
+ }
+ """);
+ }
+
+ @Test
+ public void listTeamMembershipsShouldReturnPaginatedTeamMemberships() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ final Team teamA = qm.createTeam("team-a");
+ qm.addUserToTeam(qm.createManagedUser("foo", "password"), teamA);
+ qm.addUserToTeam(qm.createManagedUser("bar", "password"), teamA);
+
+ final Team teamB = qm.createTeam("team-b");
+ qm.addUserToTeam(qm.createManagedUser("aaa", "password"), teamB);
+
+ Response response = jersey.target("/team-memberships")
+ .queryParam("limit", 2)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ final JsonObject responseJson = parseJsonObject(response);
+ assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """
+ {
+ "memberships": [
+ {
+ "team_name": "team-a",
+ "username": "bar"
+ },
+ {
+ "team_name": "team-a",
+ "username": "foo"
+ }
+ ],
+ "_pagination": {
+ "links": {
+ "self": "${json-unit.any-string}",
+ "next": "${json-unit.any-string}"
+ }
+ }
+ }
+ """);
+
+ final var nextPageUri = URI.create(
+ responseJson
+ .getJsonObject("_pagination")
+ .getJsonObject("links")
+ .getString("next"));
+
+ response = jersey.target(nextPageUri)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "memberships": [
+ {
+ "team_name": "team-b",
+ "username": "aaa"
+ }
+ ],
+ "_pagination": {
+ "links": {
+ "self": "${json-unit.any-string}"
+ }
+ }
+ }
+ """);
+ }
+
+ @Test
+ public void listTeamMembershipsShouldFilterByTeamName() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ final Team teamA = qm.createTeam("team-a");
+ qm.addUserToTeam(qm.createManagedUser("foo", "password"), teamA);
+
+ final Team teamB = qm.createTeam("team-b");
+ qm.addUserToTeam(qm.createManagedUser("bar", "password"), teamB);
+
+ Response response = jersey.target("/team-memberships")
+ .queryParam("team", "team-b")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "memberships": [
+ {
+ "team_name": "team-b",
+ "username": "bar"
+ }
+ ],
+ "_pagination": {
+ "links": {
+ "self": "${json-unit.any-string}"
+ }
+ }
+ }
+ """);
+ }
+
+ @Test
+ public void listTeamMembershipsShouldFilterByUsername() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ final Team teamA = qm.createTeam("team-a");
+ qm.addUserToTeam(qm.createManagedUser("foo", "password"), teamA);
+
+ final Team teamB = qm.createTeam("team-b");
+ qm.addUserToTeam(qm.createManagedUser("bar", "password"), teamB);
+
+ final Response response = jersey.target("/team-memberships")
+ .queryParam("user", "bar")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "memberships": [
+ {
+ "team_name": "team-b",
+ "username": "bar"
+ }
+ ],
+ "_pagination": {
+ "links": {
+ "self": "${json-unit.any-string}"
+ }
+ }
+ }
+ """);
+ }
+
+ @Test
+ public void createTeamMembershipShouldCreateTeamMembership() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ qm.createTeam("team-foo");
+ qm.createManagedUser("user-bar", "password");
+
+ final Response response = jersey.target("/team-memberships")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .post(Entity.json(/* language=JSON */ """
+ {
+ "team_name": "team-foo",
+ "username": "user-bar"
+ }
+ """));
+ assertThat(response.getStatus()).isEqualTo(201);
+ assertThat(response.getLocation()).isNull();
+ assertThat(getPlainTextBody(response)).isEmpty();
+
+ qm.getPersistenceManager().evictAll();
+
+ assertThat(qm.getTeam("team-foo").getUsers())
+ .extracting(User::getUsername)
+ .contains("user-bar");
+ }
+
+ @Test
+ public void createTeamMembershipShouldReturnConflictWhenAlreadyExists() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ final Team team = qm.createTeam("team-foo");
+ final User user = qm.createManagedUser("user-bar", "password");
+ qm.addUserToTeam(user, team);
+
+ final Response response = jersey.target("/team-memberships")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .post(Entity.json(/* language=JSON */ """
+ {
+ "team_name": "team-foo",
+ "username": "user-bar"
+ }
+ """));
+ assertThat(response.getStatus()).isEqualTo(409);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "type":"about:blank",
+ "status":409,
+ "title": "Conflict",
+ "detail": "The resource already exists."
+ }
+ """);
+ }
+
+ @Test
+ public void createTeamMembershipShouldReturnNotFoundWhenTeamNotExists() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ qm.createManagedUser("user-bar", "password");
+
+ final Response response = jersey.target("/team-memberships")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .post(Entity.json(/* language=JSON */ """
+ {
+ "team_name": "team-foo",
+ "username": "user-bar"
+ }
+ """));
+ assertThat(response.getStatus()).isEqualTo(404);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "type":"about:blank",
+ "status": 404,
+ "title": "Not Found",
+ "detail": "The requested resource could not be found."
+ }
+ """);
+ }
+
+ @Test
+ public void createTeamMembershipShouldReturnNotFoundWhenUserNotExists() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ qm.createTeam("team-foo");
+
+ final Response response = jersey.target("/team-memberships")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .post(Entity.json(/* language=JSON */ """
+ {
+ "team_name": "team-foo",
+ "username": "user-bar"
+ }
+ """));
+ assertThat(response.getStatus()).isEqualTo(404);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "type":"about:blank",
+ "status": 404,
+ "title": "Not Found",
+ "detail": "The requested resource could not be found."
+ }
+ """);
+ }
+
+ @Test
+ public void deleteTeamMembershipShouldDeleteTeamMembership() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ final Team team = qm.createTeam("foo");
+ qm.addUserToTeam(qm.createManagedUser("bar", "password"), team);
+
+ final Response response = jersey.target("/team-memberships")
+ .queryParam("team", "foo")
+ .queryParam("user", "bar")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .delete();
+ assertThat(response.getStatus()).isEqualTo(204);
+
+ qm.getPersistenceManager().evictAll();
+
+ assertThat(team.getUsers()).isEmpty();
+ }
+
+ @Test
+ public void deleteTeamMembershipShouldReturnNotFoundWhenTeamDoesNotExist() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ qm.createManagedUser("bar", "password");
+
+ final Response response = jersey.target("/team-memberships")
+ .queryParam("team", "foo")
+ .queryParam("user", "bar")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .delete();
+ assertThat(response.getStatus()).isEqualTo(404);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "type":"about:blank",
+ "status": 404,
+ "title": "Not Found",
+ "detail": "The requested resource could not be found."
+ }
+ """);
+ }
+
+ @Test
+ public void deleteTeamMembershipShouldReturnNotFoundWhenUserDoesNotExist() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ qm.createTeam("foo");
+
+ final Response response = jersey.target("/team-memberships")
+ .queryParam("team", "foo")
+ .queryParam("user", "bar")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .delete();
+ assertThat(response.getStatus()).isEqualTo(404);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "type":"about:blank",
+ "status": 404,
+ "title": "Not Found",
+ "detail": "The requested resource could not be found."
+ }
+ """);
+ }
+
+ @Test
+ public void deleteTeamMembershipShouldReturnNotFoundWhenMembershipDoesNotExist() {
+ initializeWithPermissions(Permissions.ACCESS_MANAGEMENT);
+
+ qm.createTeam("foo");
+ qm.createManagedUser("bar", "password");
+
+ final Response response = jersey.target("/team-memberships")
+ .queryParam("team", "foo")
+ .queryParam("user", "bar")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .delete();
+ assertThat(response.getStatus()).isEqualTo(404);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "type":"about:blank",
+ "status": 404,
+ "title": "Not Found",
+ "detail": "The requested resource could not be found."
+ }
+ """);
+ }
+
+}
\ No newline at end of file
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/WorkflowsResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/WorkflowsResourceTest.java
new file mode 100644
index 0000000000..e3be771c17
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/WorkflowsResourceTest.java
@@ -0,0 +1,134 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2;
+
+import alpine.server.filters.ApiFilter;
+import alpine.server.filters.AuthenticationFeature;
+import net.javacrumbs.jsonunit.core.Option;
+import org.apache.http.HttpStatus;
+import org.dependencytrack.JerseyTestRule;
+import org.dependencytrack.ResourceTest;
+import org.dependencytrack.model.WorkflowState;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import jakarta.ws.rs.core.Response;
+import java.time.Instant;
+import java.util.Date;
+import java.util.UUID;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.dependencytrack.model.WorkflowStatus.COMPLETED;
+import static org.dependencytrack.model.WorkflowStatus.PENDING;
+import static org.dependencytrack.model.WorkflowStep.BOM_CONSUMPTION;
+import static org.dependencytrack.model.WorkflowStep.BOM_PROCESSING;
+import static org.hamcrest.CoreMatchers.equalTo;
+
+public class WorkflowsResourceTest extends ResourceTest {
+
+ @ClassRule
+ public static JerseyTestRule jersey = new JerseyTestRule(
+ new ResourceConfig(WorkflowsResource.class)
+ .register(ApiFilter.class)
+ .register(AuthenticationFeature.class));
+
+ @Test
+ public void getWorkflowStatusOk() {
+ UUID uuid = UUID.randomUUID();
+ WorkflowState workflowState1 = new WorkflowState();
+ workflowState1.setParent(null);
+ workflowState1.setFailureReason(null);
+ workflowState1.setStep(BOM_CONSUMPTION);
+ workflowState1.setStatus(COMPLETED);
+ workflowState1.setToken(uuid);
+ workflowState1.setUpdatedAt(new Date());
+ var workflowState1Persisted = qm.persist(workflowState1);
+
+ WorkflowState workflowState2 = new WorkflowState();
+ workflowState2.setParent(workflowState1Persisted);
+ workflowState2.setFailureReason(null);
+ workflowState2.setStep(BOM_PROCESSING);
+ workflowState2.setStatus(PENDING);
+ workflowState2.setToken(uuid);
+ workflowState2.setStartedAt(Date.from(Instant.now()));
+ workflowState2.setUpdatedAt(Date.from(Instant.now()));
+ qm.persist(workflowState2);
+
+ Response response = jersey.target("/workflows/" + uuid).request()
+ .header(X_API_KEY, apiKey)
+ .get();
+ assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_OK);
+ final String jsonResponse = getPlainTextBody(response);
+ assertThatJson(jsonResponse)
+ .withOptions(Option.IGNORING_ARRAY_ORDER)
+ .withMatcher("token", equalTo(uuid.toString()))
+ .withMatcher("step1", equalTo("BOM_CONSUMPTION"))
+ .withMatcher("status1", equalTo("COMPLETED"))
+ .withMatcher("step2", equalTo("BOM_PROCESSING"))
+ .withMatcher("status2", equalTo("PENDING"))
+ .isEqualTo(/* language=JSON */ """
+ {
+ "states": [
+ {
+ "token": "${json-unit.matches:token}",
+ "step": "${json-unit.matches:step1}",
+ "status": "${json-unit.matches:status1}",
+ "updated_at": "${json-unit.any-number}"
+ },
+ {
+ "token": "${json-unit.matches:token}",
+ "started_at": "${json-unit.any-number}",
+ "updated_at": "${json-unit.any-number}",
+ "step": "${json-unit.matches:step2}",
+ "status": "${json-unit.matches:status2}"
+ }
+ ]
+ }
+ """);
+ }
+
+ @Test
+ public void getWorkflowStatusNotFound() {
+ WorkflowState workflowState1 = new WorkflowState();
+ workflowState1.setParent(null);
+ workflowState1.setFailureReason(null);
+ workflowState1.setStep(BOM_CONSUMPTION);
+ workflowState1.setStatus(COMPLETED);
+ workflowState1.setToken(UUID.randomUUID());
+ workflowState1.setUpdatedAt(new Date());
+ qm.persist(workflowState1);
+
+ UUID randomUuid = UUID.randomUUID();
+ Response response = jersey.target("/workflows/" + randomUuid).request()
+ .header(X_API_KEY, apiKey)
+ .get();
+
+ assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_NOT_FOUND);
+ assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """
+ {
+ "type":"about:blank",
+ "status": 404,
+ "title": "Not Found",
+ "detail": "The requested resource could not be found."
+ }
+ """);
+ }
+}
\ No newline at end of file
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapperTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapperTest.java
new file mode 100644
index 0000000000..dce59268f2
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapperTest.java
@@ -0,0 +1,96 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2.exception;
+
+import alpine.server.auth.AuthenticationNotRequired;
+import net.javacrumbs.jsonunit.core.Option;
+import org.dependencytrack.JerseyTestRule;
+import org.dependencytrack.model.validation.ValidUuid;
+import org.dependencytrack.resources.v2.ResourceConfig;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import jakarta.validation.constraints.Pattern;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.dependencytrack.resources.v2.OpenApiValidationClientResponseFilter.DISABLE_OPENAPI_VALIDATION;
+
+public class ConstraintViolationExceptionMapperTest {
+
+ @ClassRule
+ public static JerseyTestRule jersey = new JerseyTestRule(
+ new ResourceConfig()
+ .register(JsonProcessingExceptionMapperTest.TestResource.class));
+
+ @Test
+ public void test() {
+ final Response response = jersey.target("/test/not-a-uuid")
+ .queryParam("foo", "666")
+ .request()
+ .property(DISABLE_OPENAPI_VALIDATION, "true")
+ .get();
+ assertThat(response.getStatus()).isEqualTo(400);
+ assertThatJson(response.readEntity(String.class))
+ .withOptions(Option.IGNORING_ARRAY_ORDER)
+ .isEqualTo(/* language=JSON */ """
+ {
+ "type": "about:blank",
+ "status": 400,
+ "title": "Bad Request",
+ "detail": "The request could not be processed because it failed validation.",
+ "errors": [
+ {
+ "message": "must match \\"^[a-z]+$\\"",
+ "path": "get.foo",
+ "value": "666"
+ },
+ {
+ "message": "Invalid UUID",
+ "path": "get.uuid",
+ "value": "not-a-uuid"
+ }
+ ]
+ }
+ """);
+ }
+
+ @Path("/test")
+ public static class TestResource {
+
+ @GET
+ @Path("/{uuid}")
+ @Produces(MediaType.APPLICATION_JSON)
+ @AuthenticationNotRequired
+ public Response get(@PathParam("uuid") @ValidUuid final String uuid,
+ @QueryParam("optionalUuid") @ValidUuid final String optionalUuid,
+ @QueryParam("foo") @Pattern(regexp = "^[a-z]+$") final String foo) {
+ return Response.noContent().build();
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapperTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapperTest.java
new file mode 100644
index 0000000000..c2172663ea
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapperTest.java
@@ -0,0 +1,96 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2.exception;
+
+import alpine.server.auth.AuthenticationNotRequired;
+import org.dependencytrack.JerseyTestRule;
+import org.dependencytrack.resources.v2.ResourceConfig;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.ServerErrorException;
+import jakarta.ws.rs.core.Response;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.dependencytrack.resources.v2.OpenApiValidationClientResponseFilter.DISABLE_OPENAPI_VALIDATION;
+
+public class DefaultExceptionMapperTest {
+
+ @ClassRule
+ public static JerseyTestRule jersey = new JerseyTestRule(
+ new ResourceConfig()
+ .register(JsonProcessingExceptionMapperTest.TestResource.class));
+
+ @Test
+ public void shouldReturnInternalServerError() {
+ final Response response = jersey.target("/test/error")
+ .request()
+ .property(DISABLE_OPENAPI_VALIDATION, "true")
+ .get();
+ assertThat(response.getStatus()).isEqualTo(500);
+ assertThatJson(response.readEntity(String.class)).isEqualTo(/* language=JSON */ """
+ {
+ "type": "about:blank",
+ "status": 500,
+ "title": "Unexpected error",
+ "detail": "An error occurred that was not anticipated."
+ }
+ """);
+ }
+
+ @Test
+ public void shouldReturnGivenStatusForServerErrors() {
+ final Response response = jersey.target("/test/server-error")
+ .request()
+ .property(DISABLE_OPENAPI_VALIDATION, "true")
+ .get();
+ assertThat(response.getStatus()).isEqualTo(503);
+ assertThatJson(response.readEntity(String.class)).isEqualTo(/* language=JSON */ """
+ {
+ "type": "about:blank",
+ "status": 503,
+ "title": "Unexpected error",
+ "detail": "An error occurred that was not anticipated."
+ }
+ """);
+ }
+
+ @Path("/test")
+ public static class TestResource {
+
+ @GET
+ @Path("/error")
+ @AuthenticationNotRequired
+ public Response fail() throws Exception {
+ throw new ClassNotFoundException("test");
+ }
+
+ @GET
+ @Path("/server-error")
+ @AuthenticationNotRequired
+ public Response serverError() {
+ throw new ServerErrorException(Response.status(Response.Status.SERVICE_UNAVAILABLE).build());
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapperTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapperTest.java
new file mode 100644
index 0000000000..a437321487
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapperTest.java
@@ -0,0 +1,105 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.resources.v2.exception;
+
+import alpine.server.auth.AuthenticationNotRequired;
+import com.fasterxml.jackson.core.JsonGenerationException;
+import org.dependencytrack.JerseyTestRule;
+import org.dependencytrack.resources.v2.ResourceConfig;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.dependencytrack.resources.v2.OpenApiValidationClientResponseFilter.DISABLE_OPENAPI_VALIDATION;
+
+public class JsonProcessingExceptionMapperTest {
+
+ @ClassRule
+ public static JerseyTestRule jersey = new JerseyTestRule(
+ new ResourceConfig()
+ .register(TestResource.class));
+
+ @Test
+ public void shouldReturnInternalServerErrorForServerSideJsonException() {
+ final Response response = jersey.target("/test/json-generation")
+ .request()
+ .property(DISABLE_OPENAPI_VALIDATION, "true")
+ .get();
+ assertThat(response.getStatus()).isEqualTo(500);
+ assertThatJson(response.readEntity(String.class)).isEqualTo(/* language=JSON */ """
+ {
+ "type": "about:blank",
+ "status": 500,
+ "title": "Unexpected error",
+ "detail": "An error occurred that was not anticipated."
+ }
+ """);
+ }
+
+ @Test
+ public void shouldReturnBadRequestForClientSideJsonException() {
+ final Response response = jersey.target("/test")
+ .request()
+ .property(DISABLE_OPENAPI_VALIDATION, "true")
+ .post(Entity.json("[]"));
+ assertThat(response.getStatus()).isEqualTo(400);
+ assertThatJson(response.readEntity(String.class)).isEqualTo(/* language=JSON */ """
+ {
+ "type": "about:blank",
+ "status": 400,
+ "title": "JSON Processing Failed",
+ "detail": "The provided JSON could not be processed."
+ }
+ """);
+ }
+
+ @Path("/test")
+ public static class TestResource {
+
+ public record TestRequest(String name) {
+ }
+
+ @POST
+ @Path("/")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @AuthenticationNotRequired
+ public Response test(final TestRequest request) {
+ return Response.ok(request.name()).build();
+ }
+
+ @GET
+ @Path("/json-generation")
+ @AuthenticationNotRequired
+ @SuppressWarnings("deprecation")
+ public Response jsonGeneration() throws Exception {
+ throw new JsonGenerationException("boom");
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/dev/scripts/openapi-lint.sh b/dev/scripts/openapi-lint.sh
new file mode 100755
index 0000000000..ab1edd1955
--- /dev/null
+++ b/dev/scripts/openapi-lint.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+
+# This file is part of Dependency-Track.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+# Copyright (c) OWASP Foundation. All Rights Reserved.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -P -- "$(dirname "$0")" && pwd -P)"
+API_MODULE_DIR="$(cd -P -- "${SCRIPT_DIR}/../../api" && pwd -P)"
+
+# NB: Currently there's no arm64 image variant.
+docker run --rm -it -w /work \
+ --platform linux/amd64 \
+ -v "${API_MODULE_DIR}:/work" \
+ stoplight/spectral lint \
+ --ruleset src/main/spectral/ruleset.yaml \
+ src/main/openapi/openapi.yaml
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 42b4c0ac67..1157cc85cb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -29,6 +29,7 @@
support/datanucleus-pluginsupport/liquibase
+ apialpineprotopersistence-migration
@@ -147,6 +148,11 @@
${project.version}
+
+ org.dependencytrack
+ api
+ ${project.version}
+ org.dependencytrackapiserver