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:
- -
- - id: 12
- title: Team 2
- details:
- -
- '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\":[\"\"]},{\"id\":12,\"title\":\"Team 2\",\"details\":[\"\"]}]}",
+ "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());
}