KEYCLOAK-18503 Regex Policy for authorization service

This commit is contained in:
Yoshiyuki Tabata 2021-06-21 09:23:02 +09:00 committed by Pedro Igor
parent b7a4fd8745
commit 52ced98f92
11 changed files with 616 additions and 1 deletions

View file

@ -0,0 +1,63 @@
/*
* Copyright 2021 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.authorization.policy.provider.regex;
import java.util.function.BiFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.attribute.Attributes;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.policy.evaluation.Evaluation;
import org.keycloak.authorization.policy.provider.PolicyProvider;
import org.keycloak.representations.idm.authorization.RegexPolicyRepresentation;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
public class RegexPolicyProvider implements PolicyProvider {
private final BiFunction<Policy, AuthorizationProvider, RegexPolicyRepresentation> representationFunction;
public RegexPolicyProvider(BiFunction<Policy, AuthorizationProvider, RegexPolicyRepresentation> representationFunction) {
this.representationFunction = representationFunction;
}
@Override
public void close() {
}
@Override
public void evaluate(Evaluation evaluation) {
AuthorizationProvider authorizationProvider = evaluation.getAuthorizationProvider();
RegexPolicyRepresentation policy = representationFunction.apply(evaluation.getPolicy(), authorizationProvider);
Attributes.Entry targetClaim = evaluation.getContext().getIdentity().getAttributes().getValue(policy.getTargetClaim());
if (targetClaim == null) {
return;
}
Pattern pattern = Pattern.compile(policy.getPattern());
Matcher matcher = pattern.matcher(targetClaim.asString(0));
if (matcher.matches()) {
evaluation.grant();
}
}
}

View file

@ -0,0 +1,116 @@
/*
* Copyright 2021 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.authorization.policy.provider.regex;
import java.util.HashMap;
import java.util.Map;
import org.keycloak.Config.Scope;
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.policy.provider.PolicyProvider;
import org.keycloak.authorization.policy.provider.PolicyProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.representations.idm.authorization.PolicyRepresentation;
import org.keycloak.representations.idm.authorization.RegexPolicyRepresentation;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
public class RegexPolicyProviderFactory implements PolicyProviderFactory<RegexPolicyRepresentation> {
private RegexPolicyProvider provider = new RegexPolicyProvider(this::toRepresentation);
@Override
public PolicyProvider create(KeycloakSession session) {
return provider;
}
@Override
public void init(Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "regex";
}
@Override
public String getName() {
return "Regex";
}
@Override
public String getGroup() {
return "Identity Based";
}
@Override
public PolicyProvider create(AuthorizationProvider authorization) {
return provider;
}
@Override
public RegexPolicyRepresentation toRepresentation(Policy policy, AuthorizationProvider authorization) {
RegexPolicyRepresentation representation = new RegexPolicyRepresentation();
Map<String, String> config = policy.getConfig();
representation.setTargetClaim(config.get("targetClaim"));
representation.setPattern(config.get("pattern"));
return representation;
}
@Override
public Class<RegexPolicyRepresentation> getRepresentationType() {
return RegexPolicyRepresentation.class;
}
@Override
public void onCreate(Policy policy, RegexPolicyRepresentation representation, AuthorizationProvider authorization) {
updatePolicy(policy, representation);
}
@Override
public void onUpdate(Policy policy, RegexPolicyRepresentation representation, AuthorizationProvider authorization) {
updatePolicy(policy, representation);
}
@Override
public void onImport(Policy policy, PolicyRepresentation representation, AuthorizationProvider authorization) {
policy.setConfig(representation.getConfig());
}
private void updatePolicy(Policy policy, RegexPolicyRepresentation representation) {
Map<String, String> config = new HashMap<>(policy.getConfig());
config.put("targetClaim", representation.getTargetClaim());
config.put("pattern", representation.getPattern());
policy.setConfig(config);
}
}

View file

@ -45,3 +45,4 @@ org.keycloak.authorization.policy.provider.client.ClientPolicyProviderFactory
org.keycloak.authorization.policy.provider.group.GroupPolicyProviderFactory
org.keycloak.authorization.policy.provider.permission.UMAPolicyProviderFactory
org.keycloak.authorization.policy.provider.clientscope.ClientScopePolicyProviderFactory
org.keycloak.authorization.policy.provider.regex.RegexPolicyProviderFactory

View file

@ -0,0 +1,48 @@
/*
* Copyright 2021 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.representations.idm.authorization;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
public class RegexPolicyRepresentation extends AbstractPolicyRepresentation {
private String targetClaim;
private String pattern;
@Override
public String getType() {
return "regex";
}
public String getTargetClaim() {
return targetClaim;
}
public void setTargetClaim(String targetClaim) {
this.targetClaim = targetClaim;
}
public String getPattern() {
return pattern;
}
public void setPattern(String pattern) {
this.pattern = pattern;
}
}

View file

@ -106,4 +106,7 @@ public interface PoliciesResource {
@Path("client-scope")
ClientScopePoliciesResource clientScope();
@Path("regex")
RegexPoliciesResource regex();
}

View file

@ -0,0 +1,37 @@
/*
* Copyright 2021 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.admin.client.resource;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.keycloak.representations.idm.authorization.RegexPolicyRepresentation;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
public interface RegexPoliciesResource {
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
Response create(RegexPolicyRepresentation representation);
}

View file

@ -0,0 +1,196 @@
/*
* Copyright 2021 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.authz;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.fail;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.resource.AuthorizationResource;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.authorization.client.AuthorizationDeniedException;
import org.keycloak.authorization.client.AuthzClient;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.protocol.oidc.mappers.UserAttributeMapper;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.authorization.AuthorizationRequest;
import org.keycloak.representations.idm.authorization.AuthorizationResponse;
import org.keycloak.representations.idm.authorization.PermissionRequest;
import org.keycloak.representations.idm.authorization.RegexPolicyRepresentation;
import org.keycloak.representations.idm.authorization.ResourcePermissionRepresentation;
import org.keycloak.representations.idm.authorization.ResourceRepresentation;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder;
/**
* @author <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
*/
@AuthServerContainerExclude(AuthServer.REMOTE)
public class RegexPolicyTest extends AbstractAuthzTest {
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
ProtocolMapperRepresentation userAttrFooProtocolMapper = new ProtocolMapperRepresentation();
userAttrFooProtocolMapper.setName("userAttrFoo");
userAttrFooProtocolMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID);
userAttrFooProtocolMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Map<String, String> configFoo = new HashMap<>();
configFoo.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
configFoo.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
configFoo.put(OIDCAttributeMapperHelper.JSON_TYPE, "String");
configFoo.put("user.attribute", "foo");
configFoo.put("claim.name", "foo");
userAttrFooProtocolMapper.setConfig(configFoo);
ProtocolMapperRepresentation userAttrBarProtocolMapper = new ProtocolMapperRepresentation();
userAttrBarProtocolMapper.setName("userAttrBar");
userAttrBarProtocolMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID);
userAttrBarProtocolMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Map<String, String> configBar = new HashMap<>();
configBar.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
configBar.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
configBar.put(OIDCAttributeMapperHelper.JSON_TYPE, "String");
configBar.put("user.attribute", "bar");
configBar.put("claim.name", "bar");
userAttrBarProtocolMapper.setConfig(configBar);
testRealms.add(RealmBuilder.create().name("authz-test")
.user(UserBuilder.create().username("marta").password("password").addAttribute("foo", "foo").addAttribute("bar",
"barbar"))
.user(UserBuilder.create().username("taro").password("password").addAttribute("foo", "faa").addAttribute("bar",
"bbarbar"))
.client(ClientBuilder.create().clientId("resource-server-test").secret("secret").authorizationServicesEnabled(true)
.redirectUris("http://localhost/resource-server-test").directAccessGrants()
.protocolMapper(userAttrFooProtocolMapper, userAttrBarProtocolMapper))
.build());
}
@Before
public void configureAuthorization() throws Exception {
createResource("Resource A");
createResource("Resource B");
createRegexPolicy("Regex foo Policy", "foo", "foo");
createRegexPolicy("Regex bar Policy", "bar", "^bar.+$");
createResourcePermission("Resource A Permission", "Resource A", "Regex foo Policy");
createResourcePermission("Resource B Permission", "Resource B", "Regex bar Policy");
}
private void createResource(String name) {
AuthorizationResource authorization = getClient().authorization();
ResourceRepresentation resource = new ResourceRepresentation(name);
authorization.resources().create(resource).close();
}
private void createRegexPolicy(String name, String targetClaim, String pattern) {
RegexPolicyRepresentation policy = new RegexPolicyRepresentation();
policy.setName(name);
policy.setTargetClaim(targetClaim);
policy.setPattern(pattern);
getClient().authorization().policies().regex().create(policy).close();
}
private void createResourcePermission(String name, String resource, String... policies) {
ResourcePermissionRepresentation permission = new ResourcePermissionRepresentation();
permission.setName(name);
permission.addResource(resource);
permission.addPolicy(policies);
getClient().authorization().permissions().resource().create(permission).close();
}
private ClientResource getClient() {
return getClient(getRealm());
}
private ClientResource getClient(RealmResource realm) {
ClientsResource clients = realm.clients();
return clients.findByClientId("resource-server-test").stream()
.map(representation -> clients.get(representation.getId())).findFirst()
.orElseThrow(() -> new RuntimeException("Expected client [resource-server-test]"));
}
private RealmResource getRealm() {
try {
return getAdminClient().realm("authz-test");
} catch (Exception e) {
throw new RuntimeException("Failed to create admin client");
}
}
@Test
public void testWithExpectedUserAttribute() {
// Access Resource A with marta.
AuthzClient authzClient = getAuthzClient();
PermissionRequest request = new PermissionRequest("Resource A");
String ticket = authzClient.protection().permission().create(request).getTicket();
AuthorizationResponse response = authzClient.authorization("marta", "password")
.authorize(new AuthorizationRequest(ticket));
assertNotNull(response.getToken());
// Access Resource B with marta.
request = new PermissionRequest("Resource B");
ticket = authzClient.protection().permission().create(request).getTicket();
response = authzClient.authorization("marta", "password").authorize(new AuthorizationRequest(ticket));
assertNotNull(response.getToken());
}
@Test
public void testWithoutExpectedUserAttribute() {
// Access Resource A with taro.
AuthzClient authzClient = getAuthzClient();
PermissionRequest request = new PermissionRequest("Resource A");
String ticket = authzClient.protection().permission().create(request).getTicket();
try {
authzClient.authorization("taro", "password").authorize(new AuthorizationRequest(ticket));
fail("Should fail.");
} catch (AuthorizationDeniedException ignore) {
}
// Access Resource B with taro.
request = new PermissionRequest("Resource B");
ticket = authzClient.protection().permission().create(request).getTicket();
try {
authzClient.authorization("taro", "password").authorize(new AuthorizationRequest(ticket));
fail("Should fail.");
} catch (AuthorizationDeniedException ignore) {
}
}
private AuthzClient getAuthzClient() {
return AuthzClient.create(getClass().getResourceAsStream("/authorization-test/default-keycloak.json"));
}
}

View file

@ -1692,6 +1692,13 @@ authz-add-client-scope-policy=Add Client Scope Policy
authz-no-client-scopes-assigned=No client scopes assigned.
authz-policy-client-scope-client-scopes.tooltip=Specifies which client scope(s) are allowed by this policy.
select-a-client-scope=Select a client scope
# Authz Regex Policy Detail
authz-add-regex-policy=Add Regex Policy
regex=Regex
authz-policy-target-claim=Target Claim
authz-policy-target-claim.tooltip=Specifies the target claim which the policy will fetch.
authz-policy-regex-pattern=Regex Pattern
authz-policy-regex-pattern.tooltip=Specifies the regex pattern.
# Authz Permission List
authz-no-permissions-available=No permissions available.

View file

@ -418,6 +418,28 @@ module.config(['$routeProvider', function ($routeProvider) {
}
},
controller: 'ResourceServerPolicyClientScopeDetailCtrl'
}).when('/realms/:realm/clients/:client/authz/resource-server/policy/regex/create', {
templateUrl: resourceUrl + '/partials/authz/policy/provider/resource-server-policy-regex-detail.html',
resolve: {
realm: function (RealmLoader) {
return RealmLoader();
},
client : function(ClientLoader) {
return ClientLoader();
}
},
controller: 'ResourceServerPolicyRegexDetailCtrl'
}).when('/realms/:realm/clients/:client/authz/resource-server/policy/regex/:id', {
templateUrl: resourceUrl + '/partials/authz/policy/provider/resource-server-policy-regex-detail.html',
resolve: {
realm: function (RealmLoader) {
return RealmLoader();
},
client : function(ClientLoader) {
return ClientLoader();
}
},
controller: 'ResourceServerPolicyRegexDetailCtrl'
}).when('/realms/:realm/roles/:role/permissions', {
templateUrl : resourceUrl + '/partials/authz/mgmt/realm-role-permissions.html',
resolve : {

View file

@ -2192,6 +2192,28 @@ module.controller('ResourceServerPolicyClientScopeDetailCtrl', function($scope,
}, realm, client, $scope);
});
module.controller('ResourceServerPolicyRegexDetailCtrl', function($scope, realm, client, PolicyController) {
PolicyController.onInit({
getPolicyType : function() {
return "regex";
},
onInit : function() {
},
onInitUpdate : function(policy) {
},
onUpdate : function() {
delete $scope.policy.config;
},
onCreate : function() {
delete $scope.policy.config;
}
}, realm, client, $scope);
});
module.service("PolicyController", function($http, $route, $location, ResourceServer, ResourceServerPolicy, ResourceServerPermission, AuthzDialog, Notifications, policyViewState, PolicyProvider, viewState) {
var PolicyController = {};

View file

@ -0,0 +1,100 @@
<!--
~ JBoss, Home of Professional Open Source.
~ Copyright 2021 Red Hat, Inc., and individual 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.
-->
<div class="col-sm-9 col-md-10 col-sm-push-3 col-md-push-2">
<ol class="breadcrumb">
<li><a href="#/realms/{{realm.realm}}/clients">{{:: 'clients' | translate}}</a></li>
<li><a href="#/realms/{{realm.realm}}/clients/{{client.id}}">{{client.clientId}}</a></li>
<li><a href="#/realms/{{realm.realm}}/clients/{{client.id}}/authz/resource-server">{{:: 'authz-authorization' |
translate}}</a></li>
<li><a href="#/realms/{{realm.realm}}/clients/{{client.id}}/authz/resource-server/policy">{{:: 'authz-policies'
| translate}}</a></li>
<li data-ng-show="policyState.state.policy.name != null && historyBackOnSaveOrCancel">{{policyState.state.policy.name}}</li>
<li data-ng-show="policyState.state.policy.name == null && historyBackOnSaveOrCancel">{{::
policyState.state.previousPage.name | translate}}</li>
<li data-ng-show="create">{{:: 'authz-add-regex-policy' | translate}}</li>
<li data-ng-hide="create">{{:: 'regex' | translate}}</li>
<li data-ng-hide="create">{{originalPolicy.name}}</li>
</ol>
<h1 data-ng-show="create">{{:: 'authz-add-regex-policy' | translate}}</h1>
<h1 data-ng-hide="create">
{{originalPolicy.name|capitalize}}<i class="pficon pficon-delete clickable" data-ng-show="!create"
data-ng-click="remove()"></i>
</h1>
<form class="form-horizontal" name="clientForm" novalidate>
<fieldset class="border-top">
<div class="form-group">
<label class="col-md-2 control-label" for="name">{{:: 'name' | translate}} <span class="required">*</span></label>
<div class="col-sm-6">
<input class="form-control" type="text" id="name" name="name" data-ng-model="policy.name" autofocus required
data-ng-blur="checkNewNameAvailability()">
</div>
<kc-tooltip>{{:: 'authz-policy-name.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="description">{{:: 'description' | translate}} </label>
<div class="col-sm-6">
<input class="form-control" type="text" id="description" name="description"
data-ng-model="policy.description">
</div>
<kc-tooltip>{{:: 'authz-policy-description.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="targetClaim">{{:: 'authz-policy-target-claim' | translate}} <span
class="required">*</span></label>
<div class="col-sm-6">
<input class="form-control" type="text" id="targetClaim" name="targetClaim"
data-ng-model="policy.targetClaim" required>
</div>
<kc-tooltip>{{:: 'authz-policy-target-claim.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="pattern">{{:: 'authz-policy-regex-pattern' | translate}} <span
class="required">*</span></label>
<div class="col-sm-6">
<input class="form-control" type="text" id="pattern" name="pattern" data-ng-model="policy.pattern" required>
</div>
<kc-tooltip>{{:: 'authz-policy-regex-pattern.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="logic">{{:: 'authz-policy-logic' | translate}}</label>
<div class="col-sm-1">
<select class="form-control" id="logic" data-ng-model="policy.logic">
<option value="POSITIVE">{{:: 'authz-policy-logic-positive' | translate}}</option>
<option value="NEGATIVE">{{:: 'authz-policy-logic-negative' | translate}}</option>
</select>
</div>
<kc-tooltip>{{:: 'authz-policy-logic.tooltip' | translate}}</kc-tooltip>
</div>
<input type="hidden" data-ng-model="policy.type" />
</fieldset>
<div class="form-group" data-ng-show="access.manageAuthorization">
<div class="col-md-10 col-md-offset-2">
<button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button>
<button kc-reset data-ng-disabled="!changed && !historyBackOnSaveOrCancel">{{:: 'cancel' | translate}}</button>
</div>
</div>
</form>
</div>
<kc-menu></kc-menu>