feat: As a developer I want to have a global and customizable exception handeling

This commit is contained in:
Marcel Haag 2022-06-29 13:14:59 +02:00
parent f9ce7606f7
commit f6732f545a
17 changed files with 426 additions and 44 deletions

View File

@ -23,10 +23,10 @@
* Postman
## Application Architecture
![alt architecture](./wiki/SecurityC4PO_Architecture.png)
![alt architecture](./wiki/C4PO-Architecture.png)
## Data Structure
![alt architecture](./wiki/SecurityC4PO_Data_Structure.png)
![alt datastructure](./wiki/C4PO-Datastructure.png)
### Conventions
* Branch: `<initial>_c4po_<issuenumber>`
@ -38,3 +38,6 @@ Execute 'c4po.sh' and all services will run on a dev server.
### Testuser Credentials:
* Username: ttt
* Password: Test1234!
## C4PO Roadmap
![alt roadmap](./wiki/C4PO-Roadmap.png)

View File

@ -60,6 +60,7 @@ spotbugs {
val snippetsDir = file("build/generated-snippets")
dependencies {
implementation("org.json:json:20140107")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-joda:2.11.3")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")
@ -76,7 +77,10 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.modelmapper:modelmapper:2.3.2")
api("org.springframework.boot:spring-boot-starter-test")
api("org.springframework.security:spring-security-jwt:1.1.1.RELEASE")
api("net.logstash.logback:logstash-logback-encoder:6.2")
api("ch.qos.logback:logback-classic:1.2.3")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0")

View File

@ -1,8 +1,9 @@
{
"info": {
"_postman_id": "1823096a-1cb2-438d-9872-73ec0ca94cfa",
"_postman_id": "6537da59-5c7a-478d-bf24-09a39022a690",
"name": "security-c4po-api",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "5225213"
},
"item": [
{
@ -93,9 +94,34 @@
{
"name": "deleteProject",
"request": {
"method": "GET",
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICItdG1lbEV0ZHhGTnRSMW9aNXlRdE5jaFFpX0RVN2VNeV9YcU44aXY0S3hzIn0.eyJleHAiOjE2NTcwNDI3NDcsImlhdCI6MTY1NzA0MjQ0NywianRpIjoiZGFjYWY0MzItNWRlMS00ZGU1LWE0ZjgtZmExNmYyNDMwMDRhIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4ODg4L2F1dGgvcmVhbG1zL2M0cG9fcmVhbG1fbG9jYWwiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiMTBlMDZkN2EtOGRkMC00ZWNkLTg5NjMtMDU2YjQ1MDc5YzRmIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiYzRwb19sb2NhbCIsInNlc3Npb25fc3RhdGUiOiI3Nzc1ZGExMS0xYWI3LTQyZjItYjJmZC0yNDFmZTE0NjAyYTgiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbIioiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImM0cG9fdXNlciIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJjNHBvX2xvY2FsIjp7InJvbGVzIjpbInVzZXIiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSBlbWFpbCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6InRlc3QgdXNlciIsInByZWZlcnJlZF91c2VybmFtZSI6InR0dCIsImdpdmVuX25hbWUiOiJ0ZXN0IiwiZmFtaWx5X25hbWUiOiJ1c2VyIn0.EqTkweqw6KkmttmI7KyvZM-yoo4MczNo8Nlj1zRUHgzXDnQ2JbSCXLAFjvUFYTrCrGIlTn23Ojsx4WhVwvIkBmKmn8ZsrCifNwJfOYKbtu6rV0unMTJqXV1imdaRecti95wJLnFdKQf_gIPUALLzTIXH_klPZfz5zKup7OfWMXlrKhRHRzYbg0hFHBFlpd9QCYiNWzh4Z2_vq-V2YESViuCPxN6sFacR_fvz6-d2y-zWS6XHvHdblLBPKsMIn9EBTGfx49TQo-CDgUichi_w8VWMkk3vUyRUH2wl-CIz2qrYdA5y-PzAPju5yTxjgydGn-7LIIFCiOpDStdREPyFBA",
"type": "string"
},
{
"key": "undefined",
"type": "any"
}
]
},
"method": "DELETE",
"header": [],
"url": null
"url": {
"raw": "http://localhost:8443/projects/41051d0a-63ef-4290-b984-e6fbd736f218",
"protocol": "http",
"host": [
"localhost"
],
"port": "8443",
"path": [
"projects",
"41051d0a-63ef-4290-b984-e6fbd736f218"
]
}
},
"response": []
},

View File

@ -67,6 +67,10 @@ include::{snippets}/deleteProject/http-request.adoc[]
include::{snippets}/deleteProject/http-response.adoc[]
If the project has already been deleted "204 No Content" is following.
include::{snippets}/deleteNotExistingProject/http-response.adoc[]
==== Response structure
include::{snippets}/deleteProject/response-fields.adoc[]

View File

@ -0,0 +1,16 @@
package com.securityc4po.api.configuration.error.handler
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.classic.spi.ThrowableProxy
class ErrorCodeEncoder {
/*override*/ fun convert(event: ILoggingEvent): String {
return if (event.throwableProxy != null) {
val throwable = event.throwableProxy as ThrowableProxy
val ex = throwable.throwable as C4POBaseException
ex.errorcode.code.toString()
} else {
""
}
}
}

View File

@ -0,0 +1,37 @@
package com.securityc4po.api.configuration.error.handler
enum class Errorcode(val code: Int) {
// 1XXX Information Not Found
ProjectsNotFound(1001),
ProjectNotFound(1002),
PentestNotFound(1003),
// 2XXX Already Changed
ProjectAlreadyChanged(2001),
PentestAlreadyChanged(2002),
// 3XXX Invalid Model
ProjectInvalid(3000),
PentestInvalid(3001),
InsufficientData(3002),
InvalidToken(3003),
TokenWithoutField(3004),
UserIdIsEmpty(3005),
// 4XXX Unauthorized
ProjectAdjustmentNotAuthorized(4000),
PentestAdjustmentNotAuthorized(4001),
// 5XXX Server Errors
UserIdDoesNotMatch(5000),
// 6XXX Failed transaction
ProjectDeletionFailed(6000),
PentestDeletionFailed(6001),
ProjectUpdateFailed(6002),
PentestUpdateFailed(6003),
ProjectFetchingFailed(6004),
PentestFetchingFailed(6005),
ProjectInsertionFailed(6006),
PentestInsertionFailed(6007),
}

View File

@ -0,0 +1,79 @@
package com.securityc4po.api.configuration.error.handler
import org.springframework.boot.web.error.ErrorAttributeOptions
import org.springframework.boot.web.reactive.error.DefaultErrorAttributes
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClientResponseException
import org.springframework.web.reactive.function.server.ServerRequest
import org.json.JSONObject
@Component
class GlobalErrorAttributes : DefaultErrorAttributes() {
override fun getErrorAttributes(request: ServerRequest, options: ErrorAttributeOptions): Map<String, Any> {
val map = super.getErrorAttributes(request, options)
/////////////////////////
////// Exceptions ///////
/////////////////////////
if (getError(request) is EntityNotFoundException) {
val ex = getError(request) as EntityNotFoundException
map.put("message", ex.errormessage)
map.put("errorcode", ex.errorcode.code)
map.put("status", ex.status.value())
map.put("error", ex.status.reasonPhrase)
return map
}
if (getError(request) is EntityAlreadyChangedException) {
val ex = getError(request) as EntityAlreadyChangedException
map.put("message", ex.errormessage)
map.put("errorcode", ex.errorcode.code)
map.put("status", ex.status.value())
map.put("error", ex.status.reasonPhrase)
return map
}
if (getError(request) is InvalidModelException) {
val ex = getError(request) as InvalidModelException
map.put("message", ex.errormessage)
map.put("errorcode", ex.errorcode.code)
map.put("status", ex.status.value())
map.put("error", ex.status.reasonPhrase)
return map
}
if (getError(request) is UnauthorizedException) {
val ex = getError(request) as UnauthorizedException
map.put("message", ex.errormessage)
map.put("errorcode", ex.errorcode.code)
map.put("status", ex.status.value())
map.put("error", ex.status.reasonPhrase)
return map
}
if (getError(request) is TransactionInterruptedException) {
val ex = getError(request) as TransactionInterruptedException
map.put("message", ex.errormessage)
map.put("errorcode", ex.errorcode.code)
map.put("status", ex.status.value())
map.put("error", ex.status.reasonPhrase)
return map
}
if (getError(request) is WebClientResponseException) {
val ex = getError(request) as WebClientResponseException
val body = JSONObject(ex.responseBodyAsString)
map.put("message", body.get("message"))
map.put("errorcode", body.get("errorcode"))
map.put("status", ex.rawStatusCode)
map.put("error", ex.statusText)
return map
}
map.put("message", map["message"])
map.put("status", map["status"] as Int)
map.put("error", map["error"])
return map
}
}

View File

@ -0,0 +1,70 @@
package com.securityc4po.api.configuration.error.handler
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.http.HttpStatus
import org.springframework.boot.autoconfigure.web.ResourceProperties
import org.springframework.http.codec.ServerCodecConfigurer
import org.springframework.context.ApplicationContext
import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler
import org.springframework.boot.web.error.ErrorAttributeOptions
import org.springframework.boot.web.reactive.error.ErrorAttributes
import org.springframework.core.annotation.Order
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.server.*
import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Mono
@Component
@Order(-2)
class GlobalErrorWebExceptionHandler(g: GlobalErrorAttributes, applicationContext: ApplicationContext,
serverCodecConfigurer: ServerCodecConfigurer) : AbstractErrorWebExceptionHandler(g, ResourceProperties(), applicationContext) {
init {
super.setMessageWriters(serverCodecConfigurer.writers)
super.setMessageReaders(serverCodecConfigurer.readers)
}
override fun getRoutingFunction(errorAttributes: ErrorAttributes): RouterFunction<ServerResponse> {
return RouterFunctions.route(RequestPredicates.all(), HandlerFunction { this.renderErrorResponse(it) })
}
private fun renderErrorResponse(request: ServerRequest): Mono<ServerResponse> {
val errorPropertiesMap = getErrorAttributes(request, ErrorAttributeOptions.defaults())
return ServerResponse.status(HttpStatus.valueOf(errorPropertiesMap["status"] as Int))
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(errorPropertiesMap))
}
}
open class C4POBaseException (val errorcode: Errorcode, httpStatus: HttpStatus): ResponseStatusException(httpStatus)
internal class EntityNotFoundException(val errormessage: String, code: Errorcode): C4POBaseException(code, HttpStatus.NOT_FOUND)
internal class EntityAlreadyChangedException(val errormessage: String, code: Errorcode) : C4POBaseException(code, HttpStatus.CONFLICT)
internal class InvalidModelException (val errormessage: String, code: Errorcode) : C4POBaseException(code, HttpStatus.BAD_REQUEST)
internal class UnauthorizedException (val errormessage: String, code: Errorcode) : C4POBaseException(code, HttpStatus.UNAUTHORIZED)
internal class TransactionInterruptedException(val errormessage: String, code: Errorcode): C4POBaseException(code, HttpStatus.FAILED_DEPENDENCY)
/**
* This method is used to throw an exception, and log a message if needed, if a certain condition is true.
*
* @param require of type boolean. It is the condition to check on.
* @param logging lambda expression for optional logging.
* @param mappedException of type OpenSpaceBaseException.
*/
inline fun validate(require: Boolean, logging: () -> Unit, mappedException: C4POBaseException) {
if (!require) {
throw wrappedException(logging= { logging() }, mappedException = mappedException)
}
}
/**
* This method is used to reduce some lines of code when we throw an exception.
* It has an optional logging part using a lambda expression.
*
* @param logging lambda expression for optional logging.
* @param mappedException of type OpenSpaceBaseException.
*/
inline fun wrappedException(logging: () -> Unit, mappedException: C4POBaseException): C4POBaseException {
logging()
return mappedException
}

View File

@ -17,6 +17,17 @@ data class Project(
val createdBy: String
)
fun buildProject(body: ProjectRequestBody, projectEntity: ProjectEntity): Project{
return Project(
id = projectEntity.data.id,
client = body.client,
title = body.title,
createdAt = projectEntity.data.createdAt,
tester = body.tester,
createdBy = projectEntity.data.createdBy
)
}
fun Project.toProjectResponseBody(): ResponseBody {
return mapOf(
"id" to id,
@ -47,9 +58,23 @@ fun ProjectOverview.toProjectOverviewResponseBody(): ResponseBody {
data class ProjectRequestBody(
val client: String,
val title: String,
val tester: String? = null
val tester: String
)
/**
* Validates if a [ProjectRequestBody] is valid
*
* @return Boolean describing if the body is valid
*/
fun ProjectRequestBody.isValid(): Boolean {
return when {
this.client.isBlank() -> false
this.title.isBlank() -> false
this.tester.isBlank() -> false
else -> true
}
}
fun ProjectRequestBody.toProject(): Project {
return Project(
id = UUID.randomUUID().toString(),

View File

@ -7,7 +7,7 @@ import com.securityc4po.api.ResponseBody
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Mono
import java.util.*
import reactor.kotlin.core.publisher.switchIfEmpty
@RestController
@RequestMapping("/projects")
@ -47,6 +47,8 @@ class ProjectController(private val projectService: ProjectService) {
fun deleteProject(@PathVariable(value = "id") id: String): Mono<ResponseEntity<ResponseBody>> {
return this.projectService.deleteProject(id).map{
ResponseEntity.ok().body(it.toProjectDeleteResponseBody())
}.switchIfEmpty {
Mono.just(ResponseEntity.noContent().build<ResponseBody>())
}
}

View File

@ -2,14 +2,15 @@ package com.securityc4po.api.project
import com.securityc4po.api.configuration.BC_BAD_CAST_TO_ABSTRACT_COLLECTION
import com.securityc4po.api.configuration.MESSAGE_BAD_CAST_TO_ABSTRACT_COLLECTION
import com.securityc4po.api.configuration.error.handler.*
import com.securityc4po.api.configuration.error.handler.EntityNotFoundException
import com.securityc4po.api.configuration.error.handler.InvalidModelException
import com.securityc4po.api.extensions.getLoggerFor
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import reactor.kotlin.core.publisher.switchIfEmpty
import java.time.Instant
import java.util.*
@Service
@SuppressFBWarnings(BC_BAD_CAST_TO_ABSTRACT_COLLECTION, MESSAGE_BAD_CAST_TO_ABSTRACT_COLLECTION)
@ -20,35 +21,86 @@ class ProjectService(private val projectRepository: ProjectRepository) {
/**
* Get all [Project]s
*
* @throws [EntityNotFoundException] if there are no [Project]s in collection
* @return list of [Project]
*/
fun getProjects(): Mono<List<Project>> {
return projectRepository.findAll().collectList().map {
it.map { projectEntity -> projectEntity.toProject() }
}.switchIfEmpty {
val msg = "No projects not found."
val ex = EntityNotFoundException(msg, Errorcode.ProjectsNotFound)
logger.warn(msg, ex)
throw ex
}
}
/**
* Save [Project]
*
* @throws [InvalidModelException] if the [Project] is invalid
* @throws [TransactionInterruptedException] if the [Project] could not be stored
* @return saved [Project]
*/
fun saveProject(body: ProjectRequestBody): Mono<Project> {
validate(
require = body.isValid(),
logging = { logger.warn("Project not valid.") },
mappedException = InvalidModelException(
"Project not valid.", Errorcode.ProjectInvalid
)
)
val project = body.toProject()
val projectEntity = ProjectEntity(project)
return projectRepository.insert(projectEntity).map {
it.toProject()
}.doOnError {
logger.warn("Project could not be stored in Database. Thrown exception: ", it)
throw wrappedException(
logging = { logger.warn("Project could not be stored in Database. Thrown exception: ", it) },
mappedException = TransactionInterruptedException(
"Project could not be stored.",
Errorcode.ProjectInsertionFailed
)
)
}
}
/**
* Delete [Project]
*
* @throws [TransactionInterruptedException] if the [Project] could not be deleted
* @return status code of deleted [Project]
*/
fun deleteProject(id: String): Mono<Project> {
return projectRepository.findProjectById(id).switchIfEmpty{
logger.info("Project with id $id not found. Deletion not possible.")
logger.info("Project with id $id not found. Deletion not necessary.")
Mono.empty()
}.flatMap{ projectEntity: ProjectEntity ->
val project = projectEntity.toProject()
projectRepository.deleteProjectById(id).map{project}
}.onErrorMap {
TransactionInterruptedException(
"Deleting Project failed!",
Errorcode.ProjectDeletionFailed
)
}
}
/**
* Update [Project]
*
* @throws [InvalidModelException] if the [Project] is invalid
* @throws [TransactionInterruptedException] if the [Project] could not be updated
* @return updated [Project]
*/
fun updateProject(id: String, body: ProjectRequestBody): Mono<Project> {
validate(
require = body.isValid(),
logging = { logger.warn("Project not valid.") },
mappedException = InvalidModelException(
"Project not valid.", Errorcode.ProjectInvalid
)
)
return projectRepository.findProjectById(id).switchIfEmpty{
logger.info("Project with id $id not found. Updating not possible.")
Mono.empty()
@ -58,19 +110,14 @@ class ProjectService(private val projectRepository: ProjectRepository) {
projectRepository.save(projectEntity).map{
it.toProject()
}.doOnError {
logger.warn("Project could not be updated in Database. Thrown exception: ", it)
throw wrappedException(
logging = { logger.warn("Project could not be updated in Database. Thrown exception: ", it) },
mappedException = TransactionInterruptedException(
"Project could not be updated.",
Errorcode.ProjectInsertionFailed
)
)
}
}
}
}
private fun buildProject(body: ProjectRequestBody, projectEntity: ProjectEntity): Project{
return Project(
id = projectEntity.data.id,
client = body.client,
title = body.title,
createdAt = projectEntity.data.createdAt,
tester = body.tester,
createdBy = projectEntity.data.createdBy
)
}

View File

@ -16,6 +16,7 @@ import org.springframework.data.mongodb.core.query.Query
import org.springframework.restdocs.operation.preprocess.Preprocessors
import org.springframework.restdocs.payload.JsonFieldType
import org.springframework.restdocs.payload.PayloadDocumentation
import org.springframework.restdocs.request.RequestDocumentation
import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation
import reactor.core.publisher.Mono
@ -179,6 +180,34 @@ class ProjectControllerDocumentationTest : BaseDocumentationIntTest() {
)
}
@Test
fun deleteNotExistingProject() {
val randomUUID = "f85ee127-83b7-4ba3-8940-7b8d1e0a1c6e"
webTestClient.delete().uri("/projects/{id}", randomUUID)
.header("Authorization", "Bearer $tokenAdmin")
.exchange()
.expectStatus().isNoContent
.expectHeader().valueEquals("Application-Name", "SecurityC4PO")
.expectBody()
.consumeWith(
WebTestClientRestDocumentation.document(
"{methodName}",
Preprocessors.preprocessRequest(
Preprocessors.prettyPrint(),
Preprocessors.modifyUris().removePort(),
Preprocessors.removeHeaders("Host", "Content-Length")
),
Preprocessors.preprocessResponse(
Preprocessors.prettyPrint()
),
RequestDocumentation.relaxedPathParameters(
RequestDocumentation.parameterWithName("id")
.description("The id of the deleted project")
)
)
)
}
val project = Project(
id = "4f6567a8-76fd-487b-8602-f82d0ca4d1f9",
client = "E Corp",

View File

@ -128,6 +128,17 @@ class ProjectControllerIntTest : BaseIntTest() {
.expectBody().json(Json.write(projectTwo.toProjectDeleteResponseBody()))
}
@Test
fun `delete not existing project`() {
val randomUUID = "f85ee127-83b7-4ba3-8940-7b8d1e0a1c6e"
webTestClient.delete().uri("/projects/{id}", randomUUID)
.header("Authorization", "Bearer $tokenAdmin")
.exchange()
.expectStatus().isNoContent
.expectHeader().valueEquals("Application-Name", "SecurityC4PO")
.expectBody().isEmpty
}
val projectTwo = Project(
id = "61360a47-796b-4b3f-abf9-c46c668596c5",
client = "Allsafe",

View File

@ -1,26 +1,55 @@
[{
"_id": "1234567890123456789012345",
"_class": "com.securityc4po.api.project.ProjectEntity",
"_id": {
"$oid": "62c4018f18f1f463ed1e11be"
},
"lastModified": {
"$date": {
"$numberLong": "1657012623629"
}
},
"data": {
"_id": "92e7a4e3-2968-4831-a2bd-94c3b33f85fd",
"_id": "41051d0a-63ef-4290-b984-e6fbd736f218",
"client": "E Corp",
"title": "Some Mock API (v1.0) Scanning",
"createdAt": "2021-01-10T18:05:00Z",
"createdAt": "2022-07-05T09:17:03.629331Z",
"tester": "Novatester",
"createdBy": "f8aab31f-4925-4242-a6fa-f98135b4b031"
"createdBy": "ca447a34-cac3-495e-9295-0a5bf5de502a"
},
"lastModified": "2021-01-01T12:00:00.000Z"
},
{
"_id": "1234567890123456789023456",
"_class": "com.securityc4po.api.project.ProjectEntity",
"data": {
"_id": "9bcde7fe-df3d-4c6b-b392-36f4087b0446",
"client": "Allsafe",
"title": "CashMyData (iOS)",
"createdAt": "2021-01-10T18:05:00Z",
"tester": "Elliot",
"createdBy": "f8aab31f-4925-4242-a6fa-f98135b4b031"
},
"lastModified": "2021-01-01T12:00:00.000Z"
}]
"_class": "com.securityc4po.api.project.ProjectEntity"
},{
"_id": {
"$oid": "62c401b718f1f463ed1e11bf"
},
"lastModified": {
"$date": {
"$numberLong": "1657012663386"
}
},
"data": {
"_id": "0fa17ddb-7094-4f9c-b295-3433244c32c2",
"client": "Allsafe",
"title": "CashMyData (iOS)",
"createdAt": "2022-07-05T09:17:43.386782Z",
"tester": "Elliot",
"createdBy": "1ac9753a-5a62-4bf7-8412-6fde779ea33a"
},
"_class": "com.securityc4po.api.project.ProjectEntity"
},{
"_id": {
"$oid": "62c401e318f1f463ed1e11c0"
},
"lastModified": {
"$date": {
"$numberLong": "1657012707557"
}
},
"data": {
"_id": "85aa8d79-5899-4b68-894c-d07f3b168cd4",
"client": "Novatec",
"title": "Openspace log4J",
"createdAt": "2022-07-05T09:18:27.557740Z",
"tester": "mhg",
"createdBy": "3201f6f8-10a4-4826-9b49-b6bdef28b152"
},
"_class": "com.securityc4po.api.project.ProjectEntity"
}]

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 465 KiB

After

Width:  |  Height:  |  Size: 465 KiB

BIN
wiki/C4PO-Roadmap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB