diff --git a/authz/client/src/main/java/org/keycloak/authorization/client/representation/ResourceRepresentation.java b/authz/client/src/main/java/org/keycloak/authorization/client/representation/ResourceRepresentation.java index c2e8f8a9e1..fa2622e012 100644 --- a/authz/client/src/main/java/org/keycloak/authorization/client/representation/ResourceRepresentation.java +++ b/authz/client/src/main/java/org/keycloak/authorization/client/representation/ResourceRepresentation.java @@ -20,6 +20,8 @@ package org.keycloak.authorization.client.representation; import java.net.URI; import java.util.Collections; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; @@ -49,6 +51,7 @@ public class ResourceRepresentation { private String iconUri; private String owner; private Boolean ownerManagedAccess; + private Map> attributes; /** * Creates a new instance. @@ -204,4 +207,12 @@ public class ResourceRepresentation { ", scopes=" + scopes + '}'; } + + public void setAttributes(Map> attributes) { + this.attributes = attributes; + } + + public Map> getAttributes() { + return attributes; + } } diff --git a/core/src/main/java/org/keycloak/representations/idm/authorization/ResourceRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/authorization/ResourceRepresentation.java index ce823c9681..071bc324dc 100644 --- a/core/src/main/java/org/keycloak/representations/idm/authorization/ResourceRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/authorization/ResourceRepresentation.java @@ -20,11 +20,14 @@ import java.net.URI; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.keycloak.json.StringListMapDeserializer; /** *

One or more resources that the resource server manages as a set of protected resources. @@ -53,6 +56,10 @@ public class ResourceRepresentation { private List policies; private String displayName; + + @JsonDeserialize(using = StringListMapDeserializer.class) + private Map> attributes; + /** * Creates a new instance. * @@ -195,6 +202,14 @@ public class ResourceRepresentation { } } + public Map> getAttributes() { + return attributes; + } + + public void setAttributes(Map> attributes) { + this.attributes = attributes; + } + public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/authorization/ResourceAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/authorization/ResourceAdapter.java index d310fcaeda..4f59cdb11a 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/authorization/ResourceAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/authorization/ResourceAdapter.java @@ -27,6 +27,7 @@ import org.keycloak.models.cache.infinispan.authorization.entities.CachedResourc import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -35,6 +36,7 @@ import java.util.stream.Collectors; * @version $Revision: 1 $ */ public class ResourceAdapter implements Resource, CachedModel { + protected CachedResource cached; protected StoreFactoryCacheSession cacheSession; protected Resource updated; @@ -210,6 +212,50 @@ public class ResourceAdapter implements Resource, CachedModel { updated.updateScopes(scopes); } + @Override + public Map> getAttributes() { + if (updated != null) return updated.getAttributes(); + return cached.getAttributes(); + } + + @Override + public String getSingleAttribute(String name) { + if (updated != null) return updated.getSingleAttribute(name); + + List values = cached.getAttributes().getOrDefault(name, Collections.emptyList()); + + if (values.isEmpty()) { + return null; + } + + return values.get(0); + } + + @Override + public List getAttribute(String name) { + if (updated != null) return updated.getAttribute(name); + + List values = cached.getAttributes().getOrDefault(name, Collections.emptyList()); + + if (values.isEmpty()) { + return null; + } + + return Collections.unmodifiableList(values); + } + + @Override + public void setAttribute(String name, List values) { + getDelegateForUpdate(); + updated.setAttribute(name, values); + } + + @Override + public void removeAttribute(String name) { + getDelegateForUpdate(); + updated.removeAttribute(name); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/authorization/entities/CachedResource.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/authorization/entities/CachedResource.java index 383ab1c76a..7e754104ae 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/authorization/entities/CachedResource.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/authorization/entities/CachedResource.java @@ -20,8 +20,11 @@ package org.keycloak.models.cache.infinispan.authorization.entities; import org.keycloak.authorization.model.Resource; import org.keycloak.authorization.model.Scope; +import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.models.cache.infinispan.entities.AbstractRevisioned; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -39,6 +42,7 @@ public class CachedResource extends AbstractRevisioned implements InResourceServ private String uri; private Set scopesIds; private boolean ownerManagedAccess; + private MultivaluedHashMap attributes = new MultivaluedHashMap<>(); public CachedResource(Long revision, Resource resource) { super(revision, resource.getId()); @@ -51,6 +55,7 @@ public class CachedResource extends AbstractRevisioned implements InResourceServ this.resourceServerId = resource.getResourceServer().getId(); this.scopesIds = resource.getScopes().stream().map(Scope::getId).collect(Collectors.toSet()); ownerManagedAccess = resource.isOwnerManagedAccess(); + this.attributes.putAll(resource.getAttributes()); } @@ -89,4 +94,8 @@ public class CachedResource extends AbstractRevisioned implements InResourceServ public Set getScopesIds() { return this.scopesIds; } + + public Map> getAttributes() { + return attributes; + } } diff --git a/model/jpa/src/main/java/org/keycloak/authorization/jpa/entities/ResourceAttributeEntity.java b/model/jpa/src/main/java/org/keycloak/authorization/jpa/entities/ResourceAttributeEntity.java new file mode 100644 index 0000000000..19963dc3af --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/authorization/jpa/entities/ResourceAttributeEntity.java @@ -0,0 +1,104 @@ +/* + * 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.authorization.jpa.entities; + +import javax.persistence.Access; +import javax.persistence.AccessType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +/** + * @author Pedro Igor + */ +@NamedQueries({ + @NamedQuery(name="deleteResourceAttributesByNameAndResource", query="delete from ResourceAttributeEntity attr where attr.resource.id = :resourceId and attr.name = :name") +}) +@Table(name="RESOURCE_ATTRIBUTE") +@Entity +public class ResourceAttributeEntity { + + @Id + @Column(name="ID", length = 36) + @Access(AccessType.PROPERTY) // we do this because relationships often fetch id, but not entity. This avoids an extra SQL + private String id; + + @ManyToOne(fetch= FetchType.LAZY) + @JoinColumn(name = "RESOURCE_ID") + private ResourceEntity resource; + + @Column(name = "NAME") + private String name; + @Column(name = "VALUE") + private String value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public ResourceEntity getResource() { + return resource; + } + + public void setResource(ResourceEntity resource) { + this.resource = resource; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + if (!(o instanceof ResourceAttributeEntity)) return false; + + ResourceAttributeEntity that = (ResourceAttributeEntity) o; + + if (!id.equals(that.getId())) return false; + + return true; + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/model/jpa/src/main/java/org/keycloak/authorization/jpa/entities/ResourceEntity.java b/model/jpa/src/main/java/org/keycloak/authorization/jpa/entities/ResourceEntity.java index 861853a32d..8c9960bbb5 100644 --- a/model/jpa/src/main/java/org/keycloak/authorization/jpa/entities/ResourceEntity.java +++ b/model/jpa/src/main/java/org/keycloak/authorization/jpa/entities/ResourceEntity.java @@ -20,6 +20,7 @@ package org.keycloak.authorization.jpa.entities; import javax.persistence.Access; import javax.persistence.AccessType; +import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; @@ -30,11 +31,18 @@ import javax.persistence.ManyToMany; import javax.persistence.ManyToOne; import javax.persistence.NamedQueries; import javax.persistence.NamedQuery; +import javax.persistence.OneToMany; import javax.persistence.Table; import javax.persistence.UniqueConstraint; + +import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedList; import java.util.List; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; + /** * @author Pedro Igor */ @@ -94,6 +102,10 @@ public class ResourceEntity { @JoinTable(name = "RESOURCE_POLICY", joinColumns = @JoinColumn(name = "RESOURCE_ID"), inverseJoinColumns = @JoinColumn(name = "POLICY_ID")) private List policies = new LinkedList<>(); + @OneToMany(cascade = CascadeType.REMOVE, orphanRemoval = true, mappedBy="resource") + @Fetch(FetchMode.SUBSELECT) + private Collection attributes = new ArrayList<>(); + public String getId() { return id; } @@ -179,6 +191,14 @@ public class ResourceEntity { this.policies = policies; } + public Collection getAttributes() { + return attributes; + } + + public void setAttributes(Collection attributes) { + this.attributes = attributes; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/model/jpa/src/main/java/org/keycloak/authorization/jpa/store/ResourceAdapter.java b/model/jpa/src/main/java/org/keycloak/authorization/jpa/store/ResourceAdapter.java index 782f084fc0..02320ece04 100644 --- a/model/jpa/src/main/java/org/keycloak/authorization/jpa/store/ResourceAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/authorization/jpa/store/ResourceAdapter.java @@ -16,20 +16,27 @@ */ package org.keycloak.authorization.jpa.store; +import org.keycloak.authorization.jpa.entities.ResourceAttributeEntity; import org.keycloak.authorization.jpa.entities.ResourceEntity; import org.keycloak.authorization.jpa.entities.ScopeEntity; import org.keycloak.authorization.model.Resource; import org.keycloak.authorization.model.ResourceServer; import org.keycloak.authorization.model.Scope; import org.keycloak.authorization.store.StoreFactory; +import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.models.jpa.JpaModel; +import org.keycloak.models.utils.KeycloakModelUtils; import javax.persistence.EntityManager; +import javax.persistence.Query; + +import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -37,6 +44,7 @@ import java.util.Set; * @version $Revision: 1 $ */ public class ResourceAdapter implements Resource, JpaModel { + private ResourceEntity entity; private EntityManager em; private StoreFactory storeFactory; @@ -158,6 +166,72 @@ public class ResourceAdapter implements Resource, JpaModel { } } + @Override + public Map> getAttributes() { + MultivaluedHashMap result = new MultivaluedHashMap<>(); + for (ResourceAttributeEntity attr : entity.getAttributes()) { + result.add(attr.getName(), attr.getValue()); + } + return result; + } + + @Override + public String getSingleAttribute(String name) { + List values = getAttributes().getOrDefault(name, Collections.emptyList()); + + if (values.isEmpty()) { + return null; + } + + return values.get(0); + } + + @Override + public List getAttribute(String name) { + List values = getAttributes().getOrDefault(name, Collections.emptyList()); + + if (values.isEmpty()) { + return null; + } + + return Collections.unmodifiableList(values); + } + + @Override + public void setAttribute(String name, List values) { + removeAttribute(name); + + for (String value : values) { + ResourceAttributeEntity attr = new ResourceAttributeEntity(); + attr.setId(KeycloakModelUtils.generateId()); + attr.setName(name); + attr.setValue(value); + attr.setResource(entity); + em.persist(attr); + entity.getAttributes().add(attr); + } + } + + @Override + public void removeAttribute(String name) { + Query query = em.createNamedQuery("deleteResourceAttributesByNameAndResource"); + + query.setParameter("name", name); + query.setParameter("resourceId", entity.getId()); + + query.executeUpdate(); + + List toRemove = new ArrayList<>(); + + for (ResourceAttributeEntity attr : entity.getAttributes()) { + if (attr.getName().equals(name)) { + toRemove.add(attr); + } + } + + entity.getAttributes().removeAll(toRemove); + } + public static ResourceEntity toEntity(EntityManager em, Resource resource) { if (resource instanceof ResourceAdapter) { diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-authz-4.0.0.CR1.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-authz-4.0.0.CR1.xml index 1b72a34564..6666052cf7 100755 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-authz-4.0.0.CR1.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-authz-4.0.0.CR1.xml @@ -74,5 +74,21 @@ + + + + + + + + + + + + + + + + diff --git a/model/jpa/src/main/resources/META-INF/persistence.xml b/model/jpa/src/main/resources/META-INF/persistence.xml index 86eda78567..805b7c81f9 100755 --- a/model/jpa/src/main/resources/META-INF/persistence.xml +++ b/model/jpa/src/main/resources/META-INF/persistence.xml @@ -67,6 +67,7 @@ org.keycloak.authorization.jpa.entities.ScopeEntity org.keycloak.authorization.jpa.entities.PolicyEntity org.keycloak.authorization.jpa.entities.PermissionTicketEntity + org.keycloak.authorization.jpa.entities.ResourceAttributeEntity org.keycloak.storage.jpa.entity.BrokerLinkEntity diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/model/Resource.java b/server-spi-private/src/main/java/org/keycloak/authorization/model/Resource.java index cdfc0b6e12..dd7f5d7352 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/model/Resource.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/model/Resource.java @@ -19,6 +19,7 @@ package org.keycloak.authorization.model; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -126,8 +127,56 @@ public interface Resource { */ String getOwner(); + /** + * Indicates if this resource can be managed by the resource owner. + * + * @return {@code true} if this resource can be managed by the resource owner. Otherwise, {@code false}. + */ boolean isOwnerManagedAccess(); + + /** + * Sets if this resource can be managed by the resource owner. + * + * @param ownerManagedAccess {@code true} indicates that this resource can be managed by the resource owner. + */ void setOwnerManagedAccess(boolean ownerManagedAccess); + /** + * Update the set of scopes associated with this resource. + * + * @param scopes the list of scopes to update + */ void updateScopes(Set scopes); + + /** + * Returns the attributes associated with this resource. + * + * @return a map holding the attributes associated with this resource + */ + Map> getAttributes(); + + /** + * Returns the first value of an attribute with the given name + * + * @return the first value of an attribute + */ + String getSingleAttribute(String name); + + /** + * Returns the values of an attribute with the given name + * + * @return the values of an attribute + */ + List getAttribute(String name); + + /** + * Sets an attribute with the given name and values. + * + * @param name the attribute name + * @param value the attribute values + * @return a map holding the attributes associated with this resource + */ + void setAttribute(String name, List values); + + void removeAttribute(String name); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 22b4a3e956..e793cb2dbb 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -841,6 +841,8 @@ public class ModelToRepresentation { } return scope; }).collect(Collectors.toSet())); + + resource.setAttributes(new HashMap<>(model.getAttributes())); } return resource; diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 732491383a..538dcef4b7 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -26,6 +26,7 @@ import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.Map; +import java.util.Map.Entry; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -2351,6 +2352,24 @@ public class RepresentationToModel { existing.updateScopes(resource.getScopes().stream() .map((ScopeRepresentation scope) -> toModel(scope, resourceServer, authorization)) .collect(Collectors.toSet())); + Map> attributes = resource.getAttributes(); + + if (attributes != null) { + Set existingAttrNames = existing.getAttributes().keySet(); + + for (String name : existingAttrNames) { + if (attributes.containsKey(name)) { + existing.setAttribute(name, attributes.get(name)); + attributes.remove(name); + } else { + existing.removeAttribute(name); + } + } + + for (String name : attributes.keySet()) { + existing.setAttribute(name, attributes.get(name)); + } + } return existing; } @@ -2369,6 +2388,14 @@ public class RepresentationToModel { model.updateScopes(scopes.stream().map((Function) scope -> toModel(scope, resourceServer, authorization)).collect(Collectors.toSet())); } + Map> attributes = resource.getAttributes(); + + if (attributes != null) { + for (Entry> entry : attributes.entrySet()) { + model.setAttribute(entry.getKey(), entry.getValue()); + } + } + resource.setId(model.getId()); return model; diff --git a/services/src/main/java/org/keycloak/authorization/admin/ResourceSetService.java b/services/src/main/java/org/keycloak/authorization/admin/ResourceSetService.java index 04c9d74de5..b69d9910b0 100644 --- a/services/src/main/java/org/keycloak/authorization/admin/ResourceSetService.java +++ b/services/src/main/java/org/keycloak/authorization/admin/ResourceSetService.java @@ -294,6 +294,22 @@ public class ResourceSetService { return Response.ok(representation).build(); } + @Path("{id}/attributes") + @GET + @NoCache + @Produces("application/json") + public Response getAttributes(@PathParam("id") String id) { + requireView(); + StoreFactory storeFactory = authorization.getStoreFactory(); + Resource model = storeFactory.getResourceStore().findById(id, resourceServer.getId()); + + if (model == null) { + return Response.status(Status.NOT_FOUND).build(); + } + + return Response.ok(model.getAttributes()).build(); + } + @Path("/search") @GET @NoCache diff --git a/services/src/main/java/org/keycloak/authorization/protection/resource/ResourceService.java b/services/src/main/java/org/keycloak/authorization/protection/resource/ResourceService.java index 0cead7da3c..21fd27ca41 100644 --- a/services/src/main/java/org/keycloak/authorization/protection/resource/ResourceService.java +++ b/services/src/main/java/org/keycloak/authorization/protection/resource/ResourceService.java @@ -150,6 +150,8 @@ public class ResourceService { return scopeRepresentation; }).collect(Collectors.toSet())); + resource.setAttributes(umaResource.getAttributes()); + return resource; } @@ -178,6 +180,8 @@ public class ResourceService { return umaScopeRep; }).collect(Collectors.toSet())); + resource.setAttributes(model.getAttributes()); + return resource; } diff --git a/services/src/main/java/org/keycloak/authorization/protection/resource/representation/UmaResourceRepresentation.java b/services/src/main/java/org/keycloak/authorization/protection/resource/representation/UmaResourceRepresentation.java index fbbe08efec..1ac4608768 100644 --- a/services/src/main/java/org/keycloak/authorization/protection/resource/representation/UmaResourceRepresentation.java +++ b/services/src/main/java/org/keycloak/authorization/protection/resource/representation/UmaResourceRepresentation.java @@ -22,6 +22,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.net.URI; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -49,6 +51,8 @@ public class UmaResourceRepresentation { private String owner; private Boolean ownerManagedAccess; + private Map> attributes; + /** * Creates a new instance. @@ -161,4 +165,12 @@ public class UmaResourceRepresentation { public Boolean getOwnerManagedAccess() { return ownerManagedAccess; } + + public Map> getAttributes() { + return attributes; + } + + public void setAttributes(Map> attributes) { + this.attributes = attributes; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ResourceManagementTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ResourceManagementTest.java index 41fcb66eb3..5b8384a9b8 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ResourceManagementTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ResourceManagementTest.java @@ -28,8 +28,12 @@ import org.keycloak.representations.idm.authorization.ScopeRepresentation; import javax.ws.rs.NotFoundException; import javax.ws.rs.core.Response; + +import java.util.Arrays; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.Assert.assertEquals; @@ -53,6 +57,17 @@ public class ResourceManagementTest extends AbstractAuthorizationTest { assertEquals("/test/*", newResource.getUri()); assertEquals("test-resource", newResource.getType()); assertEquals("icon-test-resource", newResource.getIconUri()); + + Map> attributes = newResource.getAttributes(); + + assertEquals(2, attributes.size()); + + assertTrue(attributes.containsKey("a")); + assertTrue(attributes.containsKey("b")); + assertTrue(attributes.get("a").containsAll(Arrays.asList("a1", "a2", "a3"))); + assertEquals(3, attributes.get("a").size()); + assertTrue(attributes.get("b").containsAll(Arrays.asList("b1"))); + assertEquals(1, attributes.get("b").size()); } @Test @@ -105,11 +120,28 @@ public class ResourceManagementTest extends AbstractAuthorizationTest { resource.setIconUri("changed"); resource.setUri("changed"); + Map> attributes = resource.getAttributes(); + + attributes.remove("a"); + attributes.put("c", Arrays.asList("c1", "c2")); + attributes.put("b", Arrays.asList("changed")); + resource = doUpdateResource(resource); assertEquals("changed", resource.getIconUri()); assertEquals("changed", resource.getType()); assertEquals("changed", resource.getUri()); + + attributes = resource.getAttributes(); + + assertEquals(2, attributes.size()); + + assertFalse(attributes.containsKey("a")); + assertTrue(attributes.containsKey("b")); + assertTrue(attributes.get("b").containsAll(Arrays.asList("changed"))); + assertEquals(1, attributes.get("b").size()); + assertTrue(attributes.get("c").containsAll(Arrays.asList("c1", "c2"))); + assertEquals(2, attributes.get("c").size()); } @Test(expected = NotFoundException.class) @@ -205,6 +237,13 @@ public class ResourceManagementTest extends AbstractAuthorizationTest { newResource.setIconUri(iconUri); newResource.setOwner(owner != null ? new ResourceOwnerRepresentation(owner) : null); + Map> attributes = new HashMap<>(); + + attributes.put("a", Arrays.asList("a1", "a2", "a3")); + attributes.put("b", Arrays.asList("b1")); + + newResource.setAttributes(attributes); + return doCreateResource(newResource); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ResourceManagementWithAuthzClientTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ResourceManagementWithAuthzClientTest.java index 536d122f83..32865ec30c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ResourceManagementWithAuthzClientTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/authorization/ResourceManagementWithAuthzClientTest.java @@ -83,6 +83,8 @@ public class ResourceManagementWithAuthzClientTest extends ResourceManagementTes return scope; }).collect(Collectors.toSet())); + resourceRepresentation.setAttributes(created.getAttributes()); + return resourceRepresentation; } @@ -108,6 +110,8 @@ public class ResourceManagementWithAuthzClientTest extends ResourceManagementTes return scope; }).collect(Collectors.toSet())); + resource.setAttributes(newResource.getAttributes()); + return resource; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/PolicyEvaluationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/PolicyEvaluationTest.java index 9ffad7b417..64431fec2d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/PolicyEvaluationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/PolicyEvaluationTest.java @@ -35,6 +35,7 @@ import org.keycloak.authorization.attribute.Attributes; import org.keycloak.authorization.common.DefaultEvaluationContext; import org.keycloak.authorization.identity.Identity; import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.model.Resource; import org.keycloak.authorization.model.ResourceServer; import org.keycloak.authorization.permission.ResourcePermission; import org.keycloak.authorization.policy.evaluation.DefaultEvaluation; @@ -556,9 +557,49 @@ public class PolicyEvaluationTest extends AbstractAuthzTest { Assert.assertEquals(Effect.PERMIT, evaluation.getEffect()); } - @NotNull + @Test + public void testCheckResourceAttributes() { + testingClient.server().run(PolicyEvaluationTest::testCheckResourceAttributes); + } + + public static void testCheckResourceAttributes(KeycloakSession session) { + session.getContext().setRealm(session.realms().getRealmByName("authz-test")); + AuthorizationProvider authorization = session.getProvider(AuthorizationProvider.class); + ClientModel clientModel = session.realms().getClientByClientId("resource-server-test", session.getContext().getRealm()); + StoreFactory storeFactory = authorization.getStoreFactory(); + ResourceServer resourceServer = storeFactory.getResourceServerStore().findById(clientModel.getId()); + JSPolicyRepresentation policyRepresentation = new JSPolicyRepresentation(); + + policyRepresentation.setName("testCheckResourceAttributes"); + StringBuilder builder = new StringBuilder(); + + builder.append("var permission = $evaluation.getPermission();"); + builder.append("var resource = permission.getResource();"); + builder.append("var attributes = resource.getAttributes();"); + builder.append("if (attributes.size() == 2 && attributes.containsKey('a1') && attributes.containsKey('a2') && attributes.get('a1').size() == 2 && attributes.get('a2').get(0).equals('3') && resource.getAttribute('a1').size() == 2 && resource.getSingleAttribute('a2').equals('3')) { $evaluation.grant(); }"); + + policyRepresentation.setCode(builder.toString()); + + Policy policy = storeFactory.getPolicyStore().create(policyRepresentation, resourceServer); + PolicyProvider provider = authorization.getProvider(policy.getType()); + Resource resource = storeFactory.getResourceStore().create("testCheckResourceAttributesResource", resourceServer, resourceServer.getId()); + + resource.setAttribute("a1", Arrays.asList("1", "2")); + resource.setAttribute("a2", Arrays.asList("3")); + + DefaultEvaluation evaluation = createEvaluation(session, authorization, resource, resourceServer, policy); + + provider.evaluate(evaluation); + + Assert.assertEquals(Effect.PERMIT, evaluation.getEffect()); + } + private static DefaultEvaluation createEvaluation(KeycloakSession session, AuthorizationProvider authorization, ResourceServer resourceServer, Policy policy) { - return new DefaultEvaluation(new ResourcePermission(null, null, resourceServer), new DefaultEvaluationContext(new Identity() { + return createEvaluation(session, authorization, null, resourceServer, policy); + } + + private static DefaultEvaluation createEvaluation(KeycloakSession session, AuthorizationProvider authorization, Resource resource, ResourceServer resourceServer, Policy policy) { + return new DefaultEvaluation(new ResourcePermission(resource, null, resourceServer), new DefaultEvaluationContext(new Identity() { @Override public String getId() { return null; @@ -568,11 +609,8 @@ public class PolicyEvaluationTest extends AbstractAuthzTest { public Attributes getAttributes() { return null; } - }, session), policy, policy, new Decision() { - @Override - public void onDecision(Evaluation evaluation) { + }, session), policy, policy, evaluation -> { - } }, authorization); } } diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 12c5e05357..690b88feff 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -1183,6 +1183,10 @@ authz-resource-owner.tooltip=The owner of this resource. authz-resource-type.tooltip=The type of this resource. It can be used to group different resource instances with the same type. authz-resource-uri.tooltip=An URI that can also be used to uniquely identify this resource. authz-resource-scopes.tooltip=The scopes associated with this resource. +authz-resource-attributes=Resource Attributes +authz-resource-attributes.tooltip=The attributes associated wth the resource. +authz-resource-user-managed-access-enabled=User-Managed Access Enabled +authz-resource-user-managed-access-enabled.tooltip=If enabled this access to this resource can be managed by the resource owner. # Authz Scope List authz-add-scope=Add Scope diff --git a/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js b/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js index 612fa97c0a..8636410f65 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/authz/authz-controller.js @@ -294,6 +294,7 @@ module.controller('ResourceServerResourceDetailCtrl', function($scope, $http, $r var resource = {}; resource.scopes = []; + resource.attributes = {}; $scope.resource = angular.copy(resource); @@ -328,6 +329,10 @@ module.controller('ResourceServerResourceDetailCtrl', function($scope, $http, $r data.scopes = []; } + if (!data.attributes) { + data.attributes = {}; + } + $scope.resource = angular.copy(data); $scope.changed = false; @@ -343,6 +348,15 @@ module.controller('ResourceServerResourceDetailCtrl', function($scope, $http, $r for (i = 0; i < $scope.resource.scopes.length; i++) { delete $scope.resource.scopes[i].text; } + for (var [key, value] of Object.entries($scope.resource.attributes)) { + var values = value.toString().split(','); + + $scope.resource.attributes[key] = []; + + for (j = 0; j < values.length; j++) { + $scope.resource.attributes[key].push(values[j]); + } + } $instance.checkNameAvailability(function () { ResourceServerResource.update({realm : realm.realm, client : $scope.client.id, rsrid : $scope.resource._id}, $scope.resource, function() { $route.reload(); @@ -383,6 +397,15 @@ module.controller('ResourceServerResourceDetailCtrl', function($scope, $http, $r } }); } + + $scope.addAttribute = function() { + $scope.resource.attributes[$scope.newAttribute.key] = $scope.newAttribute.value; + delete $scope.newAttribute; + } + + $scope.removeAttribute = function(key) { + delete $scope.resource.attributes[key]; + } }); var Scopes = { diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/authz/resource-server-resource-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/authz/resource-server-resource-detail.html index c68b0e27ac..2311f1b5c3 100644 --- a/themes/src/main/resources/theme/base/admin/resources/partials/authz/resource-server-resource-detail.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/authz/resource-server-resource-detail.html @@ -67,11 +67,38 @@ {{:: 'authz-icon-uri.tooltip' | translate}}

- +
- {{:: 'authz-permission-resource-apply-to-resource-type.tooltip' | translate}} + {{:: 'authz-resource-user-managed-access-enabled.tooltip' | translate}} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
{{:: 'key' | translate}}{{:: 'value' | translate}}{{:: 'actions' | translate}}
{{key}}{{:: 'delete' | translate}}
{{:: 'add' | translate}}
+
+ {{:: 'authz-resource-attributes.tooltip' | translate}}