From 79f565be8aaa816f405b2700e75383f1432dd7e0 Mon Sep 17 00:00:00 2001 From: Torsten Juergeleit Date: Mon, 29 Jul 2024 22:13:50 +0200 Subject: [PATCH] changes Confluence page hierarchy retrieval to a recursive approach --- README.md | 2 +- config/mock/confluence-openapi.yaml | 292 ------------------ config/mock/initializerJson.json | 69 ++++- config/realms/acme.yaml | 2 +- .../content/ConfluenceContentProvider.java | 75 +++-- .../confluence/content/ConfluencePage.java | 8 +- .../ConfluenceGroupLDAPStorageMapperIT.java | 2 + .../content/ConfluenceContentProviderIT.java | 7 +- 8 files changed, 128 insertions(+), 329 deletions(-) delete mode 100644 config/mock/confluence-openapi.yaml diff --git a/README.md b/README.md index e07c6fe..337d14d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Release](https://img.shields.io/github/release/vaulttec/keycloak-confluence-ldap-group-mapper.svg)](https://github.com/vaulttec/keycloak-confluence-ldap-group-mapper/releases/latest)![](https://img.shields.io/github/license/vaulttec/keycloak-confluence-ldap-group-mapper?label=License) ![](https://img.shields.io/badge/Keycloak-23.0-blue) -Custom [Keycloak](https://www.keycloak.org) LDAP Group Mapper which creates groups and group memberships retrieved from [Confluence](https://www.atlassian.com/software/confluence) pages (representing the group hierarchy) and page properties (providing a HTML table with a group member column) via [Confluence's REST API](config/mock/confluence-openapi.yaml). +Custom [Keycloak](https://www.keycloak.org) LDAP Group Mapper which creates groups and group memberships retrieved from [Confluence](https://www.atlassian.com/software/confluence) pages (representing the group hierarchy) and page properties (providing a HTML table with a group member column) via [Confluence's REST API](https://developer.atlassian.com/server/confluence/confluence-server-rest-api/‚). ## Credit This project uses ideas or artifacts from other projects, e.g. diff --git a/config/mock/confluence-openapi.yaml b/config/mock/confluence-openapi.yaml deleted file mode 100644 index d63795d..0000000 --- a/config/mock/confluence-openapi.yaml +++ /dev/null @@ -1,292 +0,0 @@ -openapi: 3.0.1 -info: - title: The Confluence REST API - description: This document describes the REST API and resources provided by Confluence. The REST APIs are for developers who want to integrate Confluence into their application and for administrators who want to script interactions with the Confluence server.Confluence's REST APIs provide access to resources (data entities) via URI paths. To use a REST API, your application will make an HTTP request and parse the response. The response format is JSON. Your methods will be the standard HTTP methods like GET, PUT, POST and DELETE. Because the REST API is based on open standards, you can use any web development language to access the API. - termsOfService: https://atlassian.com/terms/ - version: 1.0.0 -externalDocs: - description: The online and complete version of the Confluence REST API docs. - url: https://developer.atlassian.com/server/confluence/confluence-server-rest-api/ -servers: - - url: http://your-confluence-server/confluence -security: - - bearerAuth: [] - -paths: - /rest/api/content/{id}/child: - get: - summary: Get content children - description: |- - Returns a map of the direct children of a piece of content. A piece of content - has different types of child content, depending on its type. These are - the default parent-child content type relationships: - - - `page`: child content is `page`, `comment`, `attachment` - - Apps can override these default relationships. Apps can also introduce - new content types that create new parent-child content relationships. - - Note, the map will always include all child content types that are valid - for the content. However, if the content has no instances of a child content - type, the map will contain an empty array for that child content type. - - **[Permissions](https://confluence.atlassian.com/x/_AozKw) required**: 'View' permission for the space, - and permission to view the content if it is a page. - operationId: getContentChildren - parameters: - - name: id - in: path - description: The ID of the content to be queried for its children. - required: true - schema: - type: string - - name: expand - in: query - description: |- - A multi-value parameter indicating which properties of the children to expand, where: - - - `page` returns all child pages of the content. - style: form - explode: false - schema: - type: array - items: - type: string - responses: - '200': - description: Returned if the requested content children are returned. - content: - application/json: - schema: - $ref: '#/components/schemas/ContentChildren' - example: - page: - results: - - id: '1' - title: Page 1 - children: - page: - results: - - id: '11' - title: Page 1.1 - children: - page: - results: [] - _links: - tinyui: /x/Pcz-B11 - - id: '12' - title: Page 1.2 - children: - page: - results: [] - _links: - tinyui: /x/Pcz-B12 - _links: - tinyui: /x/Pcz-B1 - - id: '2' - title: Page 2 - children: - page: - results: [] - _links: - tinyui: /x/Pcz-B2 - '404': - description: |- - Returned if; - - - There is no content with the given ID. - - The calling user does not have permission to view the content. - content: {} - /rest/masterdetail/1.0/detailssummary/lines: - get: - summary: Get page properties master detail summary lines - operationId: getDetailsSummaryLines - parameters: - - name: spaceKey - in: query - required: true - schema: - type: string - - name: cql - in: query - required: true - schema: - type: string - - name: headings - in: query - required: true - schema: - type: string - - name: pageIndex - in: query - schema: - type: string - - name: pageSize - in: query - schema: - type: string - responses: - '200': - description: Returned if the requested details summary lines are returned. - content: - application/json: - schema: - $ref: '#/components/schemas/DetailsSummaryLines' - example: - currentPage: 0 - totalPages: 1 - renderedHeadings: - - Team - detailLines: - - id: 11 - title: Team 1 - details: - -
NamePositionFunctionLocation
John DooManagerProduct OwnerAnywhere
Doo, Jim AssociateDeveloperNowhere
Jane DooAnalystDeveloperHere
- - id: 12 - title: Team 2 - details: - -
NamePositionFunctionLocation
Jane DooManagerProduct OwnerHere
Doo, John AssociateDeveloperNowhere
Jim DooAnalystDeveloperAnywhere
- '404': - description: |- - Returned if; - - - The space key is unknown. - - There is an error with the given CQL. - - The calling user does not have permission to view the pages. - content: {} - - -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - schemas: - Content: - required: - - status - - type - nullable: true - type: object - additionalProperties: true - properties: - id: - type: string - type: - type: string - description: Can be "page", "blogpost", "attachment" or "content" - status: - type: string - title: - type: string - ancestors: - nullable: true - type: array - items: - $ref: '#/components/schemas/Content' - children: - $ref: '#/components/schemas/ContentChildren' - descendants: - $ref: '#/components/schemas/ContentChildren' - extensions: - type: object - _expandable: - type: object - properties: - childTypes: - type: string - children: - type: string - ancestors: - type: string - version: - type: string - descendants: - type: string - _links: - $ref: '#/components/schemas/GenericLinks' - description: Base object for all content types. - ContentArray: - required: - - _links - - results - - size - type: object - properties: - results: - type: array - items: - $ref: '#/components/schemas/Content' - start: - type: integer - format: int32 - limit: - type: integer - format: int32 - size: - type: integer - format: int32 - _links: - $ref: '#/components/schemas/GenericLinks' - ContentChildren: - type: object - additionalProperties: true - properties: - attachment: - $ref: '#/components/schemas/ContentArray' - comment: - $ref: '#/components/schemas/ContentArray' - page: - $ref: '#/components/schemas/ContentArray' - _expandable: - type: object - additionalProperties: true - properties: - attachment: - type: string - comment: - type: string - page: - type: string - _links: - $ref: '#/components/schemas/GenericLinks' - GenericLinks: - type: object - additionalProperties: - oneOf: - - type: object - additionalProperties: true - - type: string - DetailsSummaryLines: - type: object - properties: - currentPage: - type: integer - format: int32 - totalPages: - type: integer - format: int32 - renderedHeadings: - type: array - items: - type: string - detailLines: - type: array - items: - $ref: '#/components/schemas/DetailsSummaryLine' - DetailsSummaryLine: - type: object - properties: - id: - type: integer - format: int64 - title: - type: string - relativeLink: - type: string - details: - type: array - items: - type: string - additionalProperties: true diff --git a/config/mock/initializerJson.json b/config/mock/initializerJson.json index 41ea9b8..873a79c 100644 --- a/config/mock/initializerJson.json +++ b/config/mock/initializerJson.json @@ -1,3 +1,66 @@ -{ - "specUrlOrPayload": "/config/confluence-openapi.yaml" -} +[ + { + "httpRequest": { + "method": "GET", + "path": "/confluence/rest/api/content/{id}/child/{type}", + "pathParameters": { + "id": ["1234"], + "type": ["page"] + } + }, + "httpResponse": { + "statusCode": 200, + "body": "{\"page\":{\"results\":[{\"id\":\"1\",\"title\":\"Page 1\",\"children\":{\"page\":{\"results\":[{\"id\":\"11\",\"title\":\"Page 1.1\",\"_links\":{\"tinyui\":\"/x/Pcz-B11\"}},{\"id\":\"12\",\"title\":\"Page 1.2\",\"_links\":{\"tinyui\":\"/x/Pcz-B12\"}}]}},\"_links\":{\"tinyui\":\"/x/Pcz-B1\"}},{\"id\":\"2\",\"title\":\"Page 2\",\"children\":{\"page\":{\"results\":[]}},\"_links\":{\"tinyui\":\"/x/Pcz-B2\"}}]}}", + "headers": { + "Content-Type": ["application/json"] + } + } + }, + { + "httpRequest": { + "method": "GET", + "path": "/confluence/rest/api/content/{id}/child/{type}", + "pathParameters": { + "id": ["11"], + "type": ["page"] + } + }, + "httpResponse": { + "statusCode": 200, + "body": "{\"page\":{\"results\":[{\"id\":\"111\",\"title\":\"Page 1.1.1\",\"children\":{\"page\":{\"results\":[]}},\"_links\":{\"tinyui\":\"/x/Pcz-B111\"}},{\"id\":\"112\",\"title\":\"Page 1.1.2\",\"children\":{\"page\":{\"results\":[]}},\"_links\":{\"tinyui\":\"/x/Pcz-B112\"}}]}}", + "headers": { + "Content-Type": ["application/json"] + } + } + }, + { + "httpRequest": { + "method": "GET", + "path": "/confluence/rest/api/content/{id}/child/{type}", + "pathParameters": { + "id": ["12"], + "type": ["page"] + } + }, + "httpResponse": { + "statusCode": 200, + "body": "{ \"page\": { \"results\": [] } }", + "headers": { + "Content-Type": ["application/json"] + } + } + }, + { + "httpRequest": { + "method": "GET", + "path": "/confluence/rest/masterdetail/1.0/detailssummary/lines" + }, + "httpResponse": { + "statusCode": 200, + "body": "{\"currentPage\":0,\"totalPages\":1,\"renderedHeadings\":[\"Team\"],\"detailLines\":[{\"id\":11,\"title\":\"Team 1\",\"details\":[\"
NamePositionFunctionLocation
John DooManagerProduct OwnerAnywhere
Doo, Jim AssociateDeveloperNowhere
Jane DooAnalystDeveloperHere
\"]},{\"id\":12,\"title\":\"Team 2\",\"details\":[\"
NamePositionFunctionLocation
Jane DooManagerProduct OwnerHere
Doo, John AssociateDeveloperNowhere
Jim DooAnalystDeveloperAnywhere
\"]}]}", + "headers": { + "Content-Type": ["application/json"] + } + } + } +] \ No newline at end of file diff --git a/config/realms/acme.yaml b/config/realms/acme.yaml index 0470a7b..6edb9a5 100644 --- a/config/realms/acme.yaml +++ b/config/realms/acme.yaml @@ -38,7 +38,7 @@ components: config: confluenceContent.baseUrl: ["$(env:CONFLUENCE_URL:-confluence-url)"] confluenceContent.authToken: ["token"] - confluenceContent.parentPageId: [ "123"] + confluenceContent.parentPageId: [ "1234"] confluenceContent.pageNesting: ["4"] confluenceContent.spaceKey: ["TEST"] confluenceContent.pageLabels: ["label1, label2, label3"] diff --git a/src/main/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluenceContentProvider.java b/src/main/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluenceContentProvider.java index 1e3c54e..dc3f7b0 100644 --- a/src/main/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluenceContentProvider.java +++ b/src/main/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluenceContentProvider.java @@ -28,43 +28,64 @@ public ConfluenceContentProvider(KeycloakSession session, ComponentModel model) } public List getChildPages() { - try { - SimpleHttp simpleHttp = SimpleHttp.doGet(config.getBaseUrl() + "/rest/api/content/" + config.getParentPageId() + "/child", httpClient) - .auth(config.getAuthToken()) - .param("expand", "page" + ".children.page".repeat(config.getPageNesting() - 1)); - List children = ConfluencePage.getChildren(simpleHttp.asJson()); - LOG.debugf("Retrieved %s child pages from parent page %s", children.size(), config.getParentPageId()); - return children; + return getChildPages(config.getParentPageId()); + } + + private List getChildPages(String pageId) { + SimpleHttp simpleHttp = SimpleHttp.doGet(config.getBaseUrl() + "/rest/api/content/" + pageId + "/child/page", httpClient) + .auth(config.getAuthToken()) + .param("expand", "children.page") + .param("limit", "100"); + try (SimpleHttp.Response response = simpleHttp.asResponse()) { + if (response.getStatus() == 200) { + List children = ConfluencePage.getChildren(response.asJson()); + LOG.debugf("Retrieved %s child pages from parent page %s", children.size(), pageId); + // Recursively retrieve grand-grand child pages from grand child pages + for (ConfluencePage child : children) { + for (ConfluencePage grandChild : child.getChildren()) { + grandChild.setChildren(getChildPages(grandChild.getId())); + } + } + return children; + } else { + throw new IOException(response.asJson().toString()); + } } catch (IOException e) { - LOG.errorf(e, "Retrieving child pages from %s failed", config.getBaseUrl()); + LOG.errorf(e, "Retrieving child pages from %s failed: %s", config.getBaseUrl(), e.getMessage()); } return Collections.emptyList(); } public List getPageProperties() { List pageProperties = new ArrayList<>(); - try { - for (int pageIndex = 0, totalPages = 1; totalPages > pageIndex; pageIndex++) { - SimpleHttp simpleHttp = SimpleHttp.doGet(config.getBaseUrl() + "/rest/masterdetail/1.0/detailssummary/lines", httpClient) - .auth(config.getAuthToken()) - .param("spaceKey", config.getSpaceKey()) - .param("cql", "type=page AND " + Arrays.stream(config.getPageLabels().split(",")) - .map(label -> "label='" + label.trim() + "'").collect(Collectors.joining(" AND "))) - .param("headings", config.getPagePropertyName()).param("pageIndex", String.valueOf(pageIndex)) - .param("pageSize", "500"); - JsonNode node = simpleHttp.asJson(); - if (node.has("totalPages") && node.has("detailLines")) { - totalPages = node.get("totalPages").asInt(); - pageProperties.addAll(ConfluencePageProperty.getPageProperties(node)); + for (int pageIndex = 0, totalPages = 1; totalPages > pageIndex; pageIndex++) { + SimpleHttp simpleHttp = SimpleHttp.doGet(config.getBaseUrl() + "/rest/masterdetail/1.0/detailssummary/lines", httpClient) + .auth(config.getAuthToken()) + .param("spaceKey", config.getSpaceKey()) + .param("cql", "type=page AND " + Arrays.stream(config.getPageLabels().split(",")) + .map(label -> "label='" + label.trim() + "'").collect(Collectors.joining(" AND "))) + .param("headings", config.getPagePropertyName()) + .param("pageIndex", String.valueOf(pageIndex)) + .param("pageSize", "500"); + try (SimpleHttp.Response response = simpleHttp.asResponse()) { + if (response.getStatus() == 200) { + JsonNode node = response.asJson(); + if (node.has("totalPages") && node.has("detailLines")) { + totalPages = node.get("totalPages").asInt(); + pageProperties.addAll(ConfluencePageProperty.getPageProperties(node)); + } + } else { + throw new IOException(response.asJson().toString()); } + } catch (IOException e) { + LOG.errorf(e, "Retrieving page properties from %s failed: %s", config.getBaseUrl(), e.getMessage()); + return Collections.emptyList(); } - for (ConfluencePageProperty pageProperty : pageProperties) { - pageProperty.setValues(config.getMemberColumnIndex()); - } - LOG.debugf("Retrieved %s page properties from space %s", pageProperties.size(), config.getSpaceKey()); - } catch (IOException e) { - LOG.errorf(e, "Retrieving page properties from %s failed", config.getBaseUrl()); } + for (ConfluencePageProperty pageProperty : pageProperties) { + pageProperty.setValues(config.getMemberColumnIndex()); + } + LOG.debugf("Retrieved %s page properties from space %s", pageProperties.size(), config.getSpaceKey()); return pageProperties; } } diff --git a/src/main/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluencePage.java b/src/main/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluencePage.java index 5c5db58..ee3b23f 100644 --- a/src/main/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluencePage.java +++ b/src/main/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluencePage.java @@ -44,12 +44,16 @@ public String getRelativeUrl() { return relativeUrl; } + public boolean hasChildren() { + return children != null && !children.isEmpty(); + } + public List getChildren() { return children; } - public boolean hasChildren() { - return children != null && !children.isEmpty(); + protected void setChildren(List children) { + this.children = children; } /* package */ static List getChildren(JsonNode node) { diff --git a/src/test/java/org/vaulttec/keycloak/ldap/mappers/confluence/ConfluenceGroupLDAPStorageMapperIT.java b/src/test/java/org/vaulttec/keycloak/ldap/mappers/confluence/ConfluenceGroupLDAPStorageMapperIT.java index 3fb5633..39be1b2 100644 --- a/src/test/java/org/vaulttec/keycloak/ldap/mappers/confluence/ConfluenceGroupLDAPStorageMapperIT.java +++ b/src/test/java/org/vaulttec/keycloak/ldap/mappers/confluence/ConfluenceGroupLDAPStorageMapperIT.java @@ -90,5 +90,7 @@ public void testFullSync() { List groups = realm.groups().query("Page", true); assertEquals(2, groups.size()); assertEquals(2, groups.get(0).getSubGroupCount()); + assertEquals(2, groups.get(0).getSubGroups().get(0).getSubGroupCount()); + assertEquals(0, groups.get(1).getSubGroupCount()); } } \ No newline at end of file diff --git a/src/test/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluenceContentProviderIT.java b/src/test/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluenceContentProviderIT.java index 741cc27..5ac0a3e 100644 --- a/src/test/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluenceContentProviderIT.java +++ b/src/test/java/org/vaulttec/keycloak/ldap/mappers/confluence/content/ConfluenceContentProviderIT.java @@ -19,7 +19,6 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import static org.mockserver.mock.OpenAPIExpectation.openAPIExpectation; import static org.vaulttec.keycloak.ldap.mappers.confluence.content.ConfluenceContentConfig.*; public class ConfluenceContentProviderIT { @@ -28,9 +27,8 @@ public class ConfluenceContentProviderIT { @BeforeAll static void setup() throws Exception { - Configuration config = new Configuration().logLevel(Level.WARN); + Configuration config = new Configuration().logLevel(Level.WARN).initializationJsonPath("config/mock/initializerJson.json"); mockServer = ClientAndServer.startClientAndServer(config); - mockServer.upsert(openAPIExpectation("config/mock/confluence-openapi.yaml")); URL mockEndpoint = new URIBuilder("http://localhost").setPort(mockServer.getPort()).setPath("confluence").build().toURL(); HttpClientProvider clientProvider = mock(HttpClientProvider.class); @@ -63,6 +61,9 @@ public void testGetChildPagesWithTitle() { assertEquals("Page 1", pages.get(0).getTitle()); assertEquals(2, pages.get(0).getChildren().size()); assertEquals("Page 1.1", pages.get(0).getChildren().get(0).getTitle()); + assertEquals(2, pages.get(0).getChildren().size()); + assertEquals("Page 1.1.1", pages.get(0).getChildren().get(0).getChildren().get(0).getTitle()); + assertEquals("Page 1.1.2", pages.get(0).getChildren().get(0).getChildren().get(1).getTitle()); assertEquals("Page 1.2", pages.get(0).getChildren().get(1).getTitle()); assertEquals("Page 2", pages.get(1).getTitle()); }