composite tests
This commit is contained in:
parent
6a5994c3e2
commit
1543963c9f
10 changed files with 163 additions and 27 deletions
|
@ -1,6 +1,7 @@
|
||||||
package org.keycloak.models;
|
package org.keycloak.models;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
||||||
|
@ -13,7 +14,7 @@ public interface RoleContainerModel {
|
||||||
|
|
||||||
boolean removeRoleById(String id);
|
boolean removeRoleById(String id);
|
||||||
|
|
||||||
List<RoleModel> getRoles();
|
Set<RoleModel> getRoles();
|
||||||
|
|
||||||
RoleModel getRoleById(String id);
|
RoleModel getRoleById(String id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,6 @@ public interface RoleModel {
|
||||||
|
|
||||||
RoleContainerModel getContainer();
|
RoleContainerModel getContainer();
|
||||||
|
|
||||||
|
|
||||||
boolean hasRole(RoleModel role);
|
boolean hasRole(RoleModel role);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -143,8 +143,8 @@ public class ApplicationAdapter implements ApplicationModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<RoleModel> getRoles() {
|
public Set<RoleModel> getRoles() {
|
||||||
ArrayList<RoleModel> list = new ArrayList<RoleModel>();
|
Set<RoleModel> list = new HashSet<RoleModel>();
|
||||||
Collection<ApplicationRoleEntity> roles = application.getRoles();
|
Collection<ApplicationRoleEntity> roles = application.getRoles();
|
||||||
if (roles == null) return list;
|
if (roles == null) return list;
|
||||||
for (RoleEntity entity : roles) {
|
for (RoleEntity entity : roles) {
|
||||||
|
@ -264,6 +264,7 @@ public class ApplicationAdapter implements ApplicationModel {
|
||||||
|
|
||||||
public boolean equals(Object o) {
|
public boolean equals(Object o) {
|
||||||
if (o == null) return false;
|
if (o == null) return false;
|
||||||
|
if (o == this) return true;
|
||||||
if (!(o instanceof ApplicationAdapter)) return false;
|
if (!(o instanceof ApplicationAdapter)) return false;
|
||||||
ApplicationAdapter app = (ApplicationAdapter)o;
|
ApplicationAdapter app = (ApplicationAdapter)o;
|
||||||
return app.getId().equals(getId());
|
return app.getId().equals(getId());
|
||||||
|
|
|
@ -880,8 +880,8 @@ public class RealmAdapter implements RealmModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<RoleModel> getRoles() {
|
public Set<RoleModel> getRoles() {
|
||||||
ArrayList<RoleModel> list = new ArrayList<RoleModel>();
|
Set<RoleModel> list = new HashSet<RoleModel>();
|
||||||
Collection<RealmRoleEntity> roles = realm.getRoles();
|
Collection<RealmRoleEntity> roles = realm.getRoles();
|
||||||
if (roles == null) return list;
|
if (roles == null) return list;
|
||||||
for (RoleEntity entity : roles) {
|
for (RoleEntity entity : roles) {
|
||||||
|
@ -1000,7 +1000,6 @@ public class RealmAdapter implements RealmModel {
|
||||||
entity.setUser(((UserAdapter) agent).getUser());
|
entity.setUser(((UserAdapter) agent).getUser());
|
||||||
entity.setRole(((RoleAdapter)role).getRole());
|
entity.setRole(((RoleAdapter)role).getRole());
|
||||||
em.persist(entity);
|
em.persist(entity);
|
||||||
em.flush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -3,7 +3,6 @@ package org.keycloak.models.jpa;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.RoleContainerModel;
|
import org.keycloak.models.RoleContainerModel;
|
||||||
import org.keycloak.models.RoleModel;
|
import org.keycloak.models.RoleModel;
|
||||||
import org.keycloak.models.UserModel;
|
|
||||||
import org.keycloak.models.jpa.entities.ApplicationRoleEntity;
|
import org.keycloak.models.jpa.entities.ApplicationRoleEntity;
|
||||||
import org.keycloak.models.jpa.entities.RealmRoleEntity;
|
import org.keycloak.models.jpa.entities.RealmRoleEntity;
|
||||||
import org.keycloak.models.jpa.entities.RoleEntity;
|
import org.keycloak.models.jpa.entities.RoleEntity;
|
||||||
|
@ -23,6 +22,7 @@ public class RoleAdapter implements RoleModel {
|
||||||
protected RealmModel realm;
|
protected RealmModel realm;
|
||||||
|
|
||||||
public RoleAdapter(RealmModel realm, EntityManager em, RoleEntity role) {
|
public RoleAdapter(RealmModel realm, EntityManager em, RoleEntity role) {
|
||||||
|
this.em = em;
|
||||||
this.realm = realm;
|
this.realm = realm;
|
||||||
this.role = role;
|
this.role = role;
|
||||||
}
|
}
|
||||||
|
@ -77,6 +77,7 @@ public class RoleAdapter implements RoleModel {
|
||||||
if (composite.equals(entity)) return;
|
if (composite.equals(entity)) return;
|
||||||
}
|
}
|
||||||
getRole().getCompositeRoles().add(entity);
|
getRole().getCompositeRoles().add(entity);
|
||||||
|
em.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -98,25 +99,27 @@ public class RoleAdapter implements RoleModel {
|
||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean searchCompositeFor(RoleModel role, RoleModel composite, Set<RoleModel> visited) {
|
public static boolean searchFor(RoleModel role, RoleModel composite, Set<RoleModel> visited) {
|
||||||
if (visited.contains(composite)) return false;
|
if (visited.contains(composite)) return false;
|
||||||
visited.add(composite);
|
visited.add(composite);
|
||||||
Set<RoleModel> composites = composite.getComposites();
|
Set<RoleModel> composites = composite.getComposites();
|
||||||
if (composites.contains(role)) return true;
|
if (composites.contains(role)) return true;
|
||||||
for (RoleModel contained : composites) {
|
for (RoleModel contained : composites) {
|
||||||
if (!contained.isComposite()) continue;
|
if (!contained.isComposite()) continue;
|
||||||
if (searchCompositeFor(role, contained, visited)) return true;
|
if (searchFor(role, contained, visited)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean hasRole(RoleModel role) {
|
public boolean hasRole(RoleModel role) {
|
||||||
if (this.equals(role)) return true;
|
if (this.equals(role)) return true;
|
||||||
if (!isComposite()) return false;
|
if (!isComposite()) return false;
|
||||||
|
|
||||||
Set<RoleModel> visited = new HashSet<RoleModel>();
|
Set<RoleModel> visited = new HashSet<RoleModel>();
|
||||||
return searchCompositeFor(role, this, visited);
|
return searchFor(role, this, visited);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -8,6 +8,7 @@ import javax.persistence.Id;
|
||||||
import javax.persistence.Inheritance;
|
import javax.persistence.Inheritance;
|
||||||
import javax.persistence.InheritanceType;
|
import javax.persistence.InheritanceType;
|
||||||
import javax.persistence.JoinTable;
|
import javax.persistence.JoinTable;
|
||||||
|
import javax.persistence.ManyToMany;
|
||||||
import javax.persistence.OneToMany;
|
import javax.persistence.OneToMany;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
@ -17,16 +18,17 @@ import java.util.Collection;
|
||||||
* @version $Revision: 1 $
|
* @version $Revision: 1 $
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
|
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||||
public abstract class RoleEntity {
|
public abstract class RoleEntity {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
private String id;
|
private String id;
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
private String description;
|
private String description;
|
||||||
private boolean composite;
|
private boolean composite;
|
||||||
@OneToMany(fetch = FetchType.LAZY, cascade = {}, orphanRemoval = false)
|
@ManyToMany(fetch = FetchType.LAZY, cascade = {})
|
||||||
@JoinTable(name = "COMPOSITE_ROLE")
|
//@JoinTable(name = "COMPOSITE_ROLE")
|
||||||
private Collection<RoleEntity> compositeRoles = new ArrayList<RoleEntity>();
|
private Collection<RoleEntity> compositeRoles = new ArrayList<RoleEntity>();
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -62,6 +62,21 @@ public class TokenManager {
|
||||||
return scope == null || scope.isEmpty();
|
return scope == null || scope.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void addScopes(RoleModel role, RoleModel scope, Set<RoleModel> visited, Set<RoleModel> requested) {
|
||||||
|
if (visited.contains(scope)) return;
|
||||||
|
visited.add(scope);
|
||||||
|
if (role.hasRole(scope)) {
|
||||||
|
requested.add(scope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!scope.isComposite()) return;
|
||||||
|
|
||||||
|
for (RoleModel contained : scope.getComposites()) {
|
||||||
|
addScopes(role, contained, visited, requested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public AccessCodeEntry createAccessCode(String scopeParam, String state, String redirect, RealmModel realm, UserModel client, UserModel user) {
|
public AccessCodeEntry createAccessCode(String scopeParam, String state, String redirect, RealmModel realm, UserModel client, UserModel user) {
|
||||||
AccessCodeEntry code = new AccessCodeEntry();
|
AccessCodeEntry code = new AccessCodeEntry();
|
||||||
|
@ -73,21 +88,17 @@ public class TokenManager {
|
||||||
|
|
||||||
Set<RoleModel> roleMappings = realm.getRoleMappings(user);
|
Set<RoleModel> roleMappings = realm.getRoleMappings(user);
|
||||||
Set<RoleModel> scopeMappings = realm.getScopeMappings(client);
|
Set<RoleModel> scopeMappings = realm.getScopeMappings(client);
|
||||||
|
ApplicationModel clientApp = realm.getApplicationByName(client.getLoginName());
|
||||||
|
Set<RoleModel> clientAppRoles = clientApp == null ? null : clientApp.getRoles();
|
||||||
|
if (clientAppRoles != null) scopeMappings.addAll(clientAppRoles);
|
||||||
|
|
||||||
Set<RoleModel> requestedRoles = new HashSet<RoleModel>();
|
Set<RoleModel> requestedRoles = new HashSet<RoleModel>();
|
||||||
|
|
||||||
for (RoleModel role : roleMappings) {
|
for (RoleModel role : roleMappings) {
|
||||||
|
if (clientApp != null && role.getContainer().equals(clientApp)) requestedRoles.add(role);
|
||||||
for (RoleModel desiredRole : scopeMappings) {
|
for (RoleModel desiredRole : scopeMappings) {
|
||||||
if (desiredRole.equals(role)) {
|
Set<RoleModel> visited = new HashSet<RoleModel>();
|
||||||
requestedRoles.add(role);
|
addScopes(role, desiredRole, visited, requestedRoles);
|
||||||
} else if (desiredRole.hasRole(role)) {
|
|
||||||
requestedRoles.add(role);
|
|
||||||
} else if (role.hasRole(desiredRole)) {
|
|
||||||
requestedRoles.add(desiredRole);
|
|
||||||
} else if (role.getContainer() instanceof ApplicationModel) {
|
|
||||||
if (((ApplicationModel)role.getContainer()).getApplicationUser().getLoginName().equals(client.getLoginName())) {
|
|
||||||
requestedRoles.add(role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,6 +109,36 @@ public class TokenManager {
|
||||||
ApplicationModel app = (ApplicationModel)role.getContainer();
|
ApplicationModel app = (ApplicationModel)role.getContainer();
|
||||||
if (desiresScope(scopeMap, app.getName(), role.getName())) {
|
if (desiresScope(scopeMap, app.getName(), role.getName())) {
|
||||||
resourceRolesRequested.add(app.getName(), role);
|
resourceRolesRequested.add(app.getName(), role);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Set<RoleModel> realmRoleMappings = realm.getRealmRoleMappings(user);
|
||||||
|
|
||||||
|
for (RoleModel role : realmRoleMappings) {
|
||||||
|
if (!desiresScope(scopeMap, "realm", role.getName())) continue;
|
||||||
|
for (RoleModel desiredRole : scopeMappings) {
|
||||||
|
if (desiredRole.hasRole(role)) {
|
||||||
|
realmRolesRequested.add(role);
|
||||||
|
} else if (role.hasRole(desiredRole)) {
|
||||||
|
realmRolesRequested.add(desiredRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ApplicationModel application : realm.getApplications()) {
|
||||||
|
if (!desiresScopeGroup(scopeMap, application.getName())) continue;
|
||||||
|
Set<RoleModel> appRoleMappings = application.getApplicationRoleMappings(user);
|
||||||
|
for (RoleModel role : appRoleMappings) {
|
||||||
|
if (!desiresScope(scopeMap, application.getName(), role.getName())) continue;
|
||||||
|
for (RoleModel desiredRole : scopeMappings) {
|
||||||
|
if (!application.getApplicationUser().getLoginName().equals(client.getLoginName())
|
||||||
|
&& !desiredRole.hasRole(role)) continue;
|
||||||
|
resourceRolesRequested.add(application.getName(), role);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ public class RoleContainerResource {
|
||||||
@NoCache
|
@NoCache
|
||||||
@Produces("application/json")
|
@Produces("application/json")
|
||||||
public List<RoleRepresentation> getRoles() {
|
public List<RoleRepresentation> getRoles() {
|
||||||
List<RoleModel> roleModels = roleContainer.getRoles();
|
Set<RoleModel> roleModels = roleContainer.getRoles();
|
||||||
List<RoleRepresentation> roles = new ArrayList<RoleRepresentation>();
|
List<RoleRepresentation> roles = new ArrayList<RoleRepresentation>();
|
||||||
for (RoleModel roleModel : roleModels) {
|
for (RoleModel roleModel : roleModels) {
|
||||||
if (!roleModel.getName().startsWith(Constants.INTERNAL_ROLE)) {
|
if (!roleModel.getName().startsWith(Constants.INTERNAL_ROLE)) {
|
||||||
|
|
|
@ -448,7 +448,7 @@ public class AdapterTest extends AbstractKeycloakTest {
|
||||||
test1CreateRealm();
|
test1CreateRealm();
|
||||||
realmModel.addRole("admin");
|
realmModel.addRole("admin");
|
||||||
realmModel.addRole("user");
|
realmModel.addRole("user");
|
||||||
List<RoleModel> roles = realmModel.getRoles();
|
Set<RoleModel> roles = realmModel.getRoles();
|
||||||
Assert.assertEquals(5, roles.size());
|
Assert.assertEquals(5, roles.size());
|
||||||
UserModel user = realmModel.addUser("bburke");
|
UserModel user = realmModel.addUser("bburke");
|
||||||
RoleModel role = realmModel.getRole("user");
|
RoleModel role = realmModel.getRole("user");
|
||||||
|
|
|
@ -97,6 +97,46 @@ public class CompositeRoleTest {
|
||||||
realm.updateCredential(realmRole1Application.getApplicationUser(), UserCredentialModel.password("password"));
|
realm.updateCredential(realmRole1Application.getApplicationUser(), UserCredentialModel.password("password"));
|
||||||
|
|
||||||
|
|
||||||
|
final ApplicationModel appRoleApplication = new ApplicationManager(manager).createApplication(realm, "APP_ROLE_APPLICATION");
|
||||||
|
appRoleApplication.setEnabled(true);
|
||||||
|
appRoleApplication.setBaseUrl("http://localhost:8081/app");
|
||||||
|
appRoleApplication.setManagementUrl("http://localhost:8081/app/logout");
|
||||||
|
realm.updateCredential(appRoleApplication.getApplicationUser(), UserCredentialModel.password("password"));
|
||||||
|
final RoleModel appRole1 = appRoleApplication.addRole("APP_ROLE_1");
|
||||||
|
final RoleModel appRole2 = appRoleApplication.addRole("APP_ROLE_2");
|
||||||
|
|
||||||
|
final RoleModel realmAppCompositeRole = realm.addRole("REALM_APP_COMPOSITE_ROLE");
|
||||||
|
realmAppCompositeRole.setComposite(true);
|
||||||
|
realmAppCompositeRole.addCompositeRole(appRole1);
|
||||||
|
|
||||||
|
final UserModel realmAppCompositeUser = realm.addUser("REALM_APP_COMPOSITE_USER");
|
||||||
|
realmAppCompositeUser.setEnabled(true);
|
||||||
|
realm.updateCredential(realmAppCompositeUser, UserCredentialModel.password("password"));
|
||||||
|
realm.grantRole(realmAppCompositeUser, realmAppCompositeRole);
|
||||||
|
|
||||||
|
final UserModel realmAppRoleUser = realm.addUser("REALM_APP_ROLE_USER");
|
||||||
|
realmAppRoleUser.setEnabled(true);
|
||||||
|
realm.updateCredential(realmAppRoleUser, UserCredentialModel.password("password"));
|
||||||
|
realm.grantRole(realmAppRoleUser, appRole2);
|
||||||
|
|
||||||
|
final ApplicationModel appCompositeApplication = new ApplicationManager(manager).createApplication(realm, "APP_COMPOSITE_APPLICATION");
|
||||||
|
appCompositeApplication.setEnabled(true);
|
||||||
|
appCompositeApplication.setBaseUrl("http://localhost:8081/app");
|
||||||
|
appCompositeApplication.setManagementUrl("http://localhost:8081/app/logout");
|
||||||
|
realm.updateCredential(appCompositeApplication.getApplicationUser(), UserCredentialModel.password("password"));
|
||||||
|
final RoleModel appCompositeRole = appCompositeApplication.addRole("APP_COMPOSITE_ROLE");
|
||||||
|
appCompositeRole.setComposite(true);
|
||||||
|
appCompositeApplication.addScope(appRole2);
|
||||||
|
appCompositeRole.addCompositeRole(realmRole1);
|
||||||
|
appCompositeRole.addCompositeRole(realmRole2);
|
||||||
|
appCompositeRole.addCompositeRole(realmRole3);
|
||||||
|
appCompositeRole.addCompositeRole(appRole1);
|
||||||
|
|
||||||
|
final UserModel appCompositeUser = realm.addUser("APP_COMPOSITE_USER");
|
||||||
|
appCompositeUser.setEnabled(true);
|
||||||
|
realm.updateCredential(appCompositeUser, UserCredentialModel.password("password"));
|
||||||
|
realm.grantRole(appCompositeUser, realmAppCompositeRole);
|
||||||
|
realm.grantRole(appCompositeUser, realmComposite1);
|
||||||
|
|
||||||
deployServlet("app", "/app", ApplicationServlet.class);
|
deployServlet("app", "/app", ApplicationServlet.class);
|
||||||
|
|
||||||
|
@ -115,6 +155,55 @@ public class CompositeRoleTest {
|
||||||
@WebResource
|
@WebResource
|
||||||
protected LoginPage loginPage;
|
protected LoginPage loginPage;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAppCompositeUser() throws Exception {
|
||||||
|
oauth.realm("Test");
|
||||||
|
oauth.realmPublicKey(realmPublicKey);
|
||||||
|
oauth.clientId("APP_COMPOSITE_APPLICATION");
|
||||||
|
oauth.doLogin("APP_COMPOSITE_USER", "password");
|
||||||
|
|
||||||
|
String code = oauth.getCurrentQuery().get("code");
|
||||||
|
AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password");
|
||||||
|
|
||||||
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
|
|
||||||
|
Assert.assertEquals("bearer", response.getTokenType());
|
||||||
|
|
||||||
|
SkeletonKeyToken token = oauth.verifyToken(response.getAccessToken());
|
||||||
|
|
||||||
|
Assert.assertEquals("APP_COMPOSITE_USER", token.getSubject());
|
||||||
|
|
||||||
|
Assert.assertEquals(1, token.getResourceAccess("APP_ROLE_APPLICATION").getRoles().size());
|
||||||
|
Assert.assertEquals(1, token.getRealmAccess().getRoles().size());
|
||||||
|
Assert.assertTrue(token.getResourceAccess("APP_ROLE_APPLICATION").isUserInRole("APP_ROLE_1"));
|
||||||
|
Assert.assertTrue(token.getRealmAccess().isUserInRole("REALM_ROLE_1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRealmAppCompositeUser() throws Exception {
|
||||||
|
oauth.realm("Test");
|
||||||
|
oauth.realmPublicKey(realmPublicKey);
|
||||||
|
oauth.clientId("APP_ROLE_APPLICATION");
|
||||||
|
oauth.doLogin("REALM_APP_COMPOSITE_USER", "password");
|
||||||
|
|
||||||
|
String code = oauth.getCurrentQuery().get("code");
|
||||||
|
AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password");
|
||||||
|
|
||||||
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
|
|
||||||
|
Assert.assertEquals("bearer", response.getTokenType());
|
||||||
|
|
||||||
|
SkeletonKeyToken token = oauth.verifyToken(response.getAccessToken());
|
||||||
|
|
||||||
|
Assert.assertEquals("REALM_APP_COMPOSITE_USER", token.getSubject());
|
||||||
|
|
||||||
|
Assert.assertEquals(1, token.getResourceAccess("APP_ROLE_APPLICATION").getRoles().size());
|
||||||
|
Assert.assertTrue(token.getResourceAccess("APP_ROLE_APPLICATION").isUserInRole("APP_ROLE_1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testRealmOnlyWithUserCompositeAppComposite() throws Exception {
|
public void testRealmOnlyWithUserCompositeAppComposite() throws Exception {
|
||||||
oauth.realm("Test");
|
oauth.realm("Test");
|
||||||
|
|
Loading…
Reference in a new issue