[KECLOAK-8237] - Openshift Client Storage

This commit is contained in:
Pedro Igor 2018-12-05 09:32:53 -02:00
parent 99a5656f0f
commit 0c39eda8d2
35 changed files with 2673 additions and 32 deletions

View file

@ -446,6 +446,10 @@
<artifactId>guice</artifactId>
<classifier>no_aop</classifier>
</dependency>
<dependency>
<groupId>com.openshift</groupId>
<artifactId>openshift-restclient-java</artifactId>
</dependency>
</dependencies>
</project>

View file

@ -784,6 +784,10 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.openshift</groupId>
<artifactId>openshift-restclient-java</artifactId>
</dependency>
</dependencies>
<build>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ * Copyright 2018 Red Hat, Inc. and/or its affiliates
~ * and other contributors as indicated by the @author tags.
~ *
~ * Licensed under the Apache License, Version 2.0 (the "License");
~ * you may not use this file except in compliance with the License.
~ * You may obtain a copy of the License at
~ *
~ * http://www.apache.org/licenses/LICENSE-2.0
~ *
~ * Unless required by applicable law or agreed to in writing, software
~ * distributed under the License is distributed on an "AS IS" BASIS,
~ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ * See the License for the specific language governing permissions and
~ * limitations under the License.
-->
<module xmlns="urn:jboss:module:1.3" name="com.openshift.openshift-restclient-java">
<properties>
<property name="jboss.api" value="private"/>
</properties>
<resources>
<artifact name="${com.openshift:openshift-restclient-java}"/>
</resources>
<dependencies>
<module name="com.squareup.okhttp3"/>
<module name="org.apache.commons.lang"/>
<module name="org.jboss.dmr"/>
<module name="org.apache.log4j"/>
<module name="org.slf4j"/>
</dependencies>
</module>

View file

@ -44,6 +44,9 @@
<module name="org.keycloak.keycloak-authz-policy-common" services="import"/>
<module name="org.keycloak.keycloak-authz-policy-drools" services="import"/>
<!-- Openshift Client Storage -->
<module name="com.openshift.openshift-restclient-java" services="import"/>
<module name="com.googlecode.owasp-java-html-sanitizer"/>
<module name="com.google.guava"/>
<module name="org.freemarker"/>

View file

@ -718,7 +718,7 @@ public class UserCacheSession implements UserCache {
consentModel.setLastUpdatedDate(cachedConsent.getLastUpdatedDate());
for (String clientScopeId : cachedConsent.getClientScopeIds()) {
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, clientScopeId);
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, client, clientScopeId);
if (clientScope != null) {
consentModel.addGrantedClientScope(clientScope);
}

View file

@ -305,7 +305,7 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore {
Collection<UserConsentClientScopeEntity> grantedClientScopeEntities = entity.getGrantedClientScopes();
if (grantedClientScopeEntities != null) {
for (UserConsentClientScopeEntity grantedClientScope : grantedClientScopeEntities) {
ClientScopeModel grantedClientScopeModel = KeycloakModelUtils.findClientScopeById(realm, grantedClientScope.getScopeId());
ClientScopeModel grantedClientScopeModel = KeycloakModelUtils.findClientScopeById(realm, client, grantedClientScope.getScopeId());
if (grantedClientScopeModel != null) {
model.addGrantedClientScope(grantedClientScopeModel);
}

10
pom.xml
View file

@ -92,6 +92,9 @@
<!-- Authorization Drools Policy Provider -->
<version.org.drools>7.11.0.Final</version.org.drools>
<!-- Openshift -->
<version.com.openshift.openshift-restclient-java>6.1.3.Final</version.com.openshift.openshift-restclient-java>
<!-- Others -->
<apacheds.version>2.0.0-M21</apacheds.version>
<apacheds.codec.version>1.0.0-M33</apacheds.codec.version>
@ -1172,6 +1175,13 @@
<version>${project.version}</version>
</dependency>
<!-- Openshift -->
<dependency>
<groupId>com.openshift</groupId>
<artifactId>openshift-restclient-java</artifactId>
<version>${version.com.openshift.openshift-restclient-java}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-saml-as7-modules</artifactId>

View file

@ -628,9 +628,14 @@ public final class KeycloakModelUtils {
* Lookup clientScope OR client by id. Method is useful if you know just ID, but you don't know
* if underlying model is clientScope or client
*/
public static ClientScopeModel findClientScopeById(RealmModel realm, String clientScopeId) {
public static ClientScopeModel findClientScopeById(RealmModel realm, ClientModel client, String clientScopeId) {
ClientScopeModel clientScope = realm.getClientScopeById(clientScopeId);
if (clientScope == null) {
// as fallback we try to resolve dynamic scopes
clientScope = client.getDynamicClientScope(clientScopeId);
}
if (clientScope != null) {
return clientScope;
} else {

View file

@ -0,0 +1,88 @@
/*
* Copyright 2018 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.storage.client;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RoleModel;
public abstract class AbstractReadOnlyClientScopeAdapter implements ClientScopeModel {
@Override
public void setName(String name) {
}
@Override
public void setDescription(String description) {
}
@Override
public void setProtocol(String protocol) {
}
@Override
public void setAttribute(String name, String value) {
}
@Override
public void removeAttribute(String name) {
}
@Override
public ProtocolMapperModel addProtocolMapper(ProtocolMapperModel model) {
return null;
}
@Override
public void removeProtocolMapper(ProtocolMapperModel mapping) {
}
@Override
public void updateProtocolMapper(ProtocolMapperModel mapping) {
}
@Override
public void addScopeMapping(RoleModel role) {
}
@Override
public void deleteScopeMapping(RoleModel role) {
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || !(o instanceof ClientScopeModel)) return false;
ClientScopeModel that = (ClientScopeModel) o;
return that.getId().equals(getId());
}
@Override
public int hashCode() {
return getId().hashCode();
}
}

View file

@ -177,6 +177,17 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
*/
Map<String, ClientScopeModel> getClientScopes(boolean defaultScope, boolean filterByProtocol);
/**
* <p>Returns a {@link ClientScopeModel} associated with this client.
*
* <p>This method is used as a fallback in order to let clients to resolve a {@code scope} dynamically which is not listed as default or optional scope when calling {@link #getClientScopes(boolean, boolean)}.
*
* @param scope the scope name
* @return the client scope
*/
default ClientScopeModel getDynamicClientScope(String scope) {
return null;
}
/**
* Time in seconds since epoc

View file

@ -92,6 +92,4 @@ public interface ClientScopeModel extends ProtocolMapperContainerModel, ScopeCon
default void setIncludeInTokenScope(boolean includeInTokenScope) {
setAttribute(INCLUDE_IN_TOKEN_SCOPE, String.valueOf(includeInTokenScope));
}
}

View file

@ -178,6 +178,10 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.openshift</groupId>
<artifactId>openshift-restclient-java</artifactId>
</dependency>
</dependencies>
<build>
<plugins>

View file

@ -32,6 +32,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import org.keycloak.storage.StorageId;
import java.util.ArrayList;
import java.util.HashSet;
@ -52,28 +53,18 @@ public class ApplicationsBean {
Set<ClientModel> offlineClients = new UserSessionManager(session).findClientsWithOfflineToken(realm, user);
List<ClientModel> realmClients = realm.getClients();
for (ClientModel client : realmClients) {
// Don't show bearerOnly clients
if (client.isBearerOnly()) {
continue;
}
for (ClientModel client : getApplications(session, realm, user)) {
Set<RoleModel> availableRoles = new HashSet<>();
if (client.getClientId().equals(Constants.ADMIN_CLI_CLIENT_ID)
|| client.getClientId().equals(Constants.ADMIN_CONSOLE_CLIENT_ID)) {
if (!AdminPermissions.realms(session, realm, user).isAdmin()) continue;
} else {
// Construct scope parameter with all optional scopes to see all potentially available roles
Set<ClientScopeModel> allClientScopes = new HashSet<>(client.getClientScopes(true, true).values());
allClientScopes.addAll(client.getClientScopes(false, true).values());
allClientScopes.add(client);
// Construct scope parameter with all optional scopes to see all potentially available roles
Set<ClientScopeModel> allClientScopes = new HashSet<>(client.getClientScopes(true, true).values());
allClientScopes.addAll(client.getClientScopes(false, true).values());
allClientScopes.add(client);
availableRoles = TokenManager.getAccess(user, client, allClientScopes);
}
List<RoleModel> realmRolesAvailable = new LinkedList<RoleModel>();
MultivaluedHashMap<String, ClientRoleEntry> resourceRolesAvailable = new MultivaluedHashMap<String, ClientRoleEntry>();
availableRoles = TokenManager.getAccess(user, client, allClientScopes);
List<RoleModel> realmRolesAvailable = new LinkedList<>();
MultivaluedHashMap<String, ClientRoleEntry> resourceRolesAvailable = new MultivaluedHashMap<>();
processRoles(availableRoles, realmRolesAvailable, resourceRolesAvailable);
List<ClientScopeModel> orderedScopes = new ArrayList<>();
@ -94,12 +85,39 @@ public class ApplicationsBean {
additionalGrants.add("${offlineToken}");
}
ApplicationEntry appEntry = new ApplicationEntry(realmRolesAvailable, resourceRolesAvailable, client,
clientScopesGranted, additionalGrants);
applications.add(appEntry);
applications.add(new ApplicationEntry(realmRolesAvailable, resourceRolesAvailable, client, clientScopesGranted, additionalGrants));
}
}
private Set<ClientModel> getApplications(KeycloakSession session, RealmModel realm, UserModel user) {
Set<ClientModel> clients = new HashSet<>();
for (ClientModel client : realm.getClients()) {
// Don't show bearerOnly clients
if (client.isBearerOnly()) {
continue;
}
if (client.getClientId().equals(Constants.ADMIN_CLI_CLIENT_ID)
|| client.getClientId().equals(Constants.ADMIN_CONSOLE_CLIENT_ID)) {
if (!AdminPermissions.realms(session, realm, user).isAdmin()) continue;
}
clients.add(client);
}
List<UserConsentModel> consents = session.users().getConsents(realm, user.getId());
for (UserConsentModel consent : consents) {
ClientModel client = consent.getClient();
if (!new StorageId(client.getId()).isLocal()) {
clients.add(client);
}
}
return clients;
}
private void processRoles(Set<RoleModel> inputRoles, List<RoleModel> realmRoles, MultivaluedHashMap<String, ClientRoleEntry> clientRoles) {
for (RoleModel role : inputRoles) {
if (role.getContainer() instanceof RealmModel) {

View file

@ -988,7 +988,7 @@ public class AuthenticationManager {
List<ClientScopeModel> clientScopesToDisplay = new LinkedList<>();
for (String clientScopeId : authSession.getClientScopes()) {
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, clientScopeId);
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, authSession.getClient(), clientScopeId);
if (clientScope == null || !clientScope.isDisplayOnConsentScreen()) {
continue;

View file

@ -845,8 +845,8 @@ public class LoginActionsService {
boolean updateConsentRequired = false;
for (String clientScopeId : authSession.getClientScopes()) {
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, clientScopeId);
if (clientScope != null) {
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, client, clientScopeId);
if (clientScope != null && clientScope.isDisplayOnConsentScreen()) {
if (!grantedConsent.isClientScopeGranted(clientScope)) {
grantedConsent.addGrantedClientScope(clientScope);
updateConsentRequired = true;

View file

@ -199,7 +199,7 @@ public class DefaultClientSessionContext implements ClientSessionContext {
private Set<ClientScopeModel> loadClientScopes() {
Set<ClientScopeModel> clientScopes = new HashSet<>();
for (String scopeId : clientScopeIds) {
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(clientSession.getClient().getRealm(), scopeId);
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(clientSession.getClient().getRealm(), getClientSession().getClient(), scopeId);
if (clientScope != null) {
if (isClientScopePermittedForUser(clientScope)) {
clientScopes.add(clientScope);

View file

@ -0,0 +1,88 @@
/*
* Copyright 2018 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.storage.openshift;
import java.util.regex.Matcher;
import com.openshift.restclient.IClient;
import com.openshift.restclient.NotFoundException;
import com.openshift.restclient.model.IResource;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.client.ClientStorageProvider;
import org.keycloak.storage.client.ClientStorageProviderModel;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class OpenshiftClientStorageProvider implements ClientStorageProvider {
private final KeycloakSession session;
private final ClientStorageProviderModel providerModel;
private final IClient client;
OpenshiftClientStorageProvider(KeycloakSession session, ClientStorageProviderModel providerModel, IClient client) {
this.session = session;
this.providerModel = providerModel;
this.client = client;
}
@Override
public ClientModel getClientById(String id, RealmModel realm) {
StorageId storageId = new StorageId(id);
if (!storageId.getProviderId().equals(providerModel.getId())) return null;
String clientId = storageId.getExternalId();
return getClientByClientId(clientId, realm);
}
@Override
public ClientModel getClientByClientId(String clientId, RealmModel realm) {
Matcher matcher = OpenshiftClientStorageProviderFactory.SERVICE_ACCOUNT_PATTERN.matcher(clientId);
IResource resource = null;
if (matcher.matches()) {
resource = getServiceAccount(matcher.group(2), matcher.group(1));
} else {
String defaultNamespace = providerModel.get(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_DEFAULT_NAMESPACE);
if (defaultNamespace != null) {
resource = getServiceAccount(clientId, defaultNamespace);
}
}
if (resource == null) {
return null;
}
return new OpenshiftSAClientAdapter(clientId, resource, client, session, realm, providerModel);
}
@Override
public void close() {
}
private IResource getServiceAccount(String name, String namespace) {
try {
return client.get("ServiceAccount", name, namespace);
} catch (NotFoundException nfe) {
return null;
}
}
}

View file

@ -0,0 +1,147 @@
/*
* Copyright 2018 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.storage.openshift;
import static org.keycloak.storage.CacheableStorageProviderModel.CACHE_POLICY;
import java.util.List;
import java.util.regex.Pattern;
import com.openshift.restclient.ClientBuilder;
import com.openshift.restclient.IClient;
import org.keycloak.common.Profile;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.storage.CacheableStorageProviderModel;
import org.keycloak.storage.client.ClientStorageProviderFactory;
import org.keycloak.storage.client.ClientStorageProviderModel;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class OpenshiftClientStorageProviderFactory implements ClientStorageProviderFactory<OpenshiftClientStorageProvider>, EnvironmentDependentProviderFactory {
public static final String PROVIDER_ID = "openshift-oauth-client";
static final Pattern SERVICE_ACCOUNT_PATTERN = Pattern.compile("system:serviceaccount:([^:]+):([^:]+)");
public static final String CONFIG_PROPERTY_ACCESS_TOKEN = "openshift.access_token";
public static final String CONFIG_PROPERTY_OPENSHIFT_URI = "openshift.uri";
public static final String CONFIG_PROPERTY_DEFAULT_NAMESPACE = "openshift.namespace.default";
public static final String CONFIG_PROPERTY_REQUIRE_USER_CONSENT = "user.consent.require";
public static final String CONFIG_PROPERTY_DISPLAY_SCOPE_CONSENT_TEXT= "user.consent.scope.consent.text";
private final List<ProviderConfigProperty> CONFIG_PROPERTIES;
private IClient client;
public OpenshiftClientStorageProviderFactory() {
CONFIG_PROPERTIES = ProviderConfigurationBuilder.create()
.property().name(CONFIG_PROPERTY_ACCESS_TOKEN)
.type(ProviderConfigProperty.STRING_TYPE)
.label("Access Token")
.helpText("Bearer token that will be used to invoke on Openshift api server. Must have privilege to lookup oauth clients, service accounts, and invoke on token review interface")
.add()
.property().name(CONFIG_PROPERTY_OPENSHIFT_URI)
.type(ProviderConfigProperty.STRING_TYPE)
.label("Openshift URL")
.helpText("Openshift api server URL base endpoint.")
.add()
.property().name(CONFIG_PROPERTY_DEFAULT_NAMESPACE)
.type(ProviderConfigProperty.STRING_TYPE)
.label("Default Namespace")
.helpText("The default namespace to use when the server is not able to resolve the namespace from the client identifier. Useful when clients in Openshift don't have names with the following pattern: " + SERVICE_ACCOUNT_PATTERN.pattern())
.add()
.property().name(CONFIG_PROPERTY_REQUIRE_USER_CONSENT)
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.defaultValue("true")
.label("Require User Consent")
.helpText("If set to true, clients from this storage will ask the end-user for any scope requested during the authorization flow")
.add()
.property().name(CONFIG_PROPERTY_DISPLAY_SCOPE_CONSENT_TEXT)
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.defaultValue("true")
.label("Display Scopes Consent Text")
.helpText("If set to true, the consent page will display texts from the message bundle for scopes. Otherwise, the scope name will be displayed.")
.add()
.build();
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public OpenshiftClientStorageProvider create(KeycloakSession session, ComponentModel model) {
ClientStorageProviderModel providerModel = createProviderModel(model);
IClient client = getClient(providerModel);
if (client != null) {
return new OpenshiftClientStorageProvider(session, providerModel, client);
}
client.getAuthorizationContext().setToken(providerModel.get(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_ACCESS_TOKEN));
return new OpenshiftClientStorageProvider(session, providerModel, client);
}
@Override
public String getHelpText() {
return "Openshift OAuth Client Adapter";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return CONFIG_PROPERTIES;
}
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException {
config.getConfig().putSingle(CACHE_POLICY, CacheableStorageProviderModel.CachePolicy.NO_CACHE.name());
}
@Override
public void onUpdate(KeycloakSession session, RealmModel realm, ComponentModel oldModel, ComponentModel newModel) {
if (!oldModel.get(CONFIG_PROPERTY_OPENSHIFT_URI).equals(newModel.get(CONFIG_PROPERTY_OPENSHIFT_URI))) {
client = null;
} else {
getClient(createProviderModel(newModel)).getAuthorizationContext().setToken(newModel.get(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_ACCESS_TOKEN));
}
}
@Override
public boolean isSupported() {
return Profile.isFeatureEnabled(Profile.Feature.OPENSHIFT_INTEGRATION);
}
private IClient getClient(ClientStorageProviderModel providerModel) {
synchronized (this) {
if (client == null) {
client = new ClientBuilder(providerModel.get(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_OPENSHIFT_URI)).build();
}
}
return client;
}
private ClientStorageProviderModel createProviderModel(ComponentModel model) {
return new ClientStorageProviderModel(model);
}
}

View file

@ -0,0 +1,477 @@
/*
* Copyright 2018 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.storage.openshift;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.openshift.restclient.IClient;
import com.openshift.restclient.model.IResource;
import com.openshift.restclient.model.route.IRoute;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.protocol.oidc.mappers.UserPropertyMapper;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.storage.client.AbstractReadOnlyClientScopeAdapter;
import org.keycloak.storage.client.AbstractReadOnlyClientStorageAdapter;
import org.keycloak.storage.client.ClientStorageProviderModel;
import org.keycloak.util.JsonSerialization;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class OpenshiftSAClientAdapter extends AbstractReadOnlyClientStorageAdapter {
private static final String ANNOTATION_OAUTH_REDIRECT_URI = "serviceaccounts.openshift.io/oauth-redirecturi";
private static final String ANNOTATION_OAUTH_REDIRECT_REFERENCE = "serviceaccounts.openshift.io/oauth-redirectreference";
private static final Pattern ROLE_SCOPE_PATTERN = Pattern.compile("role:([^:]+):([^:!]+)(:[!])?");
private static final Set<String> OPTIONAL_SCOPES = Stream.of("user:info", "user:check-access").collect(Collectors.toSet());
private static final Set<ProtocolMapperModel> DEFAULT_PROTOCOL_MAPPERS = createDefaultProtocolMappers();
private static Set<ProtocolMapperModel> createDefaultProtocolMappers() {
Set<ProtocolMapperModel> mappers = new HashSet<>();
ProtocolMapperModel mapper = OIDCAttributeMapperHelper.createClaimMapper("username", "username", "preferred_username", "string", true, true, UserPropertyMapper.PROVIDER_ID);
mapper.setId(KeycloakModelUtils.generateId());
mappers.add(mapper);
return mappers;
}
private final IResource resource;
private final String clientId;
private final IClient client;
private final ClientRepresentation defaultConfig = new ClientRepresentation();
public OpenshiftSAClientAdapter(String clientId, IResource resource, IClient client, KeycloakSession session, RealmModel realm, ClientStorageProviderModel component) {
super(session, realm, component);
this.resource = resource;
this.clientId = clientId;
this.client = client;
}
@Override
public String getClientId() {
return clientId;
}
@Override
public String getName() {
return resource.getName();
}
@Override
public String getDescription() {
return getConfigOrDefault(() -> defaultConfig.getDescription(), defaultConfig::setDescription, new StringBuilder().append(resource.getKind()).append(" ").append(resource.getName()).append(" from namespace ").append(resource.getNamespace().getName()).toString());
}
@Override
public boolean isEnabled() {
return getConfigOrDefault(() -> defaultConfig.isEnabled(), defaultConfig::setEnabled, true);
}
@Override
public Set<String> getWebOrigins() {
return new HashSet<>(getConfigOrDefault(() -> defaultConfig.getWebOrigins(), defaultConfig::setWebOrigins, Collections.emptyList()));
}
@Override
public Set<String> getRedirectUris() {
return new HashSet<>(getConfigOrDefault((Supplier<List<String>>) () -> defaultConfig.getRedirectUris(),
uris -> defaultConfig.setRedirectUris(uris),
(Supplier<List<String>>) () -> resource.getAnnotations().entrySet().stream()
.filter((entry) -> entry.getKey().startsWith(ANNOTATION_OAUTH_REDIRECT_URI) || entry.getKey().startsWith(ANNOTATION_OAUTH_REDIRECT_REFERENCE))
.map(entry -> {
if (entry.getKey().startsWith(ANNOTATION_OAUTH_REDIRECT_URI)) {
return entry.getValue();
} else {
Map values;
try {
values = JsonSerialization.readValue(entry.getValue(), Map.class);
} catch (IOException e) {
throw new RuntimeException("Failed to parse annotation [" + ANNOTATION_OAUTH_REDIRECT_REFERENCE + "]", e);
}
Map<String, String> reference = (Map<String, String>) values.get("reference");
String kind = (String) reference.get("kind");
if (!"Route".equals(kind)) {
throw new IllegalArgumentException("Only route references are supported for " + ANNOTATION_OAUTH_REDIRECT_REFERENCE);
}
String name = (String) reference.get("name");
IRoute route = client.get(kind, name, resource.getNamespace().getName());
StringBuilder url = new StringBuilder(route.getURL());
if (url.charAt(url.length() - 1) != '/') {
url.append('/');
}
return url.append('*').toString();
}
}).collect(Collectors.toList())));
}
@Override
public String getManagementUrl() {
return null;
}
@Override
public String getRootUrl() {
return null;
}
@Override
public String getBaseUrl() {
return null;
}
@Override
public boolean isBearerOnly() {
return false;
}
@Override
public int getNodeReRegistrationTimeout() {
return 0;
}
@Override
public String getClientAuthenticatorType() {
return null;
}
@Override
public boolean validateSecret(String secret) {
//TODO: do we want SAs as confidential clients and enable client credentials grant and resource owner grant ?
return false;
}
@Override
public String getSecret() {
//TODO: check if validate secret is enough, don't see a reason to return SAs secret
return null;
}
@Override
public String getRegistrationToken() {
return null;
}
@Override
public String getProtocol() {
//TODO: set login protocol, always oidc
return OIDCLoginProtocol.LOGIN_PROTOCOL;
}
@Override
public String getAttribute(String name) {
return null;
}
@Override
public Map<String, String> getAttributes() {
return Collections.emptyMap();
}
@Override
public String getAuthenticationFlowBindingOverride(String binding) {
return null;
}
@Override
public Map<String, String> getAuthenticationFlowBindingOverrides() {
return Collections.emptyMap();
}
@Override
public boolean isFrontchannelLogout() {
return false;
}
@Override
public boolean isFullScopeAllowed() {
return false;
}
@Override
public boolean isPublicClient() {
return true;
}
@Override
public boolean isConsentRequired() {
return component.get(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_REQUIRE_USER_CONSENT, true);
}
@Override
public boolean isDisplayOnConsentScreen() {
return false;
}
@Override
public boolean isStandardFlowEnabled() {
return true;
}
@Override
public boolean isImplicitFlowEnabled() {
return false;
}
@Override
public boolean isDirectAccessGrantsEnabled() {
return false;
}
@Override
public boolean isServiceAccountsEnabled() {
return false;
}
@Override
public Map<String, ClientScopeModel> getClientScopes(boolean defaultScope, boolean filterByProtocol) {
if (defaultScope) {
return Collections.emptyMap();
}
Map<String, ClientScopeModel> scopes = new HashMap<>();
for (String scope : OPTIONAL_SCOPES) {
scopes.put(scope, createClientScope(scope));
}
return scopes;
}
@Override
public ClientScopeModel getDynamicClientScope(String scope) {
if (OPTIONAL_SCOPES.contains(scope)) {
return createClientScope(scope);
}
Matcher matcher = ROLE_SCOPE_PATTERN.matcher(scope);
if (matcher.matches()) {
String namespace = matcher.group(2);
if (resource.getNamespace().getName().equals(namespace)) {
return createClientScope(scope);
}
}
return null;
}
@Override
public int getNotBefore() {
return 0;
}
@Override
public Set<ProtocolMapperModel> getProtocolMappers() {
return getConfigOrDefault(() -> {
List<ProtocolMapperRepresentation> mappers = defaultConfig.getProtocolMappers();
if (mappers == null) {
return null;
}
Set<ProtocolMapperModel> model = new HashSet<>();
for (ProtocolMapperRepresentation mapper : mappers) {
model.add(RepresentationToModel.toModel(mapper));
}
return model;
}, (Consumer<Set<ProtocolMapperModel>>) mappers -> {
defaultConfig.setProtocolMappers(mappers.stream().map(ModelToRepresentation::toRepresentation).collect(Collectors.toList()));
}, (Supplier<Set<ProtocolMapperModel>>) () -> DEFAULT_PROTOCOL_MAPPERS);
}
@Override
public ProtocolMapperModel getProtocolMapperById(String id) {
return getProtocolMappers().stream().filter(protocolMapperModel -> id.equals(protocolMapperModel.getId())).findAny().get();
}
@Override
public ProtocolMapperModel getProtocolMapperByName(String protocol, String name) {
return getProtocolMappers().stream().filter(protocolMapperModel -> name.equals(protocolMapperModel.getName())).findAny().get();
}
@Override
public Set<RoleModel> getScopeMappings() {
return Collections.emptySet();
}
@Override
public Set<RoleModel> getRealmScopeMappings() {
return Collections.emptySet();
}
@Override
public boolean hasScope(RoleModel role) {
return false;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || !(o instanceof ClientModel)) return false;
ClientModel that = (ClientModel) o;
return that.getId().equals(getId());
}
private <V> V getConfigOrDefault(Supplier<V> valueSupplier, Consumer<V> valueConsumer, Supplier<V> defaultValue) {
V value = valueSupplier.get();
if (value != null) {
return value;
}
value = defaultValue.get();
if (valueConsumer != null) {
valueConsumer.accept(value);
}
return value;
}
private <V> V getConfigOrDefault(Supplier<V> valueSupplier, Consumer<V> valueConsumer, V defaultValue) {
return getConfigOrDefault(valueSupplier, valueConsumer, (Supplier<V>) () -> defaultValue);
}
private ClientScopeModel createClientScope(String scope) {
ClientScopeModel managedScope = realm.getClientScopes().stream().filter(scopeModel -> scopeModel.getName().equals(scope))
.findAny().orElse(null);
if (managedScope != null) {
return managedScope;
}
Map<String, String> attributes = new HashMap<>();
attributes.put(ClientScopeModel.DISPLAY_ON_CONSENT_SCREEN, Boolean.valueOf(isConsentRequired()).toString());
if (component.get(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_DISPLAY_SCOPE_CONSENT_TEXT, Boolean.TRUE)) {
StringBuilder consentText = new StringBuilder("${openshift.scope.");
if (scope.indexOf(':') != -1) {
consentText.append(scope.replaceFirst(":", "_"));
}
attributes.put(ClientScopeModel.CONSENT_SCREEN_TEXT, consentText.append("}").toString());
} else {
attributes.put(ClientScopeModel.CONSENT_SCREEN_TEXT, scope);
}
return new AbstractReadOnlyClientScopeAdapter() {
@Override
public String getId() {
return scope;
}
@Override
public String getName() {
return scope;
}
@Override
public RealmModel getRealm() {
return realm;
}
@Override
public String getDescription() {
return scope;
}
@Override
public String getProtocol() {
return OIDCLoginProtocol.LOGIN_PROTOCOL;
}
@Override
public String getAttribute(String name) {
return attributes.get(name);
}
@Override
public Map<String, String> getAttributes() {
return attributes;
}
@Override
public Set<ProtocolMapperModel> getProtocolMappers() {
return DEFAULT_PROTOCOL_MAPPERS;
}
@Override
public ProtocolMapperModel getProtocolMapperById(String id) {
return null;
}
@Override
public ProtocolMapperModel getProtocolMapperByName(String protocol, String name) {
return null;
}
@Override
public Set<RoleModel> getScopeMappings() {
return Collections.emptySet();
}
@Override
public Set<RoleModel> getRealmScopeMappings() {
return Collections.emptySet();
}
@Override
public boolean hasScope(RoleModel role) {
return false;
}
};
}
}

View file

@ -0,0 +1,18 @@
#
# * Copyright 2018 Red Hat, Inc. and/or its affiliates
# * and other contributors as indicated by the @author tags.
# *
# * Licensed under the Apache License, Version 2.0 (the "License");
# * you may not use this file except in compliance with the License.
# * You may obtain a copy of the License at
# *
# * http://www.apache.org/licenses/LICENSE-2.0
# *
# * Unless required by applicable law or agreed to in writing, software
# * distributed under the License is distributed on an "AS IS" BASIS,
# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# * See the License for the specific language governing permissions and
# * limitations under the License.
#
org.keycloak.storage.openshift.OpenshiftClientStorageProviderFactory

View file

@ -0,0 +1,288 @@
/*
* Copyright 2018 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.openshift;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import static org.keycloak.common.Profile.Feature.OPENSHIFT_INTEGRATION;
import static org.keycloak.testsuite.ProfileAssume.assumeFeatureEnabled;
import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.util.Arrays;
import io.undertow.Undertow;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.graphene.page.Page;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.resource.ComponentResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.StreamUtil;
import org.keycloak.events.Details;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.storage.client.ClientStorageProvider;
import org.keycloak.storage.openshift.OpenshiftClientStorageProviderFactory;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.ConsentPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.runonserver.RunOnServerDeployment;
import org.keycloak.testsuite.util.OAuthClient;
/**
* Test that clients can override auth flows
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class OpenshiftClientStorageTest extends AbstractTestRealmKeycloakTest {
private static Undertow OPENSHIFT_API_SERVER;
@Rule
public AssertEvents events = new AssertEvents(this);
@Page
private LoginPage loginPage;
@Page
private AppPage appPage;
@Page
private ConsentPage consentPage;
@Page
private ErrorPage errorPage;
private String userId;
private String clientStorageId;
@Deployment
public static WebArchive deploy() {
return RunOnServerDeployment.create(UserResource.class)
.addPackages(true, "org.keycloak.testsuite");
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
}
@BeforeClass
public static void onBeforeClass() {
OPENSHIFT_API_SERVER = Undertow.builder().addHttpListener(8880, "localhost", new HttpHandler() {
@Override
public void handleRequest(HttpServerExchange exchange) throws Exception {
String uri = exchange.getRequestURI();
if (uri.endsWith("/version/openshift") || uri.endsWith("/version")) {
writeResponse("openshift-version.json", exchange);
} else if (uri.endsWith("/oapi")) {
writeResponse("oapi-response.json", exchange);
} else if (uri.endsWith("/apis")) {
writeResponse("apis-response.json", exchange);
} else if (uri.endsWith("/api")) {
writeResponse("api.json", exchange);
} else if (uri.endsWith("/api/v1")) {
writeResponse("api-v1.json", exchange);
} else if (uri.endsWith("/oapi/v1")) {
writeResponse("oapi-v1.json", exchange);
} else if (uri.contains("/apis/route.openshift.io/v1")) {
writeResponse("apis-route-v1.json", exchange);
} else if (uri.endsWith("/api/v1/namespaces/default")) {
writeResponse("namespace-default.json", exchange);
} else if (uri.endsWith("/oapi/v1/namespaces/default/routes/proxy")) {
writeResponse("route-response.json", exchange);
} else if (uri.contains("/serviceaccounts/system")) {
writeResponse("sa-system.json", exchange);
} else if (uri.contains("/serviceaccounts/")) {
writeResponse(uri.substring(uri.lastIndexOf('/') + 1) + ".json", exchange);
}
}
private void writeResponse(String file, HttpServerExchange exchange) throws IOException {
exchange.getResponseSender().send(StreamUtil.readString(getClass().getResourceAsStream("/openshift/client-storage/" + file)));
}
}).build();
OPENSHIFT_API_SERVER.start();
}
@AfterClass
public static void onAfterClass() {
OPENSHIFT_API_SERVER.stop();
}
@Before
public void onBefore() {
assumeFeatureEnabled(OPENSHIFT_INTEGRATION);
ComponentRepresentation provider = new ComponentRepresentation();
provider.setName("openshift-client-storage");
provider.setProviderId(OpenshiftClientStorageProviderFactory.PROVIDER_ID);
provider.setProviderType(ClientStorageProvider.class.getName());
provider.setConfig(new MultivaluedHashMap<>());
provider.getConfig().putSingle(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_OPENSHIFT_URI, "http://localhost:8880");
provider.getConfig().putSingle(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_ACCESS_TOKEN, "token");
provider.getConfig().putSingle(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_DEFAULT_NAMESPACE, "default");
provider.getConfig().putSingle(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_REQUIRE_USER_CONSENT, "true");
Response resp = adminClient.realm("test").components().add(provider);
resp.close();
clientStorageId = ApiUtil.getCreatedId(resp);
getCleanup().addComponentId(clientStorageId);
}
@Before
public void clientConfiguration() {
userId = findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId();
}
@Test
public void testCodeGrantFlowWithServiceAccountUsingOAuthRedirectReference() {
String clientId = "system:serviceaccount:default:sa-oauth-redirect-reference";
testCodeGrantFlow(clientId, "https://myapp.org/callback", () -> assertSuccessfulResponseWithoutConsent(clientId));
}
@Test
public void failCodeGrantFlowWithServiceAccountUsingOAuthRedirectReference() throws Exception {
testCodeGrantFlow("system:serviceaccount:default:sa-oauth-redirect-reference", "http://myapp.org/callback", () -> assertEquals(OAuthErrorException.INVALID_REDIRECT_URI, events.poll().getError()));
}
@Test
public void testCodeGrantFlowWithServiceAccountUsingOAuthRedirectUri() {
String clientId = "system:serviceaccount:default:sa-oauth-redirect-uri";
testCodeGrantFlow(clientId, "http://localhost:8180/auth/realms/master/app/auth", () -> assertSuccessfulResponseWithoutConsent(clientId));
testCodeGrantFlow(clientId, "http://localhost:8180/auth/realms/master/app/auth/second", () -> assertSuccessfulResponseWithoutConsent(clientId));
testCodeGrantFlow(clientId, "http://localhost:8180/auth/realms/master/app/auth/third", () -> assertSuccessfulResponseWithoutConsent(clientId));
}
@Test
public void testCodeGrantFlowWithUserConsent() {
String clientId = "system:serviceaccount:default:sa-oauth-redirect-uri";
testCodeGrantFlow(clientId, "http://localhost:8180/auth/realms/master/app/auth", () -> assertSuccessfulResponseWithConsent(clientId), "user:info user:check-access");
ComponentResource component = testRealm().components().component(clientStorageId);
ComponentRepresentation representation = component.toRepresentation();
representation.getConfig().put(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_REQUIRE_USER_CONSENT, Arrays.asList("false"));
component.update(representation);
testCodeGrantFlow(clientId, "http://localhost:8180/auth/realms/master/app/auth", () -> assertSuccessfulResponseWithoutConsent(clientId), "user:info user:check-access");
representation.getConfig().put(OpenshiftClientStorageProviderFactory.CONFIG_PROPERTY_REQUIRE_USER_CONSENT, Arrays.asList("true"));
component.update(representation);
testCodeGrantFlow(clientId, "http://localhost:8180/auth/realms/master/app/auth", () -> assertSuccessfulResponseWithoutConsent(clientId, Details.CONSENT_VALUE_PERSISTED_CONSENT), "user:info user:check-access");
testRealm().users().get(userId).revokeConsent(clientId);
testCodeGrantFlow(clientId, "http://localhost:8180/auth/realms/master/app/auth", () -> assertSuccessfulResponseWithConsent(clientId), "user:info user:check-access");
}
@Test
public void failCodeGrantFlowWithServiceAccountUsingOAuthRedirectUri() throws Exception {
testCodeGrantFlow("system:serviceaccount:default:sa-oauth-redirect-uri", "http://myapp.org/callback", () -> assertEquals(OAuthErrorException.INVALID_REDIRECT_URI, events.poll().getError()));
}
private void testCodeGrantFlow(String clientId, String expectedRedirectUri, Runnable assertThat) {
testCodeGrantFlow(clientId, expectedRedirectUri, assertThat, null);
}
private void testCodeGrantFlow(String clientId, String expectedRedirectUri, Runnable assertThat, String scope) {
if (scope != null) {
oauth.scope(scope);
}
oauth.clientId(clientId);
oauth.redirectUri(expectedRedirectUri);
driver.navigate().to(oauth.getLoginFormUrl());
loginPage.assertCurrent();
try {
// Fill username+password. I am successfully authenticated
oauth.fillLoginForm("test-user@localhost", "password");
} catch (Exception ignore) {
}
assertThat.run();
}
private void assertSuccessfulResponseWithoutConsent(String clientId) {
assertSuccessfulResponseWithoutConsent(clientId, null);
}
private void assertSuccessfulResponseWithoutConsent(String clientId, String consentDetail) {
AssertEvents.ExpectedEvent expectedEvent = events.expectLogin().client(clientId).detail(Details.REDIRECT_URI, oauth.getRedirectUri()).detail(Details.USERNAME, "test-user@localhost");
if (consentDetail != null) {
expectedEvent.detail(Details.CONSENT, Details.CONSENT_VALUE_PERSISTED_CONSENT);
}
expectedEvent.assertEvent();
assertSuccessfulRedirect();
}
private void assertSuccessfulResponseWithConsent(String clientId) {
consentPage.assertCurrent();
driver.getPageSource().contains("user:info");
driver.getPageSource().contains("user:check-access");
consentPage.confirm();
events.expectLogin().client(clientId).detail(Details.REDIRECT_URI, oauth.getRedirectUri()).detail(Details.USERNAME, "test-user@localhost").detail(Details.CONSENT, Details.CONSENT_VALUE_CONSENT_GRANTED).assertEvent();
assertSuccessfulRedirect("user:info", "user:check-access");
}
private void assertSuccessfulRedirect(String... expectedScopes) {
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, null);
String accessToken = tokenResponse.getAccessToken();
Assert.assertNotNull(accessToken);
try {
AccessToken token = new JWSInput(accessToken).readJsonContent(AccessToken.class);
for (String expectedScope : expectedScopes) {
token.getScope().contains(expectedScope);
}
} catch (Exception e) {
fail("Failed to parse access token");
e.printStackTrace();
}
Assert.assertNotNull(tokenResponse.getRefreshToken());
oauth.doLogout(tokenResponse.getRefreshToken(), null);
events.clear();
}
}

View file

@ -0,0 +1,495 @@
{
"kind": "APIResourceList",
"groupVersion": "v1",
"resources": [
{
"name": "bindings",
"singularName": "",
"namespaced": true,
"kind": "Binding",
"verbs": [
"create"
]
},
{
"name": "componentstatuses",
"singularName": "",
"namespaced": false,
"kind": "ComponentStatus",
"verbs": [
"get",
"list"
],
"shortNames": [
"cs"
]
},
{
"name": "configmaps",
"singularName": "",
"namespaced": true,
"kind": "ConfigMap",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"cm"
]
},
{
"name": "endpoints",
"singularName": "",
"namespaced": true,
"kind": "Endpoints",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"ep"
]
},
{
"name": "events",
"singularName": "",
"namespaced": true,
"kind": "Event",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"ev"
]
},
{
"name": "limitranges",
"singularName": "",
"namespaced": true,
"kind": "LimitRange",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"limits"
]
},
{
"name": "namespaces",
"singularName": "",
"namespaced": false,
"kind": "Namespace",
"verbs": [
"create",
"delete",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"ns"
]
},
{
"name": "namespaces/finalize",
"singularName": "",
"namespaced": false,
"kind": "Namespace",
"verbs": [
"update"
]
},
{
"name": "namespaces/status",
"singularName": "",
"namespaced": false,
"kind": "Namespace",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "nodes",
"singularName": "",
"namespaced": false,
"kind": "Node",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"no"
]
},
{
"name": "nodes/proxy",
"singularName": "",
"namespaced": false,
"kind": "Node",
"verbs": []
},
{
"name": "nodes/status",
"singularName": "",
"namespaced": false,
"kind": "Node",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "persistentvolumeclaims",
"singularName": "",
"namespaced": true,
"kind": "PersistentVolumeClaim",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"pvc"
]
},
{
"name": "persistentvolumeclaims/status",
"singularName": "",
"namespaced": true,
"kind": "PersistentVolumeClaim",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "persistentvolumes",
"singularName": "",
"namespaced": false,
"kind": "PersistentVolume",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"pv"
]
},
{
"name": "persistentvolumes/status",
"singularName": "",
"namespaced": false,
"kind": "PersistentVolume",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "pods",
"singularName": "",
"namespaced": true,
"kind": "Pod",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"po"
],
"categories": [
"all"
]
},
{
"name": "pods/attach",
"singularName": "",
"namespaced": true,
"kind": "Pod",
"verbs": []
},
{
"name": "pods/binding",
"singularName": "",
"namespaced": true,
"kind": "Binding",
"verbs": [
"create"
]
},
{
"name": "pods/eviction",
"singularName": "",
"namespaced": true,
"group": "policy",
"version": "v1beta1",
"kind": "Eviction",
"verbs": [
"create"
]
},
{
"name": "pods/exec",
"singularName": "",
"namespaced": true,
"kind": "Pod",
"verbs": []
},
{
"name": "pods/log",
"singularName": "",
"namespaced": true,
"kind": "Pod",
"verbs": [
"get"
]
},
{
"name": "pods/portforward",
"singularName": "",
"namespaced": true,
"kind": "Pod",
"verbs": []
},
{
"name": "pods/proxy",
"singularName": "",
"namespaced": true,
"kind": "Pod",
"verbs": []
},
{
"name": "pods/status",
"singularName": "",
"namespaced": true,
"kind": "Pod",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "podtemplates",
"singularName": "",
"namespaced": true,
"kind": "PodTemplate",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "replicationcontrollers",
"singularName": "",
"namespaced": true,
"kind": "ReplicationController",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"rc"
],
"categories": [
"all"
]
},
{
"name": "replicationcontrollers/scale",
"singularName": "",
"namespaced": true,
"group": "autoscaling",
"version": "v1",
"kind": "Scale",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "replicationcontrollers/status",
"singularName": "",
"namespaced": true,
"kind": "ReplicationController",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "resourcequotas",
"singularName": "",
"namespaced": true,
"kind": "ResourceQuota",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"quota"
]
},
{
"name": "resourcequotas/status",
"singularName": "",
"namespaced": true,
"kind": "ResourceQuota",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "secrets",
"singularName": "",
"namespaced": true,
"kind": "Secret",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "serviceaccounts",
"singularName": "",
"namespaced": true,
"kind": "ServiceAccount",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"sa"
]
},
{
"name": "services",
"singularName": "",
"namespaced": true,
"kind": "Service",
"verbs": [
"create",
"delete",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"svc"
],
"categories": [
"all"
]
},
{
"name": "services/proxy",
"singularName": "",
"namespaced": true,
"kind": "Service",
"verbs": []
},
{
"name": "services/status",
"singularName": "",
"namespaced": true,
"kind": "Service",
"verbs": [
"get",
"patch",
"update"
]
}
]
}

View file

@ -0,0 +1,12 @@
{
"kind": "APIVersions",
"versions": [
"v1"
],
"serverAddressByClientCIDRs": [
{
"clientCIDR": "0.0.0.0/0",
"serverAddress": "localhost:8880"
}
]
}

View file

@ -0,0 +1,20 @@
{
"kind": "APIGroupList",
"apiVersion": "v1",
"groups": [
{
"name": "route.openshift.io",
"versions": [
{
"groupVersion": "route.openshift.io/v1",
"version": "v1"
}
],
"preferredVersion": {
"groupVersion": "route.openshift.io/v1",
"version": "v1"
},
"serverAddressByClientCIDRs": null
}
]
}

View file

@ -0,0 +1,37 @@
{
"kind": "APIResourceList",
"apiVersion": "v1",
"groupVersion": "route.openshift.io/v1",
"resources": [
{
"name": "routes",
"singularName": "",
"namespaced": true,
"kind": "Route",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"categories": [
"all"
]
},
{
"name": "routes/status",
"singularName": "",
"namespaced": true,
"kind": "Route",
"verbs": [
"get",
"patch",
"update"
]
}
]
}

View file

@ -0,0 +1,25 @@
{
"kind": "Namespace",
"apiVersion": "v1",
"metadata": {
"name": "default",
"selfLink": "/api/v1/namespaces/default",
"uid": "cb37acb7-d084-11e8-aea9-5254001e7d16",
"resourceVersion": "977",
"creationTimestamp": "2018-10-15T14:15:39Z",
"annotations": {
"openshift.io/sa.scc.mcs": "s0:c1,c0",
"openshift.io/sa.scc.supplemental-groups": "1000000000/10000",
"openshift.io/sa.scc.uid-range": "1000000000/10000"
}
},
"spec": {
"finalizers": [
"kubernetes",
"openshift.io/origin"
]
},
"status": {
"phase": "Active"
}
}

View file

@ -0,0 +1,12 @@
{
"kind": "APIVersions",
"versions": [
"v1"
],
"serverAddressByClientCIDRs": [
{
"clientCIDR": "0.0.0.0/0",
"serverAddress": "192.168.121.194:8443"
}
]
}

View file

@ -0,0 +1,732 @@
{
"kind": "APIResourceList",
"groupVersion": "v1",
"resources": [
{
"name": "appliedclusterresourcequotas",
"singularName": "",
"namespaced": true,
"kind": "AppliedClusterResourceQuota",
"verbs": [
"get",
"list"
]
},
{
"name": "buildconfigs",
"singularName": "",
"namespaced": true,
"kind": "BuildConfig",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"bc"
]
},
{
"name": "buildconfigs/instantiate",
"singularName": "",
"namespaced": true,
"kind": "BuildRequest",
"verbs": [
"create"
]
},
{
"name": "buildconfigs/instantiatebinary",
"singularName": "",
"namespaced": true,
"kind": "BinaryBuildRequestOptions",
"verbs": []
},
{
"name": "buildconfigs/webhooks",
"singularName": "",
"namespaced": true,
"kind": "Build",
"verbs": []
},
{
"name": "builds",
"singularName": "",
"namespaced": true,
"kind": "Build",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "builds/clone",
"singularName": "",
"namespaced": true,
"kind": "BuildRequest",
"verbs": [
"create"
]
},
{
"name": "builds/details",
"singularName": "",
"namespaced": true,
"kind": "Build",
"verbs": [
"update"
]
},
{
"name": "builds/log",
"singularName": "",
"namespaced": true,
"kind": "BuildLog",
"verbs": [
"get"
]
},
{
"name": "clusternetworks",
"singularName": "",
"namespaced": false,
"kind": "ClusterNetwork",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "clusterresourcequotas",
"singularName": "",
"namespaced": false,
"kind": "ClusterResourceQuota",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"clusterquota"
]
},
{
"name": "clusterresourcequotas/status",
"singularName": "",
"namespaced": false,
"kind": "ClusterResourceQuota",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "clusterrolebindings",
"singularName": "",
"namespaced": false,
"kind": "ClusterRoleBinding",
"verbs": [
"create",
"delete",
"get",
"list",
"patch",
"update"
]
},
{
"name": "clusterroles",
"singularName": "",
"namespaced": false,
"kind": "ClusterRole",
"verbs": [
"create",
"delete",
"get",
"list",
"patch",
"update"
]
},
{
"name": "deploymentconfigs",
"singularName": "",
"namespaced": true,
"kind": "DeploymentConfig",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"dc"
]
},
{
"name": "deploymentconfigs/instantiate",
"singularName": "",
"namespaced": true,
"kind": "DeploymentRequest",
"verbs": [
"create"
]
},
{
"name": "deploymentconfigs/log",
"singularName": "",
"namespaced": true,
"kind": "DeploymentLog",
"verbs": [
"get"
]
},
{
"name": "deploymentconfigs/rollback",
"singularName": "",
"namespaced": true,
"kind": "DeploymentConfigRollback",
"verbs": [
"create"
]
},
{
"name": "deploymentconfigs/scale",
"singularName": "",
"namespaced": true,
"group": "extensions",
"version": "v1beta1",
"kind": "Scale",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "deploymentconfigs/status",
"singularName": "",
"namespaced": true,
"kind": "DeploymentConfig",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "egressnetworkpolicies",
"singularName": "",
"namespaced": true,
"kind": "EgressNetworkPolicy",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "groups",
"singularName": "",
"namespaced": false,
"kind": "Group",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "hostsubnets",
"singularName": "",
"namespaced": false,
"kind": "HostSubnet",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "identities",
"singularName": "",
"namespaced": false,
"kind": "Identity",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "images",
"singularName": "",
"namespaced": false,
"kind": "Image",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "imagesignatures",
"singularName": "",
"namespaced": false,
"kind": "ImageSignature",
"verbs": [
"create",
"delete"
]
},
{
"name": "imagestreamimages",
"singularName": "",
"namespaced": true,
"kind": "ImageStreamImage",
"verbs": [
"get"
],
"shortNames": [
"isimage"
]
},
{
"name": "imagestreamimports",
"singularName": "",
"namespaced": true,
"kind": "ImageStreamImport",
"verbs": [
"create"
]
},
{
"name": "imagestreammappings",
"singularName": "",
"namespaced": true,
"kind": "ImageStreamMapping",
"verbs": [
"create"
]
},
{
"name": "imagestreams",
"singularName": "",
"namespaced": true,
"kind": "ImageStream",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
],
"shortNames": [
"is"
]
},
{
"name": "imagestreams/secrets",
"singularName": "",
"namespaced": true,
"kind": "SecretList",
"verbs": [
"get"
]
},
{
"name": "imagestreams/status",
"singularName": "",
"namespaced": true,
"kind": "ImageStream",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "imagestreamtags",
"singularName": "",
"namespaced": true,
"kind": "ImageStreamTag",
"verbs": [
"create",
"delete",
"get",
"list",
"patch",
"update"
],
"shortNames": [
"istag"
]
},
{
"name": "localresourceaccessreviews",
"singularName": "",
"namespaced": true,
"kind": "LocalResourceAccessReview",
"verbs": [
"create"
]
},
{
"name": "localsubjectaccessreviews",
"singularName": "",
"namespaced": true,
"kind": "LocalSubjectAccessReview",
"verbs": [
"create"
]
},
{
"name": "netnamespaces",
"singularName": "",
"namespaced": false,
"kind": "NetNamespace",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "oauthaccesstokens",
"singularName": "",
"namespaced": false,
"kind": "OAuthAccessToken",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "oauthauthorizetokens",
"singularName": "",
"namespaced": false,
"kind": "OAuthAuthorizeToken",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "oauthclientauthorizations",
"singularName": "",
"namespaced": false,
"kind": "OAuthClientAuthorization",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "oauthclients",
"singularName": "",
"namespaced": false,
"kind": "OAuthClient",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "podsecuritypolicyreviews",
"singularName": "",
"namespaced": true,
"kind": "PodSecurityPolicyReview",
"verbs": [
"create"
]
},
{
"name": "podsecuritypolicyselfsubjectreviews",
"singularName": "",
"namespaced": true,
"kind": "PodSecurityPolicySelfSubjectReview",
"verbs": [
"create"
]
},
{
"name": "podsecuritypolicysubjectreviews",
"singularName": "",
"namespaced": true,
"kind": "PodSecurityPolicySubjectReview",
"verbs": [
"create"
]
},
{
"name": "processedtemplates",
"singularName": "",
"namespaced": true,
"kind": "Template",
"verbs": [
"create"
]
},
{
"name": "projectrequests",
"singularName": "",
"namespaced": false,
"kind": "ProjectRequest",
"verbs": [
"create",
"list"
]
},
{
"name": "projects",
"singularName": "",
"namespaced": false,
"kind": "Project",
"verbs": [
"create",
"delete",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "resourceaccessreviews",
"singularName": "",
"namespaced": true,
"kind": "ResourceAccessReview",
"verbs": [
"create"
]
},
{
"name": "rolebindingrestrictions",
"singularName": "",
"namespaced": true,
"kind": "RoleBindingRestriction",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "rolebindings",
"singularName": "",
"namespaced": true,
"kind": "RoleBinding",
"verbs": [
"create",
"delete",
"get",
"list",
"patch",
"update"
]
},
{
"name": "roles",
"singularName": "",
"namespaced": true,
"kind": "Role",
"verbs": [
"create",
"delete",
"get",
"list",
"patch",
"update"
]
},
{
"name": "routes",
"singularName": "",
"namespaced": true,
"kind": "Route",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "routes/status",
"singularName": "",
"namespaced": true,
"kind": "Route",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "selfsubjectrulesreviews",
"singularName": "",
"namespaced": true,
"kind": "SelfSubjectRulesReview",
"verbs": [
"create"
]
},
{
"name": "subjectaccessreviews",
"singularName": "",
"namespaced": true,
"kind": "SubjectAccessReview",
"verbs": [
"create"
]
},
{
"name": "subjectrulesreviews",
"singularName": "",
"namespaced": true,
"kind": "SubjectRulesReview",
"verbs": [
"create"
]
},
{
"name": "templates",
"singularName": "",
"namespaced": true,
"kind": "Template",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"name": "useridentitymappings",
"singularName": "",
"namespaced": false,
"kind": "UserIdentityMapping",
"verbs": [
"create",
"delete",
"get",
"patch",
"update"
]
},
{
"name": "users",
"singularName": "",
"namespaced": false,
"kind": "User",
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
}
]
}

View file

@ -0,0 +1,11 @@
{
"major": "3",
"minor": "10+",
"gitVersion": "v3.10.0+2084755-68",
"gitCommit": "2084755",
"gitTreeState": "",
"buildDate": "2018-10-30T09:01:17Z",
"goVersion": "",
"compiler": "",
"platform": ""
}ssss

View file

@ -0,0 +1,44 @@
{
"kind": "Route",
"apiVersion": "v1",
"metadata": {
"name": "proxy",
"namespace": "default",
"selfLink": "/oapi/v1/namespaces/default/routes/proxy",
"uid": "3bf12cd8-d14a-11e8-82c2-5254001e7d16",
"resourceVersion": "45934",
"creationTimestamp": "2018-10-16T13:48:59Z",
"annotations": {
"openshift.io/host.generated": "true"
}
},
"spec": {
"host": "myapp.org",
"to": {
"kind": "Service",
"name": "proxy",
"weight": 100
},
"tls": {
"termination": "reencrypt",
"destinationCACertificate": "-----BEGIN COMMENT-----\nThis is an empty PEM file created to provide backwards compatibility\nfor reencrypt routes that have no destinationCACertificate. This \ncontent will only appear for routes accessed via /oapi/v1/routes.\n-----END COMMENT-----\n"
},
"wildcardPolicy": "None"
},
"status": {
"ingress": [
{
"host": "myapp.org",
"routerName": "router",
"conditions": [
{
"type": "Admitted",
"status": "True",
"lastTransitionTime": "2018-10-16T13:49:00Z"
}
],
"wildcardPolicy": "None"
}
]
}
}

View file

@ -0,0 +1,15 @@
{
"kind": "ServiceAccount",
"apiVersion": "v1",
"metadata": {
"name": "proxy-with-redirect-reference",
"namespace": "default",
"selfLink": "/api/v1/namespaces/default/serviceaccounts/proxy",
"uid": "3befc25c-d14a-11e8-8666-5254001e7d16",
"resourceVersion": "205225",
"creationTimestamp": "2018-10-16T13: 48:59Z",
"annotations": {
"serviceaccounts.openshift.io/oauth-redirectreference.primary": "{\"kind\":\"OAuthRedirectReference\",\"apiVersion\":\"v1\",\"reference\":{\"kind\":\"Route\",\"name\":\"proxy\"}}"
}
}
}

View file

@ -0,0 +1,17 @@
{
"kind": "ServiceAccount",
"apiVersion": "v1",
"metadata": {
"name": "proxy-with-redirect-uri",
"namespace": "default",
"selfLink": "/api/v1/namespaces/default/serviceaccounts/proxy",
"uid": "3befc25c-d14a-11e8-8666-5254001e7d16",
"resourceVersion": "205225",
"creationTimestamp": "2018-10-16T13: 48:59Z",
"annotations": {
"serviceaccounts.openshift.io/oauth-redirecturi.first": "http://localhost:8180/auth/realms/master/app/auth",
"serviceaccounts.openshift.io/oauth-redirecturi.second": "http://localhost:8180/auth/realms/master/app/auth/second",
"serviceaccounts.openshift.io/oauth-redirecturi.third": "http://localhost:8180/auth/realms/master/app/auth/third"
}
}
}

View file

@ -0,0 +1,12 @@
{
"kind": "ServiceAccount",
"apiVersion": "v1",
"metadata": {
"name": "system",
"namespace": "default",
"selfLink": "/api/v1/namespaces/default/serviceaccounts/proxy",
"uid": "3befc25c-d14a-11e8-8666-5254001e7d16",
"resourceVersion": "205225",
"creationTimestamp": "2018-10-16T13: 48:59Z"
}
}

View file

@ -346,3 +346,9 @@ addTeam=Add team to share your resource with
myPermissions=My Permissions
waitingforApproval=Waiting for approval
anyPermission=Any Permission
# Openshift messages
openshift.scope.user_info=User information
openshift.scope.user_check-access=User access information
openshift.scope.user_full=Full Access
openshift.scope.list-projects=List projects

View file

@ -312,3 +312,9 @@ console-verify-email=You are required to verify your email address. An email ha
console-email-code=Email Code:
console-accept-terms=Accept Terms? [y/n]:
console-accept=y
# Openshift messages
openshift.scope.user_info=User information
openshift.scope.user_check-access=User access information
openshift.scope.user_full=Full Access
openshift.scope.list-projects=List projects