keycloak-uncached

Changes

Details

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 c2e8f8a..fa2622e 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<String, List<String>> attributes;
 
     /**
      * Creates a new instance.
@@ -204,4 +207,12 @@ public class ResourceRepresentation {
                 ", scopes=" + scopes +
                 '}';
     }
+
+    public void setAttributes(Map<String, List<String>> attributes) {
+        this.attributes = attributes;
+    }
+
+    public Map<String, List<String>> 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 ce823c9..071bc32 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;
 
 /**
  * <p>One or more resources that the resource server manages as a set of protected resources.
@@ -53,6 +56,10 @@ public class ResourceRepresentation {
     private List<PolicyRepresentation> policies;
 
     private String displayName;
+
+    @JsonDeserialize(using = StringListMapDeserializer.class)
+    private Map<String, List<String>> attributes;
+
     /**
      * Creates a new instance.
      *
@@ -195,6 +202,14 @@ public class ResourceRepresentation {
         }
     }
 
+    public Map<String, List<String>> getAttributes() {
+        return attributes;
+    }
+
+    public void setAttributes(Map<String, List<String>> 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/entities/CachedResource.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/authorization/entities/CachedResource.java
index 383ab1c..7e75410 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<String> scopesIds;
     private boolean ownerManagedAccess;
+    private MultivaluedHashMap<String, String> 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<String> getScopesIds() {
         return this.scopesIds;
     }
+
+    public Map<String, List<String>> getAttributes() {
+        return attributes;
+    }
 }
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 d310fca..4f59cdb 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<Resource> {
+
     protected CachedResource cached;
     protected StoreFactoryCacheSession cacheSession;
     protected Resource updated;
@@ -211,6 +213,50 @@ public class ResourceAdapter implements Resource, CachedModel<Resource> {
     }
 
     @Override
+    public Map<String, List<String>> getAttributes() {
+        if (updated != null) return updated.getAttributes();
+        return cached.getAttributes();
+    }
+
+    @Override
+    public String getSingleAttribute(String name) {
+        if (updated != null) return updated.getSingleAttribute(name);
+
+        List<String> values = cached.getAttributes().getOrDefault(name, Collections.emptyList());
+
+        if (values.isEmpty()) {
+            return null;
+        }
+
+        return values.get(0);
+    }
+
+    @Override
+    public List<String> getAttribute(String name) {
+        if (updated != null) return updated.getAttribute(name);
+
+        List<String> values = cached.getAttributes().getOrDefault(name, Collections.emptyList());
+
+        if (values.isEmpty()) {
+            return null;
+        }
+
+        return Collections.unmodifiableList(values);
+    }
+
+    @Override
+    public void setAttribute(String name, List<String> 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;
         if (o == null || !(o instanceof Resource)) return false;
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 0000000..19963dc
--- /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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
+ */
+@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 861853a..8c9960b 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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
  */
@@ -94,6 +102,10 @@ public class ResourceEntity {
     @JoinTable(name = "RESOURCE_POLICY", joinColumns = @JoinColumn(name = "RESOURCE_ID"), inverseJoinColumns = @JoinColumn(name = "POLICY_ID"))
     private List<PolicyEntity> policies = new LinkedList<>();
 
+    @OneToMany(cascade = CascadeType.REMOVE, orphanRemoval = true, mappedBy="resource")
+    @Fetch(FetchMode.SUBSELECT)
+    private Collection<ResourceAttributeEntity> attributes = new ArrayList<>();
+
     public String getId() {
         return id;
     }
@@ -179,6 +191,14 @@ public class ResourceEntity {
         this.policies = policies;
     }
 
+    public Collection<ResourceAttributeEntity> getAttributes() {
+        return attributes;
+    }
+
+    public void setAttributes(Collection<ResourceAttributeEntity> 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 782f084..02320ec 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<ResourceEntity> {
+
     private ResourceEntity entity;
     private EntityManager em;
     private StoreFactory storeFactory;
@@ -158,6 +166,72 @@ public class ResourceAdapter implements Resource, JpaModel<ResourceEntity> {
         }
     }
 
+    @Override
+    public Map<String, List<String>> getAttributes() {
+        MultivaluedHashMap<String, String> result = new MultivaluedHashMap<>();
+        for (ResourceAttributeEntity attr : entity.getAttributes()) {
+            result.add(attr.getName(), attr.getValue());
+        }
+        return result;
+    }
+
+    @Override
+    public String getSingleAttribute(String name) {
+        List<String> values = getAttributes().getOrDefault(name, Collections.emptyList());
+
+        if (values.isEmpty()) {
+            return null;
+        }
+
+        return values.get(0);
+    }
+
+    @Override
+    public List<String> getAttribute(String name) {
+        List<String> values = getAttributes().getOrDefault(name, Collections.emptyList());
+
+        if (values.isEmpty()) {
+            return null;
+        }
+
+        return Collections.unmodifiableList(values);
+    }
+
+    @Override
+    public void setAttribute(String name, List<String> 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<ResourceAttributeEntity> 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 1b72a34..6666052 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 @@
                 <constraints nullable="false"/>
             </column>
         </addColumn>
+
+        <createTable tableName="RESOURCE_ATTRIBUTE">
+            <column name="ID" type="VARCHAR(36)" defaultValue="sybase-needs-something-here">
+                <constraints nullable="false"/>
+            </column>
+            <column name="NAME" type="VARCHAR(255)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="VALUE" type="VARCHAR(255)"/>
+            <column name="RESOURCE_ID" type="VARCHAR(36)">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+
+        <addPrimaryKey columnNames="ID" constraintName="RES_ATTR_PK" tableName="RESOURCE_ATTRIBUTE"/>
+        <addForeignKeyConstraint baseColumnNames="RESOURCE_ID" baseTableName="RESOURCE_ATTRIBUTE" constraintName="FK_5HRM2VLF9QL5FU022KQEPOVBR" referencedColumnNames="ID" referencedTableName="RESOURCE_SERVER_RESOURCE"/>
     </changeSet>
 </databaseChangeLog>
diff --git a/model/jpa/src/main/resources/META-INF/persistence.xml b/model/jpa/src/main/resources/META-INF/persistence.xml
index 86eda78..805b7c8 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 @@
         <class>org.keycloak.authorization.jpa.entities.ScopeEntity</class>
         <class>org.keycloak.authorization.jpa.entities.PolicyEntity</class>
         <class>org.keycloak.authorization.jpa.entities.PermissionTicketEntity</class>
+        <class>org.keycloak.authorization.jpa.entities.ResourceAttributeEntity</class>
 
         <!-- User Federation Storage -->
         <class>org.keycloak.storage.jpa.entity.BrokerLinkEntity</class>
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 cdfc0b6..dd7f5d7 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<Scope> scopes);
+
+    /**
+     * Returns the attributes associated with this resource.
+     *
+     * @return a map holding the attributes associated with this resource
+     */
+    Map<String, List<String>> getAttributes();
+
+    /**
+     * Returns the first value of an attribute with the given <code>name</code>
+     *
+     * @return the first value of an attribute
+     */
+    String getSingleAttribute(String name);
+
+    /**
+     * Returns the values of an attribute with the given <code>name</code>
+     *
+     * @return the values of an attribute
+     */
+    List<String> getAttribute(String name);
+
+    /**
+     * Sets an attribute with the given <code>name</code> and <code>values</code>.
+     *
+     * @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<String> 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 22b4a3e..e793cb2 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 7324913..538dcef 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<String, List<String>> attributes = resource.getAttributes();
+
+            if (attributes != null) {
+                Set<String> 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<ScopeRepresentation, Scope>) scope -> toModel(scope, resourceServer, authorization)).collect(Collectors.toSet()));
         }
 
+        Map<String, List<String>> attributes = resource.getAttributes();
+
+        if (attributes != null) {
+            for (Entry<String, List<String>> 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 04c9d74..b69d991 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/representation/UmaResourceRepresentation.java b/services/src/main/java/org/keycloak/authorization/protection/resource/representation/UmaResourceRepresentation.java
index fbbe08e..1ac4608 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<String, List<String>> attributes;
+
 
     /**
      * Creates a new instance.
@@ -161,4 +165,12 @@ public class UmaResourceRepresentation {
     public Boolean getOwnerManagedAccess() {
         return ownerManagedAccess;
     }
+
+    public Map<String, List<String>> getAttributes() {
+        return attributes;
+    }
+
+    public void setAttributes(Map<String, List<String>> attributes) {
+        this.attributes = attributes;
+    }
 }
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 0cead7d..21fd27c 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/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 41fcb66..5b8384a 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<String, List<String>> 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<String, List<String>> 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<String, List<String>> 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 536d122..32865ec 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 9ffad7b..64431fe 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 12c5e05..690b88f 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 612fa97..8636410 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 c68b0e2..2311f1b 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 @@
                 <kc-tooltip>{{:: 'authz-icon-uri.tooltip' | translate}}</kc-tooltip>
             </div>
             <div class="form-group">
-                <label class="col-md-2 control-label" for="resource.ownerManagedAccess">User-Managed Access Enabled</label>
+                <label class="col-md-2 control-label" for="resource.ownerManagedAccess">{{:: 'authz-resource-user-managed-access-enabled' | translate}}</label>
                 <div class="col-md-6">
                     <input ng-model="resource.ownerManagedAccess" id="resource.ownerManagedAccess" onoffswitch />
                 </div>
-                <kc-tooltip>{{:: 'authz-permission-resource-apply-to-resource-type.tooltip' | translate}}</kc-tooltip>
+                <kc-tooltip>{{:: 'authz-resource-user-managed-access-enabled.tooltip' | translate}}</kc-tooltip>
+            </div>
+            <div class="form-group">
+                <label class="col-md-2 control-label">{{:: 'authz-resource-attributes' | translate}}</label>
+                <div class="col-md-6">
+                    <table class="table table-striped table-bordered">
+                        <thead>
+                        <tr>
+                            <th>{{:: 'key' | translate}}</th>
+                            <th>{{:: 'value' | translate}}</th>
+                            <th>{{:: 'actions' | translate}}</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        <tr ng-repeat="(key, value) in resource.attributes | toOrderedMapSortedByKey">
+                            <td>{{key}}</td>
+                            <td><input ng-model="resource.attributes[key]" class="form-control" type="text" name="{{key}}" id="attribute-{{key}}" /></td>
+                            <td class="kc-action-cell" id="removeAttribute" data-ng-click="removeAttribute(key)">{{:: 'delete' | translate}}</td>
+                        </tr>
+                        <tr>
+                            <td><input ng-model="newAttribute.key" class="form-control" type="text" id="newAttributeKey" /></td>
+                            <td><input ng-model="newAttribute.value" class="form-control" type="text" id="newAttributeValue" /></td>
+                            <td class="kc-action-cell" id="addAttribute" data-ng-click="addAttribute()" data-ng-disabled="!newAttribute.key.length || !newAttribute.value.length">{{:: 'add' | translate}}</td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
+                <kc-tooltip>{{:: 'authz-resource-attributes.tooltip' | translate}}</kc-tooltip>
             </div>
         </fieldset>