From 82159b577332ed19020c0072ff8da9c8de8f3414 Mon Sep 17 00:00:00 2001 From: Jorden_Reuter Date: Fri, 9 Feb 2024 16:11:09 +0100 Subject: [PATCH 1/2] feat: added modificationCount and updated client structure --- .../bff/rs/controller/AnnouncementRestController.java | 4 ++-- .../onecx/announcement/bff/rs/mappers/AnnouncementMapper.java | 2 +- .../announcement/bff/rs/mappers/ProblemDetailMapper.java | 2 +- src/main/openapi/openapi-announcement-bff.yaml | 4 ++++ src/main/resources/application.properties | 2 +- .../announcement/bff/rs/AnnouncementRestControllerTest.java | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/tkit/onecx/announcement/bff/rs/controller/AnnouncementRestController.java b/src/main/java/org/tkit/onecx/announcement/bff/rs/controller/AnnouncementRestController.java index f00642d..54b7cf6 100644 --- a/src/main/java/org/tkit/onecx/announcement/bff/rs/controller/AnnouncementRestController.java +++ b/src/main/java/org/tkit/onecx/announcement/bff/rs/controller/AnnouncementRestController.java @@ -15,10 +15,10 @@ import org.tkit.onecx.announcement.bff.rs.mappers.ProblemDetailMapper; import org.tkit.quarkus.log.cdi.LogService; -import gen.org.tkit.onecx.announcement.bff.clients.api.AnnouncementInternalApi; -import gen.org.tkit.onecx.announcement.bff.clients.model.*; import gen.org.tkit.onecx.announcement.bff.rs.internal.AnnouncementInternalApiService; import gen.org.tkit.onecx.announcement.bff.rs.internal.model.*; +import gen.org.tkit.onecx.announcement.client.api.AnnouncementInternalApi; +import gen.org.tkit.onecx.announcement.client.model.*; @ApplicationScoped @Transactional(value = Transactional.TxType.NOT_SUPPORTED) diff --git a/src/main/java/org/tkit/onecx/announcement/bff/rs/mappers/AnnouncementMapper.java b/src/main/java/org/tkit/onecx/announcement/bff/rs/mappers/AnnouncementMapper.java index cee3179..e3c94fc 100644 --- a/src/main/java/org/tkit/onecx/announcement/bff/rs/mappers/AnnouncementMapper.java +++ b/src/main/java/org/tkit/onecx/announcement/bff/rs/mappers/AnnouncementMapper.java @@ -4,8 +4,8 @@ import org.mapstruct.Mapping; import org.tkit.quarkus.rs.mappers.OffsetDateTimeMapper; -import gen.org.tkit.onecx.announcement.bff.clients.model.*; import gen.org.tkit.onecx.announcement.bff.rs.internal.model.*; +import gen.org.tkit.onecx.announcement.client.model.*; @Mapper(uses = { OffsetDateTimeMapper.class }) public interface AnnouncementMapper { diff --git a/src/main/java/org/tkit/onecx/announcement/bff/rs/mappers/ProblemDetailMapper.java b/src/main/java/org/tkit/onecx/announcement/bff/rs/mappers/ProblemDetailMapper.java index 57b22c0..753df27 100644 --- a/src/main/java/org/tkit/onecx/announcement/bff/rs/mappers/ProblemDetailMapper.java +++ b/src/main/java/org/tkit/onecx/announcement/bff/rs/mappers/ProblemDetailMapper.java @@ -4,8 +4,8 @@ import org.mapstruct.Mapping; import org.tkit.quarkus.rs.mappers.OffsetDateTimeMapper; -import gen.org.tkit.onecx.announcement.bff.clients.model.ProblemDetailResponse; import gen.org.tkit.onecx.announcement.bff.rs.internal.model.ProblemDetailResponseDTO; +import gen.org.tkit.onecx.announcement.client.model.ProblemDetailResponse; @Mapper(uses = { OffsetDateTimeMapper.class }) public interface ProblemDetailMapper { diff --git a/src/main/openapi/openapi-announcement-bff.yaml b/src/main/openapi/openapi-announcement-bff.yaml index 0818401..5b3cad3 100644 --- a/src/main/openapi/openapi-announcement-bff.yaml +++ b/src/main/openapi/openapi-announcement-bff.yaml @@ -211,7 +211,11 @@ components: required: - title - startDate + - modificationCount properties: + modificationCount: + format: int32 + type: integer title: type: string content: diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c65f7f1..54f244b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -8,7 +8,7 @@ org.eclipse.microprofile.rest.client.propagateHeaders=apm-principal-token quarkus.openapi-generator.codegen.input-base-dir=target/tmp/openapi quarkus.openapi-generator.codegen.spec.onecx_announcement_svc_yaml.config-key=onecx_announcement_svc -quarkus.openapi-generator.codegen.spec.onecx_announcement_svc_yaml.base-package=gen.org.tkit.onecx.announcement.bff.clients +quarkus.openapi-generator.codegen.spec.onecx_announcement_svc_yaml.base-package=gen.org.tkit.onecx.announcement.client quarkus.openapi-generator.codegen.spec.onecx_announcement_svc_yaml.return-response=true quarkus.openapi-generator.codegen.spec.onecx_announcement_svc.additional-api-type-annotations=@org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders; diff --git a/src/test/java/org/tkit/onecx/announcement/bff/rs/AnnouncementRestControllerTest.java b/src/test/java/org/tkit/onecx/announcement/bff/rs/AnnouncementRestControllerTest.java index 4aa9968..45d97c4 100644 --- a/src/test/java/org/tkit/onecx/announcement/bff/rs/AnnouncementRestControllerTest.java +++ b/src/test/java/org/tkit/onecx/announcement/bff/rs/AnnouncementRestControllerTest.java @@ -21,8 +21,8 @@ import org.tkit.onecx.announcement.bff.rs.controller.AnnouncementRestController; import org.tkit.quarkus.rs.mappers.OffsetDateTimeMapper; -import gen.org.tkit.onecx.announcement.bff.clients.model.*; import gen.org.tkit.onecx.announcement.bff.rs.internal.model.*; +import gen.org.tkit.onecx.announcement.client.model.*; import io.quarkiverse.mockserver.test.InjectMockServerClient; import io.quarkus.test.common.http.TestHTTPEndpoint; import io.quarkus.test.junit.QuarkusTest; From e7ce0adde0671a799b8deacc4fc4d08f93818238 Mon Sep 17 00:00:00 2001 From: Jorden_Reuter Date: Fri, 9 Feb 2024 16:36:14 +0100 Subject: [PATCH 2/2] feat: enabled permissions --- pom.xml | 27 +++++++- src/main/helm/values.yaml | 12 +++- .../openapi/openapi-announcement-bff.yaml | 24 +++++++ src/main/resources/application.properties | 19 +++++- .../announcement/bff/rs/AbstractTest.java | 12 ++++ .../rs/AnnouncementRestControllerTest.java | 67 ++++++++++++++----- .../resources/mockserver/permissions.json | 62 +++++++++++++++++ 7 files changed, 202 insertions(+), 21 deletions(-) create mode 100644 src/test/resources/mockserver/permissions.json diff --git a/pom.xml b/pom.xml index 12ecb8d..a2b06d6 100644 --- a/pom.xml +++ b/pom.xml @@ -54,7 +54,10 @@ org.tkit.quarkus.lib tkit-quarkus-rest - + + org.tkit.quarkus.lib + tkit-quarkus-rest-context + org.mapstruct mapstruct @@ -72,6 +75,22 @@ io.quarkus quarkus-micrometer-registry-prometheus + + org.tkit.onecx.quarkus + onecx-permissions + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-oidc-client-reactive-filter + + + org.tkit.quarkus.lib + tkit-quarkus-security + @@ -97,6 +116,11 @@ swagger-parser test + + io.quarkus + quarkus-test-keycloak-server + test + @@ -118,6 +142,7 @@ + onecx-permissions=true jaxrs-spec ApiService DTO diff --git a/src/main/helm/values.yaml b/src/main/helm/values.yaml index ff09c4b..fb988b7 100644 --- a/src/main/helm/values.yaml +++ b/src/main/helm/values.yaml @@ -3,4 +3,14 @@ app: image: repository: "onecx/onecx-announcement-bff" db: - enabled: true \ No newline at end of file + enabled: true + operator: + # Permission + permission: + enabled: true + spec: + permissions: + announcements: + read: permission on all GET requests and POST search + write: permission on PUT, POST, PATCH requests, where objects are saved or updated + delete: permission on all DELETE requests \ No newline at end of file diff --git a/src/main/openapi/openapi-announcement-bff.yaml b/src/main/openapi/openapi-announcement-bff.yaml index 5b3cad3..cda14ee 100644 --- a/src/main/openapi/openapi-announcement-bff.yaml +++ b/src/main/openapi/openapi-announcement-bff.yaml @@ -10,6 +10,10 @@ tags: paths: /announcements/search: post: + x-onecx: + permissions: + announcements: + - read tags: - AnnouncementInternal summary: Find announcements by criteria @@ -37,6 +41,10 @@ paths: $ref: '#/components/schemas/ProblemDetailResponse' /announcements: post: + x-onecx: + permissions: + announcements: + - write tags: - AnnouncementInternal summary: Create announcement @@ -68,6 +76,10 @@ paths: $ref: '#/components/schemas/ProblemDetailResponse' /announcements/appIds: get: + x-onecx: + permissions: + announcements: + - read tags: - AnnouncementInternal summary: Get all application IDs to which announcements are assigned @@ -81,6 +93,10 @@ paths: $ref: '#/components/schemas/AnnouncementApps' /announcements/{id}: get: + x-onecx: + permissions: + announcements: + - read tags: - AnnouncementInternal summary: Retrieve announcement by id @@ -99,6 +115,10 @@ paths: schema: $ref: '#/components/schemas/Announcement' delete: + x-onecx: + permissions: + announcements: + - delete tags: - AnnouncementInternal summary: Delete announcement @@ -113,6 +133,10 @@ paths: "204": description: No content put: + x-onecx: + permissions: + announcements: + - write tags: - AnnouncementInternal summary: Patch/update announcement diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 54f244b..4e0a5ff 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,8 +1,16 @@ +# AUTHENTICATION +quarkus.http.auth.permission.health.paths=/q/* +quarkus.http.auth.permission.health.policy=permit +quarkus.http.auth.permission.default.paths=/* +quarkus.http.auth.permission.default.policy=authenticated +onecx.permissions.application-id=${quarkus.application.name} + # propagate the apm-principal-token from requests we receive org.eclipse.microprofile.rest.client.propagateHeaders=apm-principal-token # PROD %prod.quarkus.rest-client.onecx_announcement_svc.url=http://onecx-announcement-svc:8080 +%prod.quarkus.oidc-client.client-id=${quarkus.application.name} # BUILD quarkus.openapi-generator.codegen.input-base-dir=target/tmp/openapi @@ -27,7 +35,16 @@ quarkus.test.integration-test-profile=test %test.quarkus.mockserver.devservices.config-file=/mockserver.properties %test.quarkus.mockserver.devservices.config-dir=/mockserver %test.quarkus.rest-client.onecx_announcement_svc.url=${quarkus.mockserver.endpoint} - +%test.tkit.rs.context.token.header-param=apm-principal-token +%test.tkit.rs.context.token.enabled=false +%test.quarkus.rest-client.onecx_announcement_svc.providers=io.quarkus.oidc.client.reactive.filter.OidcClientRequestReactiveFilter +%test.tkit.rs.context.tenant-id.mock.claim-org-id=orgId +%test.quarkus.rest-client.onecx_permission.url=${quarkus.mockserver.endpoint} +%test.quarkus.keycloak.devservices.roles.alice=role-admin +%test.quarkus.keycloak.devservices.roles.bob=role-user +%test.quarkus.oidc-client.auth-server-url=${quarkus.oidc.auth-server-url} +%test.quarkus.oidc-client.client-id=${quarkus.oidc.client-id} +%test.quarkus.oidc-client.credentials.secret=${quarkus.oidc.credentials.secret} # PIPE CONFIG diff --git a/src/test/java/org/tkit/onecx/announcement/bff/rs/AbstractTest.java b/src/test/java/org/tkit/onecx/announcement/bff/rs/AbstractTest.java index 6422f40..addf67f 100644 --- a/src/test/java/org/tkit/onecx/announcement/bff/rs/AbstractTest.java +++ b/src/test/java/org/tkit/onecx/announcement/bff/rs/AbstractTest.java @@ -1,11 +1,14 @@ package org.tkit.onecx.announcement.bff.rs; +import org.eclipse.microprofile.config.ConfigProvider; + import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import io.quarkiverse.mockserver.test.MockServerTestResource; import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.keycloak.client.KeycloakTestClient; import io.restassured.RestAssured; import io.restassured.config.ObjectMapperConfig; import io.restassured.config.RestAssuredConfig; @@ -13,6 +16,15 @@ @QuarkusTestResource(MockServerTestResource.class) public abstract class AbstractTest { + protected static final String ADMIN = "alice"; + + protected static final String USER = "bob"; + + KeycloakTestClient keycloakClient = new KeycloakTestClient(); + + protected static final String APM_HEADER_PARAM = ConfigProvider.getConfig() + .getValue("%test.tkit.rs.context.token.header-param", String.class); + static { RestAssured.config = RestAssuredConfig.config().objectMapperConfig( ObjectMapperConfig.objectMapperConfig().jackson2ObjectMapperFactory( diff --git a/src/test/java/org/tkit/onecx/announcement/bff/rs/AnnouncementRestControllerTest.java b/src/test/java/org/tkit/onecx/announcement/bff/rs/AnnouncementRestControllerTest.java index 45d97c4..1676e48 100644 --- a/src/test/java/org/tkit/onecx/announcement/bff/rs/AnnouncementRestControllerTest.java +++ b/src/test/java/org/tkit/onecx/announcement/bff/rs/AnnouncementRestControllerTest.java @@ -12,14 +12,13 @@ import jakarta.ws.rs.HttpMethod; import jakarta.ws.rs.core.Response; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockserver.client.MockServerClient; import org.mockserver.model.JsonBody; import org.mockserver.model.MediaType; import org.tkit.onecx.announcement.bff.rs.controller.AnnouncementRestController; -import org.tkit.quarkus.rs.mappers.OffsetDateTimeMapper; import gen.org.tkit.onecx.announcement.bff.rs.internal.model.*; import gen.org.tkit.onecx.announcement.client.model.*; @@ -36,15 +35,20 @@ class AnnouncementRestControllerTest extends AbstractTest { @InjectMockServerClient MockServerClient mockServerClient; - @AfterEach - void resetMockserver() { - mockServerClient.reset(); + static final String mockId = "MOCK"; + + @BeforeEach + void resetExpectation() { + try { + mockServerClient.clear(mockId); + } catch (Exception ex) { + // mockId not existing + } } @Test void createAnnouncement_shouldReturnAnnouncement() { var offsetDateTime = OffsetDateTime.parse("2023-11-30T13:53:03.688710200+01:00"); - OffsetDateTimeMapper offsetDateTimeMapper = new OffsetDateTimeMapper(); // Request data to svc Announcement data = new Announcement(); @@ -57,7 +61,7 @@ void createAnnouncement_shouldReturnAnnouncement() { mockServerClient .when(request().withPath(ANNOUNCEMENT_SVC_INTERNAL_API_BASE_PATH) .withMethod(HttpMethod.POST)) - .withPriority(100) + .withId(mockId) .respond(httpRequest -> response().withStatusCode(Response.Status.OK.getStatusCode()) .withContentType(MediaType.APPLICATION_JSON) .withBody(JsonBody.json(data))); @@ -68,9 +72,22 @@ void createAnnouncement_shouldReturnAnnouncement() { input.setTitle("announcementTitle"); input.startDate(offsetDateTime); + // standard USER get FORBIDDEN with only READ permission + given() + .when() + .auth().oauth2(keycloakClient.getAccessToken(USER)) + .header(APM_HEADER_PARAM, USER) + .contentType(APPLICATION_JSON) + .body(input) + .post() + .then() + .statusCode(Response.Status.FORBIDDEN.getStatusCode()); + // bff call var response = given() .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) .contentType(APPLICATION_JSON) .body(input) .post() @@ -105,7 +122,7 @@ void getAnnouncements_shouldReturnAnnouncementPageResults() { mockServerClient .when(request().withPath(ANNOUNCEMENT_SVC_INTERNAL_API_BASE_PATH + "/search") .withMethod(HttpMethod.POST)) - .withPriority(100) + .withId(mockId) .respond(httpRequest -> response().withStatusCode(Response.Status.OK.getStatusCode()) .withContentType(MediaType.APPLICATION_JSON) .withBody(JsonBody.json(data))); @@ -116,6 +133,8 @@ void getAnnouncements_shouldReturnAnnouncementPageResults() { // bff call var response = given() .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) .contentType(APPLICATION_JSON) .body(input) .post("/search") @@ -143,7 +162,7 @@ void getAnnouncementById_shouldReturnAnnouncement() { .when(request() .withPath(ANNOUNCEMENT_SVC_INTERNAL_API_BASE_PATH + "/1") .withMethod(HttpMethod.GET)) - .withPriority(100) + .withId(mockId) .respond( httpRequest -> response() .withStatusCode(Response.Status.OK.getStatusCode()) @@ -153,6 +172,8 @@ void getAnnouncementById_shouldReturnAnnouncement() { // bff call var response = given() .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) .contentType(APPLICATION_JSON) .get("/1") .then() @@ -179,7 +200,7 @@ void getAllAppsWithAnnouncements_shouldReturnAnnouncementApps() { .when(request() .withPath(ANNOUNCEMENT_SVC_INTERNAL_API_BASE_PATH + "/appIds") .withMethod(HttpMethod.GET)) - .withPriority(100) + .withId(mockId) .respond( httpRequest -> response() .withStatusCode(Response.Status.OK.getStatusCode()) @@ -191,6 +212,8 @@ void getAllAppsWithAnnouncements_shouldReturnAnnouncementApps() { // bff call var response = given() .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) .contentType(APPLICATION_JSON) .get("/appIds") .then() @@ -217,7 +240,7 @@ void getAnnouncementById_shouldReturnNotFound() { .when(request() .withPath(ANNOUNCEMENT_SVC_INTERNAL_API_BASE_PATH + "/" + idNotFound) .withMethod(HttpMethod.GET)) - .withPriority(100) + .withId(mockId) .respond( httpRequest -> response() .withStatusCode(Response.Status.NOT_FOUND.getStatusCode()) @@ -226,6 +249,8 @@ void getAnnouncementById_shouldReturnNotFound() { // bff call given() .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) .contentType(APPLICATION_JSON) .get(idNotFound) .then() @@ -242,7 +267,7 @@ void deleteAnnouncementById() { .when(request() .withPath(ANNOUNCEMENT_SVC_INTERNAL_API_BASE_PATH + "/" + deleteId) .withMethod(HttpMethod.DELETE)) - .withPriority(100) + .withId(mockId) .respond( httpRequest -> response() .withStatusCode(Response.Status.NO_CONTENT.getStatusCode())); @@ -250,6 +275,8 @@ void deleteAnnouncementById() { // bff call var response = given() .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) .contentType(APPLICATION_JSON) .delete(deleteId) .then() @@ -262,7 +289,6 @@ void deleteAnnouncementById() { @Test void updateAnnouncementById() { var offsetDateTime = OffsetDateTime.parse("2023-11-30T13:53:03.688710200+01:00"); - OffsetDateTimeMapper offsetDateTimeMapper = new OffsetDateTimeMapper(); String updateId = "updateId_NO_CONTENT"; Announcement data = new Announcement(); @@ -272,7 +298,7 @@ void updateAnnouncementById() { .when(request() .withPath(ANNOUNCEMENT_SVC_INTERNAL_API_BASE_PATH + "/" + updateId) .withMethod(HttpMethod.PUT)) - .withPriority(100) + .withId(mockId) .respond( httpRequest -> response() .withStatusCode(Response.Status.OK.getStatusCode()) @@ -281,9 +307,12 @@ void updateAnnouncementById() { UpdateAnnouncementRequestDTO input = new UpdateAnnouncementRequestDTO(); input.setStartDate(offsetDateTime); input.setTitle("appTitle"); + input.setModificationCount(0); // bff call given() .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) .contentType(APPLICATION_JSON) .body(input) .put(updateId) @@ -302,7 +331,7 @@ void updateAnnouncementById_shouldReturnBadRequest() { .when(request() .withPath(ANNOUNCEMENT_SVC_INTERNAL_API_BASE_PATH + "/" + updateId) .withMethod(HttpMethod.PUT)) - .withPriority(100) + .withId(mockId) .respond( httpRequest -> response() .withStatusCode(Response.Status.BAD_REQUEST.getStatusCode()) @@ -313,6 +342,8 @@ void updateAnnouncementById_shouldReturnBadRequest() { // bff call given() .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) .contentType(APPLICATION_JSON) .body(input) .put(updateId) @@ -338,15 +369,15 @@ void createAnnouncement_shouldReturnBadRequest_whenRunningIntoValidationConstrai data.setInvalidParams(list); mockServerClient.when(request().withPath(ANNOUNCEMENT_SVC_INTERNAL_API_BASE_PATH).withMethod(HttpMethod.POST)) - .withPriority(100) + .withId(mockId) .respond(httpRequest -> response().withStatusCode(Response.Status.BAD_REQUEST.getStatusCode()) .withContentType(MediaType.APPLICATION_JSON) .withBody(JsonBody.json(data))); - CreateAnnouncementRequestDTO emptyRequestDTO; - var response = given() .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) .contentType(APPLICATION_JSON) .post() .then() diff --git a/src/test/resources/mockserver/permissions.json b/src/test/resources/mockserver/permissions.json new file mode 100644 index 0000000..8872f6e --- /dev/null +++ b/src/test/resources/mockserver/permissions.json @@ -0,0 +1,62 @@ +[ + { + "id": "2", + "httpRequest": { + "headers": { + "apm-principal-token": [ + "alice" + ] + }, + "path": "/v1/permissions/user/application/onecx-announcement-bff" + }, + "httpResponse": { + "body": { + "type": "JSON", + "json": { + "appId": "onecx-announcement-bff", + "permissions": { + "announcements": [ + "read", + "write", + "delete" + ], + "permissions": [ + "admin-write", + "admin-read" + ] + } + }, + "contentType": "application/json" + } + } + }, + { + "id": "3", + "httpRequest": { + "headers": { + "apm-principal-token": [ + "bob" + ] + }, + "path": "/v1/permissions/user/application/onecx-announcement-bff" + }, + "httpResponse": { + "body": { + "type": "JSON", + "json": { + "appId": "onecx-announcement-bff", + "permissions": { + "announcements": [ + "read" + ], + "permissions": [ + "admin-write", + "admin-read" + ] + } + }, + "contentType": "application/json" + } + } + } +] \ No newline at end of file