diff --git a/Makefile b/Makefile index 767aa28..dd1858b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,12 @@ +install: + mvn -f attribute-endpoints-provider install + verify: mvn -f attribute-endpoints-provider verify +test: + mvn -f attribute-endpoints-provider test + clean: mvn -f attribute-endpoints-provider clean diff --git a/attribute-endpoints-provider/pom.xml b/attribute-endpoints-provider/pom.xml index a6d1663..c525026 100644 --- a/attribute-endpoints-provider/pom.xml +++ b/attribute-endpoints-provider/pom.xml @@ -28,6 +28,19 @@ test + + org.mockito + mockito-core + 5.23.0 + test + + + + org.jboss.resteasy + resteasy-core + test + + org.keycloak keycloak-server-spi-private diff --git a/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java new file mode 100644 index 0000000..fb57f4b --- /dev/null +++ b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java @@ -0,0 +1,315 @@ +package de.ccc.hamburg.keycloak.attribute_endpoints; + +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.ServerErrorException; +import jakarta.ws.rs.core.Response; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.*; +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.representations.userprofile.config.UPGroup; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager.AuthResult; +import org.keycloak.userprofile.UserProfileProvider; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests the two endpoints of {@link AttributeEndpointsResourceProvider} against the realm + * configuration and expected results documented in {@code testing/README.md}. + */ +public class AttributeEndpointsResourceProviderTest { + + private static final String SSH_KEY_1 = "ssh-key-1"; + private static final String SSH_KEY_2 = "ssh-key-2"; + private static final String MAILING_LIST_CHAOS = "mailing-list-address-chaos"; + private static final String MAILING_LIST_INTERN = "mailing-list-address-intern"; + + private KeycloakSession session; + private RealmModel realm; + private Map roles; + private UserModel hacker; + private UserModel tester; + private UserModel noone; + + @Before + public void setUp() { + session = mock(KeycloakSession.class); + KeycloakContext context = mock(KeycloakContext.class); + when(session.getContext()).thenReturn(context); + + realm = mock(RealmModel.class); + when(context.getRealm()).thenReturn(realm); + + roles = new HashMap<>(); + for (String name : List.of("dooris-authorized", "export-dooris-ssh-keys", + "mailing-list-chaos-member", "mailing-list-intern-member", + "export-mailing-list-addresses")) { + roles.put(name, mock(RoleModel.class)); + } + when(realm.getRole(anyString())).thenAnswer(inv -> roles.get(inv.getArgument(0, String.class))); + + List components = List.of( + attributeEndpointComponent("dooris-ssh-keys", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"), + attributeEndpointComponent("mailing-list-addresses-chaos", + "export-mailing-list-addresses", "mailing-list-chaos-member", MAILING_LIST_CHAOS), + attributeEndpointComponent("mailing-list-addresses-intern", + "export-mailing-list-addresses", "mailing-list-intern-member", MAILING_LIST_INTERN)); + when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); + + UserProfileProvider userProfileProvider = mock(UserProfileProvider.class); + when(session.getProvider(UserProfileProvider.class)).thenReturn(userProfileProvider); + when(userProfileProvider.getConfiguration()).thenReturn(testUserProfileConfig()); + + hacker = user("hacker", Map.of( + SSH_KEY_1, "hacker-ssh-key-1", + SSH_KEY_2, "hacker-ssh-key-2", + MAILING_LIST_CHAOS, "hacker+chaos@example.net", + MAILING_LIST_INTERN, "hacker+intern@example.net"), + List.of("dooris-authorized", "mailing-list-chaos-member", "mailing-list-intern-member")); + tester = user("tester", Map.of( + SSH_KEY_1, "tester-ssh-key-1", + SSH_KEY_2, "tester-ssh-key-2", + MAILING_LIST_CHAOS, "tester+chaos@example.com", + MAILING_LIST_INTERN, "tester+intern@example.com"), + List.of("mailing-list-chaos-member")); + noone = user("noone", Map.of( + SSH_KEY_1, "noone-ssh-key-1", + SSH_KEY_2, "noone-ssh-key-2", + MAILING_LIST_CHAOS, "noone+chaos@example.org", + MAILING_LIST_INTERN, "noone+intern@example.org"), + Collections.emptyList()); + + UserProvider userProvider = mock(UserProvider.class); + when(session.users()).thenReturn(userProvider); + when(userProvider.searchForUserStream(eq(realm), eq(Map.of()))) + .thenAnswer(inv -> Stream.of(hacker, tester, noone)); + } + + @Test + public void exportAttributeValues_doorisSlug_returnsSshKeysOfMatchingUser() { + try (Response response = callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("dooris-ssh-keys"))) { + + assertEquals(200, response.getStatus()); + assertEquals(List.of("hacker-ssh-key-1", "hacker-ssh-key-2"), response.getEntity()); + } + } + + /* NOTYET + @Test + public void exportAttributeValuesMap_doorisSlug_returnsSshKeysPerAttribute() { + try (Response response = callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValuesMap("dooris-ssh-keys"))) { + + assertEquals(200, response.getStatus()); + assertEquals(Map.of( + SSH_KEY_1, List.of("hacker-ssh-key-1"), + SSH_KEY_2, List.of("hacker-ssh-key-2")), + response.getEntity()); + } + } + */ + + /* NOTYET + @Test + public void exportAttributeValues_mailingListChaosSlug_returnsAddressesOfAllMatchingUsers() { + try (Response response = callAuthenticatedAs("export-mailing-list-addresses", + () -> new AttributeEndpointsResourceProvider(session) + .exportAttributeValues("mailing-list-addresses-chaos"))) { + + assertEquals(200, response.getStatus()); + assertEquals(List.of("hacker+chaos@example.net", "tester+chaos@example.com"), response.getEntity()); + } + } + */ + + /* NOTYET + @Test + public void exportAttributeValues_mailingListInternSlug_returnsAddressOfSingleMatchingUser() { + try (Response response = callAuthenticatedAs("export-mailing-list-addresses", + () -> new AttributeEndpointsResourceProvider(session) + .exportAttributeValues("mailing-list-addresses-intern"))) { + + assertEquals(200, response.getStatus()); + assertEquals(List.of("hacker+intern@example.net"), response.getEntity()); + } + } + */ + + /* NOTYET + @Test + public void exportAttributeValuesMap_mailingListChaosSlug_returnsAddressesPerAttribute() { + try (Response response = callAuthenticatedAs("export-mailing-list-addresses", + () -> new AttributeEndpointsResourceProvider(session) + .exportAttributeValuesMap("mailing-list-addresses-chaos"))) { + + assertEquals(200, response.getStatus()); + assertEquals(Map.of(MAILING_LIST_CHAOS, + List.of("hacker+chaos@example.net", "tester+chaos@example.com")), + response.getEntity()); + } + } + */ + + @Test + public void exportAttributeValues_unknownSlug_throwsNotFound() { + NotFoundException exception = assertThrows(NotFoundException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("unknown-slug"))); + + assertEquals(404, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("not found")); + } + + @Test + public void exportAttributeValues_duplicateSlugConfiguration_throwsNotFound() { + List duplicateComponents = List.of( + attributeEndpointComponent("duplicate-slug", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"), + attributeEndpointComponent("duplicate-slug", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys")); + when(realm.getComponentsStream()).thenAnswer(inv -> duplicateComponents.stream()); + + NotFoundException exception = assertThrows(NotFoundException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("duplicate-slug"))); + + assertEquals(404, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("Multiple configurations exist")); + } + + @Test + public void exportAttributeValues_missingAuthRole_throwsServerError() { + List components = List.of( + attributeEndpointComponent("bad-auth-role", + "nonexistent-role", "dooris-authorized", "dooris-ssh-keys")); + when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("bad-auth-role"))); + + assertEquals(500, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("auth-role does not exist")); + } + + @Test + public void exportAttributeValues_missingMatchRole_throwsServerError() { + List components = List.of( + attributeEndpointComponent("bad-match-role", + "export-dooris-ssh-keys", "nonexistent-role", "dooris-ssh-keys")); + when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("bad-match-role"))); + + assertEquals(500, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("match-role does not exist")); + } + + @Test + public void exportAttributeValues_invalidAttributeRegex_throwsServerError() { + ComponentModel component = attributeEndpointComponent("bad-regex", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"); + component.put("attribute-regex", "["); + when(realm.getComponentsStream()).thenAnswer(inv -> Stream.of(component)); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("bad-regex"))); + + assertEquals(500, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("attribute-regex is not a valid regex pattern")); + } + + @Test + public void exportAttributeValues_callerMissingAuthRole_throwsForbidden() { + ForbiddenException exception = assertThrows(ForbiddenException.class, + () -> callAuthenticatedAs("not-a-configured-role", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("dooris-ssh-keys"))); + + assertEquals(403, exception.getResponse().getStatus()); + } + + /** + * Runs {@code call} as if authenticated by a bearer token belonging to a service account + * that holds {@code requiredRole}, by intercepting construction of the + * {@link AppAuthManager.BearerTokenAuthenticator} the provider creates internally. + */ + private Response callAuthenticatedAs(String requiredRole, Supplier call) { + UserModel serviceAccount = mock(UserModel.class); + when(serviceAccount.hasRole(roles.get(requiredRole))).thenReturn(true); + + try (MockedConstruction ignored = Mockito.mockConstruction( + AppAuthManager.BearerTokenAuthenticator.class, + (mock, ctx) -> when(mock.authenticate()) + .thenReturn(new AuthResult(serviceAccount, null, null, null)))) { + return call.get(); + } + } + + private static ComponentModel attributeEndpointComponent(String slug, String authRole, String matchRole, + String attributeGroup) { + ComponentModel component = new ComponentModel(); + component.setProviderId(AdminUiPage.PROVIDER_ID); + component.put("slug", slug); + component.put("auth-role", authRole); + component.put("match-role", matchRole); + component.put("attribute-group", attributeGroup); + return component; + } + + /** + * Mirrors the User Profile configuration in testing. + */ + private static UPConfig testUserProfileConfig() { + UPConfig upConfig = new UPConfig(); + upConfig.setGroups(List.of(new UPGroup("dooris-ssh-keys"), new UPGroup("mailing-list-addresses"))); + upConfig.setAttributes(List.of( + attribute(SSH_KEY_1, "dooris-ssh-keys"), + attribute(SSH_KEY_2, "dooris-ssh-keys"), + attribute(MAILING_LIST_CHAOS, "mailing-list-addresses"), + attribute(MAILING_LIST_INTERN, "mailing-list-addresses"))); + return upConfig; + } + + private static UPAttribute attribute(String name, String group) { + UPAttribute attribute = new UPAttribute(name); + attribute.setGroup(group); + return attribute; + } + + private UserModel user(String username, Map attributes, List roleNames) { + UserModel user = mock(UserModel.class); + when(user.getUsername()).thenReturn(username); + + when(user.getAttributeStream(anyString())).thenAnswer(inv -> Stream.empty()); + attributes.forEach((name, value) -> + when(user.getAttributeStream(eq(name))).thenAnswer(inv -> Stream.of(value))); + + when(user.hasRole(any(RoleModel.class))).thenReturn(false); + roleNames.forEach(name -> when(user.hasRole(roles.get(name))).thenReturn(true)); + + return user; + } +} diff --git a/testing/README.md b/testing/README.md index 2c5600e..7a16c0e 100644 --- a/testing/README.md +++ b/testing/README.md @@ -1,9 +1,11 @@ -# Creating a Testing Keycloak +# Integration Testing + +This directory contains infrastructure and tests to verify end-to-end operation of the provider. + +## Creating a Testing Keycloak The Docker Compose setup in this directory sets up Keycloak and imports a realm `testing`. -## Starting Keycloak - Run `docker compose up -d` to start Keycloak. The admin console will be available at http://localhost:8080/. You can attach a Java debugger at `localhost:8081`. @@ -11,7 +13,7 @@ You can attach a Java debugger at `localhost:8081`. The realm export is in `import/testing.json`. It will be imported automatically when Keycloak starts. -## Updating the Realm +### Updating the Realm If you want to make changes to the testing realm and persist them, you need to export the realm. @@ -25,16 +27,36 @@ docker compose exec -it keycloak sh -c "cp -rp /opt/keycloak/data/h2 /tmp && env ## Running Integration Tests -This directory also contains shell scripts that exercise the attribute endpoint. +This directory contains shell scripts that exercise the attribute endpoint. Bring up Keycloak with `docker compose up -d`, then run one of the `client-test-export-`*`.sh` scripts. ```shell $ ./client-test-export-dooris.sh +All attributes collated into a single list [ "hacker-ssh-key-1", "hacker-ssh-key-2" ] +Results mapped per attribute +{ + "ssh-key-1": [ + "hacker-ssh-key-1" + ], + "ssh-key-2": [ + "hacker-ssh-key-2" + ] +} +$ ./client-test-export-mailing-lists.sh +chaos@ +[ + "hacker+chaos@example.net", + "tester+chaos@example.com" +] +intern@ +[ + "hacker+intern@example.net" +] ``` ## Realm `testing` Configuration @@ -43,16 +65,16 @@ The realm contains these objects: * Clients: * `export-dooris-ssh-keys` with secret `export-dooris-ssh-keys-secret` and user `service-account-export-dooris-ssh-keys` - * `export-mailing-list-addresses` with secret `export-mailing-list-addresses-secret` and role `export-mailing-list-addresses` + * `export-mailing-list-addresses` with secret `export-mailing-list-addresses-secret` and user `service-account-export-mailing-list-addresses` * Realm Roles: - * `dooris-authorized` - * `mailing-list-chaos-member` - * `mailing-list-intern-member` - * `export-dooris-ssh-keys` - * `export-mailing-list-addresses` + * `dooris-authorized` to assign to users whose SSH keys should be exported + * `mailing-list-chaos-member` to assign to users who are a member of the chaos mailing list + * `mailing-list-intern-member` to assign to users who are a member of the intern mailing list + * `export-dooris-ssh-keys` to assign to service account users that should be allowed to use the dooris endpoint config + * `export-mailing-list-addresses` to assign to service account users that should be allowed to use the mailing list endpoint configs * Groups: * `chaos` with role `mailing-list-chaos-member` - * `ìntern` with roles `dooris-authorized` and `mailing-list-intern-member` + * `ìntern` with roles `dooris-authorized`, `mailing-list-chaos-member` and `mailing-list-intern-member` * Users: * `tester` (Tony Tester), email `tester@example.com`, member of group `chaos` * Mailing List Addresses: @@ -81,7 +103,7 @@ The realm contains these objects: * `mailing-list-address-chaos` * Attribute Group `mailing-list-addresses` * `mailing-list-address-intern` - * Attribute Group `mailing-list-intern` + * Attribute Group `mailing-list-addresses` * `ssh-key-1` * Attribute Group `dooris-ssh-keys` * `ssh-key-2` diff --git a/testing/client-test-export-dooris.sh b/testing/client-test-export-dooris.sh index b6d4696..82f5ad3 100755 --- a/testing/client-test-export-dooris.sh +++ b/testing/client-test-export-dooris.sh @@ -7,14 +7,21 @@ set -e ACCESS_TOKEN="$(curl -s --request POST \ - --url http://localhost:8080/realms/testing/protocol/openid-connect/token \ - --header 'content-type: application/x-www-form-urlencoded' \ - --data scope=openid \ - --data client_id=export-dooris-ssh-keys \ - --data client_secret=export-dooris-ssh-keys-secret \ - --data grant_type=client_credentials | jq --raw-output .access_token -)" + --url http://localhost:8080/realms/testing/protocol/openid-connect/token \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data scope=openid \ + --data client_id=export-dooris-ssh-keys \ + --data client_secret=export-dooris-ssh-keys-secret \ + --data grant_type=client_credentials | jq --raw-output .access_token -)" +echo "All attributes collated into a single list" curl -s --request GET \ - --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/dooris-ssh-keys \ - --header "authorization: Bearer ${ACCESS_TOKEN}" \ - --header "content-type: application/json" | jq . - \ No newline at end of file + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/dooris-ssh-keys \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - + +echo "Results mapped per attribute" +curl -s --request GET \ + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/dooris-ssh-keys/map \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - diff --git a/testing/client-test-export-mailing-lists.sh b/testing/client-test-export-mailing-lists.sh new file mode 100755 index 0000000..4f15d76 --- /dev/null +++ b/testing/client-test-export-mailing-lists.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +# +# Use curl to run a test export of Dooris ssh keys +# + +set -e + +ACCESS_TOKEN="$(curl -s --request POST \ + --url http://localhost:8080/realms/testing/protocol/openid-connect/token \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data scope=openid \ + --data client_id=export-mailing-list-addresses \ + --data client_secret=export-mailing-list-addresses-secret \ + --data grant_type=client_credentials | jq --raw-output .access_token -)" + +echo "chaos@" +curl -s --request GET \ + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/mailing-list-addresses-chaos \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - + +echo "intern@" +curl -s --request GET \ + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/mailing-list-addresses-intern \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - diff --git a/testing/compose.yaml b/testing/compose.yaml index 0311532..3f73478 100644 --- a/testing/compose.yaml +++ b/testing/compose.yaml @@ -12,6 +12,17 @@ services: ports: - "8080:8080" - "8081:8081" + networks: + - keycloak volumes: - ./import:/opt/keycloak/data/import/ - - ../attribute-endpoints-provider/target/attribute-endpoints-provider-1.0-SNAPSHOT.jar:/opt/keycloak/providers/attribute-endpoints-provider.jar \ No newline at end of file + - ../attribute-endpoints-provider/target/attribute-endpoints-provider-1.0-SNAPSHOT.jar:/opt/keycloak/providers/attribute-endpoints-provider.jar + +networks: + # force a "local" IP address that Keycloak accepts HTTP (not HTTPS) requests from + keycloak: + driver: bridge + ipam: + config: + - subnet: 192.168.231.128/29 + gateway: 192.168.231.129 diff --git a/testing/import/testing.json b/testing/import/testing.json index 3744ff8..dfa0fa7 100644 --- a/testing/import/testing.json +++ b/testing/import/testing.json @@ -1478,7 +1478,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "saml-user-attribute-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper" ] } }, { "id" : "a49de9bf-462b-4c61-bb52-0373732f4b1b", @@ -1538,7 +1538,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper" ] } }, { "id" : "dd4024e9-f080-4319-aeb7-7f9906c345c5", @@ -1605,8 +1605,8 @@ "subComponents" : { }, "config" : { "match-role" : [ "mailing-list-intern-member" ], - "auth-role" : [ "export-mailing-list-addresses" ], "attribute-group" : [ "mailing-list-addresses" ], + "auth-role" : [ "export-mailing-list-addresses" ], "slug" : [ "mailing-list-addresses-intern" ] } }, { @@ -1615,8 +1615,8 @@ "subComponents" : { }, "config" : { "match-role" : [ "dooris-authorized" ], - "auth-role" : [ "export-dooris-ssh-keys" ], "attribute-group" : [ "dooris-ssh-keys" ], + "auth-role" : [ "export-dooris-ssh-keys" ], "slug" : [ "dooris-ssh-keys" ] } }, {