[KEYCLOAK-18447] Dynamically select attributes based on requested scopes
This commit is contained in:
parent
82491ae5d2
commit
458c841c39
10 changed files with 225 additions and 24 deletions
|
@ -19,7 +19,6 @@
|
|||
|
||||
package org.keycloak.userprofile;
|
||||
|
||||
import static org.keycloak.userprofile.AttributeMetadata.ALWAYS_FALSE;
|
||||
import static org.keycloak.userprofile.AttributeMetadata.ALWAYS_TRUE;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
@ -73,8 +72,8 @@ public final class UserProfileMetadata implements Cloneable {
|
|||
return addAttribute(new AttributeMetadata(name).addValidator(validators));
|
||||
}
|
||||
|
||||
public AttributeMetadata addAttribute(String name, List<AttributeValidatorMetadata> validator, Predicate<AttributeContext> writeAllowed, Predicate<AttributeContext> required, Predicate<AttributeContext> readAllowed) {
|
||||
return addAttribute(new AttributeMetadata(name, ALWAYS_TRUE, writeAllowed, required, readAllowed).addValidator(validator));
|
||||
public AttributeMetadata addAttribute(String name, List<AttributeValidatorMetadata> validator, Predicate<AttributeContext> selector, Predicate<AttributeContext> writeAllowed, Predicate<AttributeContext> required, Predicate<AttributeContext> readAllowed) {
|
||||
return addAttribute(new AttributeMetadata(name, selector, writeAllowed, required, readAllowed).addValidator(validator));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -78,7 +78,14 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
|||
private static final String PARSED_CONFIG_COMPONENT_KEY = "kc.user.profile.metadata";
|
||||
private static final String UP_PIECE_COMPONENT_CONFIG_KEY_BASE = "config-piece-";
|
||||
|
||||
private static boolean createRequiredForScopePredicate(AttributeContext context, List<String> requiredScopes) {
|
||||
/**
|
||||
* Method used for predicate which returns true if any of the configuredScopes is requested in current auth flow.
|
||||
*
|
||||
* @param context to get current auth flow from
|
||||
* @param configuredScopes to be evaluated
|
||||
* @return
|
||||
*/
|
||||
private static boolean requestedScopePredicate(AttributeContext context, List<String> configuredScopes) {
|
||||
KeycloakSession session = context.getSession();
|
||||
AuthenticationSessionModel authenticationSession = session.getContext().getAuthenticationSession();
|
||||
|
||||
|
@ -89,7 +96,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
|||
String requestedScopesString = authenticationSession.getClientNote(OIDCLoginProtocol.SCOPE_PARAM);
|
||||
ClientModel client = authenticationSession.getClient();
|
||||
|
||||
return getRequestedClientScopes(requestedScopesString, client).map((csm) -> csm.getName()).anyMatch(requiredScopes::contains);
|
||||
return getRequestedClientScopes(requestedScopesString, client).map((csm) -> csm.getName()).anyMatch(configuredScopes::contains);
|
||||
}
|
||||
|
||||
private String defaultRawConfig;
|
||||
|
@ -251,7 +258,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
|||
UPAttributeRequired rc = attrConfig.getRequired();
|
||||
Predicate<AttributeContext> required = AttributeMetadata.ALWAYS_FALSE;
|
||||
|
||||
if (rc != null && !(UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(attributeName))) {
|
||||
if (rc != null && !isUsernameOrEmailAttribute(attributeName)) {
|
||||
// do not take requirements from config for username and email as they are
|
||||
// driven by business logic from parent!
|
||||
if (rc.isAlways() || UPConfigUtils.isRoleForContext(context, rc.getRoles())) {
|
||||
|
@ -259,7 +266,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
|||
} else if (UPConfigUtils.canBeAuthFlowContext(context) && rc.getScopes() != null && !rc.getScopes().isEmpty()) {
|
||||
// for contexts executed from auth flow and with configured scopes requirement
|
||||
// we have to create required validation with scopes based selector
|
||||
required = (c) -> createRequiredForScopePredicate(c, rc.getScopes());
|
||||
required = (c) -> requestedScopePredicate(c, rc.getScopes());
|
||||
}
|
||||
|
||||
validators.add(new AttributeValidatorMetadata(AttributeRequiredByMetadataValidator.ID));
|
||||
|
@ -285,9 +292,17 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
|||
}
|
||||
}
|
||||
|
||||
Predicate<AttributeContext> selector = AttributeMetadata.ALWAYS_TRUE;
|
||||
UPAttributeSelector sc = attrConfig.getSelector();
|
||||
if (sc != null && !isUsernameOrEmailAttribute(attributeName) && UPConfigUtils.canBeAuthFlowContext(context) && sc.getScopes() != null && !sc.getScopes().isEmpty()) {
|
||||
// for contexts executed from auth flow and with configured scopes selector
|
||||
// we have to create correct predicate
|
||||
selector = (c) -> requestedScopePredicate(c, sc.getScopes());
|
||||
}
|
||||
|
||||
Map<String, Object> annotations = attrConfig.getAnnotations();
|
||||
|
||||
if (UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(attributeName)) {
|
||||
if (isUsernameOrEmailAttribute(attributeName)) {
|
||||
if (permissions == null) {
|
||||
writeAllowed = AttributeMetadata.ALWAYS_TRUE;
|
||||
}
|
||||
|
@ -305,7 +320,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
|||
} else {
|
||||
// always add validation for imuttable/read-only attributes
|
||||
validators.add(new AttributeValidatorMetadata(ImmutableAttributeValidator.ID));
|
||||
decoratedMetadata.addAttribute(attributeName, validators, writeAllowed, required, readAllowed).addAnnotations(annotations);
|
||||
decoratedMetadata.addAttribute(attributeName, validators, selector, writeAllowed, required, readAllowed).addAnnotations(annotations);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -313,6 +328,10 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
|
|||
|
||||
}
|
||||
|
||||
private boolean isUsernameOrEmailAttribute(String attributeName) {
|
||||
return UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(attributeName);
|
||||
}
|
||||
|
||||
private Predicate<AttributeContext> createViewAllowedPredicate(Predicate<AttributeContext> canEdit,
|
||||
List<String> viewRoles) {
|
||||
return ac -> UPConfigUtils.isRoleForContext(ac.getContext(), viewRoles) || canEdit.test(ac);
|
||||
|
|
|
@ -35,6 +35,8 @@ public class UPAttribute {
|
|||
private UPAttributeRequired required;
|
||||
/** null means everyone can view and edit the attribute */
|
||||
private UPAttributePermissions permissions;
|
||||
/** null means it is always selected */
|
||||
private UPAttributeSelector selector;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
|
@ -83,9 +85,16 @@ public class UPAttribute {
|
|||
validations.put(validator, config);
|
||||
}
|
||||
|
||||
public UPAttributeSelector getSelector() {
|
||||
return selector;
|
||||
}
|
||||
|
||||
public void setSelector(UPAttributeSelector selector) {
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "UPAttribute [name=" + name + ", permissions=" + permissions + ", required=" + required + ", validations=" + validations + ", annotations="
|
||||
+ annotations + "]";
|
||||
return "UPAttribute [name=" + name + ", permissions=" + permissions + ", selector=" + selector + ", required=" + required + ", validations=" + validations + ", annotations=" + annotations + "]";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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.userprofile.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Config of the rules when attribute is selected.
|
||||
*
|
||||
* @author Vlastimil Elias <velias@redhat.com>
|
||||
*
|
||||
*/
|
||||
public class UPAttributeSelector {
|
||||
|
||||
private List<String> scopes;
|
||||
|
||||
public List<String> getScopes() {
|
||||
return scopes;
|
||||
}
|
||||
|
||||
public void setScopes(List<String> scopes) {
|
||||
this.scopes = scopes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "UPAttributeSelector [scopes=" + scopes + "]";
|
||||
}
|
||||
|
||||
}
|
|
@ -45,7 +45,6 @@ import org.keycloak.models.RealmModel;
|
|||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.userprofile.AttributeContext;
|
||||
import org.keycloak.userprofile.AttributeMetadata;
|
||||
import org.keycloak.userprofile.AttributeValidatorMetadata;
|
||||
import org.keycloak.userprofile.Attributes;
|
||||
import org.keycloak.userprofile.DefaultAttributes;
|
||||
|
@ -283,11 +282,6 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
|
|||
new AttributeValidatorMetadata(DuplicateUsernameValidator.ID),
|
||||
new AttributeValidatorMetadata(UsernameMutationValidator.ID));
|
||||
|
||||
metadata.addAttribute(UserModel.FIRST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID,
|
||||
BlankAttributeValidator.createConfig(Messages.MISSING_FIRST_NAME)));
|
||||
|
||||
metadata.addAttribute(UserModel.LAST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_LAST_NAME)));
|
||||
|
||||
metadata.addAttribute(UserModel.EMAIL, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_EMAIL)),
|
||||
new AttributeValidatorMetadata(EmailValidator.ID, ValidatorConfig.builder().config(EmailValidator.IGNORE_EMPTY_VALUE, true).build()),
|
||||
new AttributeValidatorMetadata(DuplicateEmailValidator.ID),
|
||||
|
@ -311,10 +305,6 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
|
|||
|
||||
metadata.addAttribute(UserModel.USERNAME, new AttributeValidatorMetadata(BrokeringFederatedUsernameHasValueValidator.ID));
|
||||
|
||||
metadata.addAttribute(UserModel.FIRST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_FIRST_NAME)));
|
||||
|
||||
metadata.addAttribute(UserModel.LAST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_LAST_NAME)));
|
||||
|
||||
metadata.addAttribute(UserModel.EMAIL, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_EMAIL)),
|
||||
new AttributeValidatorMetadata(EmailValidator.ID));
|
||||
|
||||
|
|
|
@ -22,8 +22,12 @@ package org.keycloak.userprofile.legacy;
|
|||
import java.util.Map;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.userprofile.AttributeValidatorMetadata;
|
||||
import org.keycloak.userprofile.UserProfileContext;
|
||||
import org.keycloak.userprofile.UserProfileMetadata;
|
||||
import org.keycloak.userprofile.validator.BlankAttributeValidator;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:markus.till@bosch.io">Markus Till</a>
|
||||
|
@ -54,4 +58,13 @@ public class DefaultUserProfileProvider extends AbstractUserProfileProvider<Defa
|
|||
public int order() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata) {
|
||||
UserProfileContext ctx = metadata.getContext();
|
||||
if(ctx != UserProfileContext.USER_API && ctx != UserProfileContext.REGISTRATION_USER_CREATION) {
|
||||
metadata.addAttribute(UserModel.FIRST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_FIRST_NAME)));
|
||||
metadata.addAttribute(UserModel.LAST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_LAST_NAME)));
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -524,6 +524,125 @@ public class VerifyProfileTest extends AbstractTestRealmKeycloakTest {
|
|||
assertEquals("ExistingLast", user.getLastName());
|
||||
assertEquals("ExistingDepartment", user.firstAttribute(ATTRIBUTE_DEPARTMENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAttributeRequiredButNotSelectedByScopeDoesntForceVerificationScreen() {
|
||||
|
||||
setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT);
|
||||
updateUser(user5Id, "ExistingFirst", "ExistingLast", null);
|
||||
|
||||
setUserProfileConfiguration("{\"attributes\": ["
|
||||
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
|
||||
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "},"
|
||||
+ "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{}, \"selector\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}"
|
||||
+ "]}");
|
||||
|
||||
oauth.clientId(client_scope_optional.getClientId()).openLoginForm();
|
||||
|
||||
loginPage.assertCurrent();
|
||||
loginPage.login("login-test5", "password");
|
||||
|
||||
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
|
||||
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAttributeRequiredAndSelectedByScope() {
|
||||
|
||||
setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT);
|
||||
updateUser(user5Id, "ExistingFirst", "ExistingLast", null);
|
||||
|
||||
setUserProfileConfiguration("{\"attributes\": ["
|
||||
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
|
||||
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "},"
|
||||
+ "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{}, \"selector\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}"
|
||||
+ "]}");
|
||||
|
||||
oauth.scope(SCOPE_DEPARTMENT).clientId(client_scope_optional.getClientId()).openLoginForm();
|
||||
|
||||
loginPage.assertCurrent();
|
||||
loginPage.login("login-test5", "password");
|
||||
|
||||
verifyProfilePage.assertCurrent();
|
||||
|
||||
verifyProfilePage.update("FirstAA", "LastAA", "DepartmentAA");
|
||||
|
||||
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
|
||||
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
|
||||
|
||||
events.expectRequiredAction(EventType.VERIFY_PROFILE).client(client_scope_optional).user(user5Id).assertEvent();
|
||||
|
||||
UserRepresentation user = getUser(user5Id);
|
||||
assertEquals("FirstAA", user.getFirstName());
|
||||
assertEquals("LastAA", user.getLastName());
|
||||
assertEquals("DepartmentAA", user.firstAttribute(ATTRIBUTE_DEPARTMENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAttributeNotRequiredAndSelectedByScopeCanBeUpdatedFromVerificationScreenForcedByAnotherAttribute() {
|
||||
|
||||
setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT);
|
||||
updateUser(user5Id, "ExistingFirst", null, null);
|
||||
|
||||
setUserProfileConfiguration("{\"attributes\": ["
|
||||
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
|
||||
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
|
||||
+ "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"selector\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}"
|
||||
+ "]}");
|
||||
|
||||
oauth.scope(SCOPE_DEPARTMENT).clientId(client_scope_optional.getClientId()).openLoginForm();
|
||||
|
||||
loginPage.assertCurrent();
|
||||
loginPage.login("login-test5", "password");
|
||||
|
||||
verifyProfilePage.assertCurrent();
|
||||
|
||||
Assert.assertTrue(verifyProfilePage.isDepartmentPresent());
|
||||
verifyProfilePage.update("FirstAA", "LastAA", "Department AA");
|
||||
|
||||
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
|
||||
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
|
||||
|
||||
events.expectRequiredAction(EventType.VERIFY_PROFILE).client(client_scope_optional).user(user5Id).assertEvent();
|
||||
|
||||
UserRepresentation user = getUser(user5Id);
|
||||
assertEquals("FirstAA", user.getFirstName());
|
||||
assertEquals("LastAA", user.getLastName());
|
||||
assertEquals("Department AA", user.firstAttribute(ATTRIBUTE_DEPARTMENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAttributeRequiredButNotSelectedByScopeIsNotRenderedOnVerificationScreenForcedByAnotherAttribute() {
|
||||
|
||||
setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT);
|
||||
updateUser(user5Id, "ExistingFirst", null, null);
|
||||
|
||||
setUserProfileConfiguration("{\"attributes\": ["
|
||||
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
|
||||
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
|
||||
+ "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{}, \"selector\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}"
|
||||
+ "]}");
|
||||
|
||||
oauth.clientId(client_scope_optional.getClientId()).openLoginForm();
|
||||
|
||||
loginPage.assertCurrent();
|
||||
loginPage.login("login-test5", "password");
|
||||
|
||||
verifyProfilePage.assertCurrent();
|
||||
|
||||
Assert.assertFalse(verifyProfilePage.isDepartmentPresent());
|
||||
verifyProfilePage.update("FirstAA", "LastAA");
|
||||
|
||||
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
|
||||
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
|
||||
|
||||
events.expectRequiredAction(EventType.VERIFY_PROFILE).client(client_scope_optional).user(user5Id).assertEvent();
|
||||
|
||||
UserRepresentation user = getUser(user5Id);
|
||||
assertEquals("FirstAA", user.getFirstName());
|
||||
assertEquals("LastAA", user.getLastName());
|
||||
assertEquals(null, user.firstAttribute(ATTRIBUTE_DEPARTMENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCustomValidationInCustomAttribute() {
|
||||
|
|
|
@ -135,7 +135,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
|
|||
}
|
||||
|
||||
assertThat(profile.getAttributes().nameSet(),
|
||||
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, "address"));
|
||||
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, "address"));
|
||||
|
||||
attributes.put("address", "myaddress");
|
||||
|
||||
|
@ -415,7 +415,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
|
|||
UserModel user = profile.create();
|
||||
|
||||
assertThat(profile.getAttributes().nameSet(),
|
||||
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, "address", "department"));
|
||||
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, "address", "department"));
|
||||
|
||||
assertNull(user.getFirstAttribute("department"));
|
||||
|
||||
|
|
|
@ -104,7 +104,6 @@ public class UPConfigParserTest extends AbstractTestRealmKeycloakTest {
|
|||
Assert.assertNotNull(att.getRequired().getRoles());
|
||||
Assert.assertEquals(2, att.getRequired().getRoles().size());
|
||||
|
||||
// permissions
|
||||
att = config.getAttributes().get(3);
|
||||
Assert.assertTrue(att.getRequired().isAlways());
|
||||
|
||||
|
@ -117,6 +116,12 @@ public class UPConfigParserTest extends AbstractTestRealmKeycloakTest {
|
|||
Assert.assertEquals(2, att.getPermissions().getView().size());
|
||||
Assert.assertTrue(att.getPermissions().getView().contains("admin"));
|
||||
Assert.assertTrue(att.getPermissions().getView().contains("user"));
|
||||
|
||||
//selector
|
||||
att = config.getAttributes().get(4);
|
||||
Assert.assertNotNull(att.getSelector().getScopes());
|
||||
Assert.assertEquals(3, att.getSelector().getScopes().size());
|
||||
Assert.assertTrue(att.getSelector().getScopes().contains("phone-3-sel"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -49,6 +49,9 @@
|
|||
"scopes" : ["phone-1", "phone-2"],
|
||||
"roles" : ["user", "admin"]
|
||||
},
|
||||
"selector" : {
|
||||
"scopes" : ["phone-1-sel", "phone-2-sel", "phone-3-sel"]
|
||||
},
|
||||
"permissions": {
|
||||
"view": ["admin", "user"],
|
||||
"edit": ["admin"]
|
||||
|
|
Loading…
Reference in a new issue