UserManagedPermissionService.java

/*
 * Copyright 2022 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.protection.policy;

import java.io.IOException;
import java.util.Set;
import java.util.stream.Collectors;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;

import org.jboss.resteasy.annotations.cache.NoCache;
import org.keycloak.OAuthErrorException;
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.admin.PermissionService;
import org.keycloak.authorization.admin.PolicyTypeResourceService;
import org.keycloak.authorization.common.KeycloakIdentity;
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.store.ResourceStore;
import org.keycloak.representations.idm.authorization.UmaPermissionRepresentation;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.resources.admin.AdminEventBuilder;
import org.keycloak.util.JsonSerialization;

/**
 * @author <a href="mailto:federico@martel-innovate.com">Federico M. Facca</a>
 */
public class UserManagedPermissionService {

    private final ResourceServer resourceServer;
    private final Identity identity;
    private final AuthorizationProvider authorization;
    private final PermissionService delegate;

    public UserManagedPermissionService(KeycloakIdentity identity, ResourceServer resourceServer, AuthorizationProvider authorization, AdminEventBuilder eventBuilder) {
        this.identity = identity;
        this.resourceServer = resourceServer;
        this.authorization = authorization;
        delegate = new PermissionService(resourceServer, authorization, null, eventBuilder);
    }

    @POST
    @Path("{resourceId}")
    @Consumes("application/json")
    @Produces("application/json")
    public Response create(@PathParam("resourceId") String resourceId, UmaPermissionRepresentation representation) {
        if (representation.getId() != null) {
            throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Newly created uma policies should not have an id", Response.Status.BAD_REQUEST);
        }

        checkRequest(resourceId, representation);

        representation.addResource(resourceId);
        representation.setOwner(identity.getId());

        return findById(delegate.create(representation).getId());
    }

    @Path("{policyId}")
    @PUT
    @Consumes("application/json")
    @Produces("application/json")
    public Response update(@PathParam("policyId") String policyId, String payload) {
        UmaPermissionRepresentation representation;

        try {
            representation = JsonSerialization.readValue(payload, UmaPermissionRepresentation.class);
        } catch (IOException e) {
            throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Failed to parse representation", Status.BAD_REQUEST);
        }

        checkRequest(getAssociatedResourceId(policyId), representation);

        return PolicyTypeResourceService.class.cast(delegate.getResource(policyId)).update(payload);
    }

    @Path("{policyId}")
    @DELETE
    public Response delete(@PathParam("policyId") String policyId) {
        checkRequest(getAssociatedResourceId(policyId), null);
        PolicyTypeResourceService.class.cast(delegate.getResource(policyId)).delete();
        return Response.noContent().build();
    }

    @Path("{policyId}")
    @GET
    @Produces("application/json")
    public Response findById(@PathParam("policyId") String policyId) {
        checkRequest(getAssociatedResourceId(policyId), null);
        return PolicyTypeResourceService.class.cast(delegate.getResource(policyId)).findById(null);
    }

    @GET
    @NoCache
    @Produces("application/json")
    public Response find(@QueryParam("name") String name,
                         @QueryParam("resource") String resource,
                         @QueryParam("scope") String scope,
                         @QueryParam("first") Integer firstResult,
                         @QueryParam("max") Integer maxResult) {
        return  delegate.findAll(null, name, "uma", resource, scope, true, identity.getId(), null, firstResult, maxResult);
    }

    private Policy getPolicy(@PathParam("policyId") String policyId) {
        Policy existing = authorization.getStoreFactory().getPolicyStore().findById(resourceServer.getRealm(), resourceServer, policyId);

        if (existing == null) {
            throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Policy with [" + policyId + "] does not exist", Status.NOT_FOUND);
        }

        return existing;
    }

    private void checkRequest(String resourceId, UmaPermissionRepresentation representation) {
        ResourceStore resourceStore = this.authorization.getStoreFactory().getResourceStore();
        Resource resource = resourceStore.findById(resourceServer.getRealm(), resourceServer, resourceId);

        if (resource == null) {
            throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Resource [" + resourceId + "] cannot be found", Response.Status.BAD_REQUEST);
        }

        if (!resource.getOwner().equals(identity.getId())) {
            throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Only resource owner can access policies for resource [" + resourceId + "]", Status.BAD_REQUEST);
        }

        if (!resource.isOwnerManagedAccess()) {
            throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Only resources with owner managed accessed can have policies", Status.BAD_REQUEST);
        }

        if (!resourceServer.isAllowRemoteResourceManagement()) {
            throw new ErrorResponseException(OAuthErrorException.REQUEST_NOT_SUPPORTED, "Remote Resource Management not enabled on resource server [" + resourceServer.getId() + "]", Status.FORBIDDEN);
        }

        if (representation != null) {
            Set<String> resourceScopes = resource.getScopes().stream().map(scope -> scope.getName()).collect(Collectors.toSet());
            Set<String> scopes = representation.getScopes();

            if (scopes == null || scopes.isEmpty()) {
                scopes = resourceScopes;
                representation.setScopes(scopes);
            }

            if (!resourceScopes.containsAll(scopes)) {
                throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Some of the scopes [" + scopes + "] are not valid for resource [" + resourceId + "]", Response.Status.BAD_REQUEST);
            }
        }
    }

    private String getAssociatedResourceId(String policyId) {
        return getPolicy(policyId).getResources().iterator().next().getId();
    }
}