GUAC-586: Separate authentication from authorization.

This commit is contained in:
Michael Jumper
2015-08-20 17:31:24 -07:00
parent 843682c329
commit 90ae5b0e17
8 changed files with 487 additions and 87 deletions

View File

@@ -0,0 +1,73 @@
/*
* Copyright (C) 2015 Glyptodon LLC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.glyptodon.guacamole.net.auth;
/**
* Basic implementation of an AuthenticatedUser which uses the username to
* determine equality. Username comparison is case-sensitive.
*
* @author Michael Jumper
*/
public abstract class AbstractAuthenticatedUser implements AuthenticatedUser {
/**
* The name of this user.
*/
private String username;
@Override
public String getIdentifier() {
return username;
}
@Override
public void setIdentifier(String username) {
this.username = username;
}
@Override
public int hashCode() {
if (username == null) return 0;
return username.hashCode();
}
@Override
public boolean equals(Object obj) {
// Not equal if null or not a User
if (obj == null) return false;
if (!(obj instanceof AbstractAuthenticatedUser)) return false;
// Get username
String objUsername = ((AbstractAuthenticatedUser) obj).username;
// If null, equal only if this username is null
if (objUsername == null) return username == null;
// Otherwise, equal only if strings are identical
return objUsername.equals(username);
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2015 Glyptodon LLC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.glyptodon.guacamole.net.auth;
/**
* A user of the Guacamole web application who has been authenticated by an
* AuthenticationProvider.
*
* @author Michael Jumper
*/
public interface AuthenticatedUser extends Identifiable {
/**
* Returns the AuthenticationProvider that authenticated this user.
*
* @return
* The AuthenticationProvider that authenticated this user.
*/
AuthenticationProvider getAuthenticationProvider();
/**
* Returns the credentials that the user provided when they successfully
* authenticated.
*
* @return
* The credentials provided by the user when they authenticated.
*/
Credentials getCredentials();
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2013 Glyptodon LLC
* Copyright (C) 2015 Glyptodon LLC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -25,46 +25,112 @@ package org.glyptodon.guacamole.net.auth;
import org.glyptodon.guacamole.GuacamoleException;
/**
* Provides means of accessing and managing the available
* GuacamoleConfiguration objects and User objects. Access to each configuration
* and each user is limited by a given Credentials object.
* Provides means of authorizing users and for accessing and managing data
* associated with those users. Access to such data is limited according to the
* AuthenticationProvider implementation.
*
* @author Michael Jumper
*/
public interface AuthenticationProvider {
/**
* Returns the UserContext of the user authorized by the given credentials.
* Returns an AuthenticatedUser representing the user authenticated by the
* given credentials, if any.
*
* @param credentials The credentials to use to retrieve the environment.
* @return The UserContext of the user authorized by the given credentials,
* or null if the credentials are not authorized.
* @param credentials
* The credentials to use for authentication.
*
* @throws GuacamoleException If an error occurs while creating the
* UserContext.
* @return
* An AuthenticatedUser representing the user authenticated by the
* given credentials, if any, or null if the credentials are invalid.
*
* @throws GuacamoleException
* If an error occurs while authenticating the user, or if access is
* temporarily, permanently, or conditionally denied, such as if the
* supplied credentials are insufficient or invalid.
*/
UserContext getUserContext(Credentials credentials)
AuthenticatedUser authenticateUser(Credentials credentials)
throws GuacamoleException;
/**
* Returns a new or updated UserContext for the user authorized by the
* give credentials and having the given existing UserContext. Note that
* because this function will be called for all future requests after
* initial authentication, including tunnel requests, care must be taken
* to avoid using functions of HttpServletRequest which invalidate the
* entire request body, such as getParameter().
* Returns a new or updated AuthenticatedUser for the given credentials
* already having produced the given AuthenticatedUser. Note that because
* this function will be called for all future requests after initial
* authentication, including tunnel requests, care must be taken to avoid
* using functions of HttpServletRequest which invalidate the entire request
* body, such as getParameter(). Doing otherwise may cause the
* GuacamoleHTTPTunnelServlet to fail.
*
* @param context The existing UserContext belonging to the user in
* question.
* @param credentials The credentials to use to retrieve or update the
* environment.
* @return The updated UserContext, which need not be the same as the
* UserContext given, or null if the user is no longer authorized.
* @param credentials
* The credentials to use for authentication.
*
* @throws GuacamoleException If an error occurs while updating the
* UserContext.
* @param authenticatedUser
* An AuthenticatedUser object representing the user authenticated by
* an arbitrary set of credentials. The AuthenticatedUser may come from
* this AuthenticationProvider or any other installed
* AuthenticationProvider.
*
* @return
* An updated AuthenticatedUser representing the user authenticated by
* the given credentials, if any, or null if the credentials are
* invalid.
*
* @throws GuacamoleException
* If an error occurs while updating the AuthenticatedUser.
*/
UserContext updateUserContext(UserContext context, Credentials credentials)
AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
Credentials credentials) throws GuacamoleException;
/**
* Returns the UserContext of the user authenticated by the given
* credentials.
*
* @param authenticatedUser
* An AuthenticatedUser object representing the user authenticated by
* an arbitrary set of credentials. The AuthenticatedUser may come from
* this AuthenticationProvider or any other installed
* AuthenticationProvider.
*
* @return
* A UserContext describing the permissions, connection, connection
* groups, etc. accessible or associated with the given authenticated
* user, or null if this AuthenticationProvider refuses to provide any
* such data.
*
* @throws GuacamoleException
* If an error occurs while creating the UserContext.
*/
UserContext getUserContext(AuthenticatedUser authenticatedUser)
throws GuacamoleException;
/**
* Returns a new or updated UserContext for the given AuthenticatedUser
* already having the given UserContext. Note that because this function
* will be called for all future requests after initial authentication,
* including tunnel requests, care must be taken to avoid using functions
* of HttpServletRequest which invalidate the entire request body, such as
* getParameter(). Doing otherwise may cause the GuacamoleHTTPTunnelServlet
* to fail.
*
* @param context
* The existing UserContext belonging to the user in question.
*
* @param authenticatedUser
* An AuthenticatedUser object representing the user authenticated by
* an arbitrary set of credentials. The AuthenticatedUser may come from
* this AuthenticationProvider or any other installed
* AuthenticationProvider.
*
* @return
* An updated UserContext describing the permissions, connection,
* connection groups, etc. accessible or associated with the given
* authenticated user, or null if this AuthenticationProvider refuses
* to provide any such data.
*
* @throws GuacamoleException
* If an error occurs while updating the UserContext.
*/
UserContext updateUserContext(UserContext context,
AuthenticatedUser authenticatedUser) throws GuacamoleException;
}

View File

@@ -23,8 +23,11 @@
package org.glyptodon.guacamole.net.auth.simple;
import java.util.Map;
import java.util.UUID;
import org.glyptodon.guacamole.GuacamoleException;
import org.glyptodon.guacamole.net.auth.AbstractAuthenticatedUser;
import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
import org.glyptodon.guacamole.net.auth.Credentials;
import org.glyptodon.guacamole.net.auth.UserContext;
import org.glyptodon.guacamole.protocol.GuacamoleConfiguration;
@@ -62,12 +65,100 @@ public abstract class SimpleAuthenticationProvider
getAuthorizedConfigurations(Credentials credentials)
throws GuacamoleException;
@Override
public UserContext getUserContext(Credentials credentials)
throws GuacamoleException {
/**
* AuthenticatedUser which contains its own predefined set of authorized
* configurations.
*
* @author Michael Jumper
*/
private class SimpleAuthenticatedUser extends AbstractAuthenticatedUser {
// Get username, if any
/**
* The credentials provided when this AuthenticatedUser was
* authenticated.
*/
private final Credentials credentials;
/**
* The GuacamoleConfigurations that this AuthenticatedUser is
* authorized to use.
*/
private final Map<String, GuacamoleConfiguration> configs;
/**
* Creates a new SimpleAuthenticatedUser associated with the given
* credentials and having access to the given Map of
* GuacamoleConfigurations.
*
* @param credentials
* The credentials provided by the user when they authenticated.
*
* @param configs
* A Map of all GuacamoleConfigurations for which this user has
* access. The keys of this Map are Strings which uniquely identify
* each configuration.
*/
public SimpleAuthenticatedUser(Credentials credentials, Map<String, GuacamoleConfiguration> configs) {
// Store credentials and configurations
this.credentials = credentials;
this.configs = configs;
// Pull username from credentials if it exists
String username = credentials.getUsername();
if (username != null && !username.isEmpty())
setIdentifier(username);
// Otherwise generate a random username
else
setIdentifier(UUID.randomUUID().toString());
}
/**
* Returns a Map containing all GuacamoleConfigurations that this user
* is authorized to use. The keys of this Map are Strings which
* uniquely identify each configuration.
*
* @return
* A Map of all configurations for which this user is authorized.
*/
public Map<String, GuacamoleConfiguration> getAuthorizedConfigurations() {
return configs;
}
@Override
public AuthenticationProvider getAuthenticationProvider() {
return SimpleAuthenticationProvider.this;
}
@Override
public Credentials getCredentials() {
return credentials;
}
}
/**
* Given an arbitrary credentials object, returns a Map containing all
* configurations authorized by those credentials, filtering those
* configurations using a TokenFilter and the standard credential tokens
* (like ${GUAC_USERNAME} and ${GUAC_PASSWORD}). The keys of this Map
* are Strings which uniquely identify each configuration.
*
* @param credentials
* The credentials to use to retrieve authorized configurations.
*
* @return
* A Map of all configurations authorized by the given credentials, or
* null if the credentials given are not authorized.
*
* @throws GuacamoleException
* If an error occurs while retrieving configurations.
*/
private Map<String, GuacamoleConfiguration>
getFilteredAuthorizedConfigurations(Credentials credentials)
throws GuacamoleException {
// Get configurations
Map<String, GuacamoleConfiguration> configs =
@@ -85,19 +176,85 @@ public abstract class SimpleAuthenticationProvider
for (GuacamoleConfiguration config : configs.values())
tokenFilter.filterValues(config.getParameters());
// Return user context restricted to authorized configs
if (username != null && !username.isEmpty())
return new SimpleUserContext(username, configs);
return configs;
// If there is no associated username, let SimpleUserContext generate one
else
return new SimpleUserContext(configs);
}
/**
* Given a user who has already been authenticated, returns a Map
* containing all configurations for which that user is authorized,
* filtering those configurations using a TokenFilter and the standard
* credential tokens (like ${GUAC_USERNAME} and ${GUAC_PASSWORD}). The keys
* of this Map are Strings which uniquely identify each configuration.
*
* @param authenticatedUser
* The user whose authorized configurations are to be retrieved.
*
* @return
* A Map of all configurations authorized for use by the given user, or
* null if the user is not authorized to use any configurations.
*
* @throws GuacamoleException
* If an error occurs while retrieving configurations.
*/
private Map<String, GuacamoleConfiguration>
getFilteredAuthorizedConfigurations(AuthenticatedUser authenticatedUser)
throws GuacamoleException {
// Pull cached configurations, if any
if (authenticatedUser instanceof SimpleAuthenticatedUser)
return ((SimpleAuthenticatedUser) authenticatedUser).getAuthorizedConfigurations();
// Otherwise, pull using credentials
return getFilteredAuthorizedConfigurations(authenticatedUser.getCredentials());
}
@Override
public AuthenticatedUser authenticateUser(final Credentials credentials)
throws GuacamoleException {
// Get configurations
Map<String, GuacamoleConfiguration> configs =
getFilteredAuthorizedConfigurations(credentials);
// Return as unauthorized if not authorized to retrieve configs
if (configs == null)
return null;
return new SimpleAuthenticatedUser(credentials, configs);
}
@Override
public UserContext getUserContext(AuthenticatedUser authenticatedUser)
throws GuacamoleException {
// Get configurations
Map<String, GuacamoleConfiguration> configs =
getFilteredAuthorizedConfigurations(authenticatedUser);
// Return as unauthorized if not authorized to retrieve configs
if (configs == null)
return null;
// Return user context restricted to authorized configs
return new SimpleUserContext(authenticatedUser.getIdentifier(), configs);
}
@Override
public AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
Credentials credentials) throws GuacamoleException {
// Simply return the given user, updating nothing
return authenticatedUser;
}
@Override
public UserContext updateUserContext(UserContext context,
Credentials credentials) throws GuacamoleException {
AuthenticatedUser authorizedUser) throws GuacamoleException {
// Simply return the given context, updating nothing
return context;

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 Glyptodon LLC
* Copyright (C) 2015 Glyptodon LLC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -27,7 +27,7 @@ import java.util.concurrent.ConcurrentHashMap;
import org.glyptodon.guacamole.GuacamoleException;
import org.glyptodon.guacamole.environment.Environment;
import org.glyptodon.guacamole.net.GuacamoleTunnel;
import org.glyptodon.guacamole.net.auth.Credentials;
import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
import org.glyptodon.guacamole.net.auth.UserContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -46,9 +46,9 @@ public class GuacamoleSession {
private static final Logger logger = LoggerFactory.getLogger(GuacamoleSession.class);
/**
* The credentials provided when the user authenticated.
* The user associated with this session.
*/
private Credentials credentials;
private AuthenticatedUser authenticatedUser;
/**
* The user context associated with this session.
@@ -72,8 +72,8 @@ public class GuacamoleSession {
* The environment of the Guacamole server associated with this new
* session.
*
* @param credentials
* The credentials provided by the user during login.
* @param authenticatedUser
* The authenticated user to associate this session with.
*
* @param userContext
* The user context to associate this session with.
@@ -81,34 +81,33 @@ public class GuacamoleSession {
* @throws GuacamoleException
* If an error prevents the session from being created.
*/
public GuacamoleSession(Environment environment, Credentials credentials,
UserContext userContext) throws GuacamoleException {
public GuacamoleSession(Environment environment,
AuthenticatedUser authenticatedUser, UserContext userContext)
throws GuacamoleException {
this.lastAccessedTime = System.currentTimeMillis();
this.credentials = credentials;
this.authenticatedUser = authenticatedUser;
this.userContext = userContext;
}
/**
* Returns the credentials used when the user associated with this session
* authenticated.
* Returns the authenticated user associated with this session.
*
* @return
* The credentials used when the user associated with this session
* authenticated.
* The authenticated user associated with this session.
*/
public Credentials getCredentials() {
return credentials;
public AuthenticatedUser getAuthenticatedUser() {
return authenticatedUser;
}
/**
* Replaces the credentials associated with this session with the given
* credentials.
* Replaces the authenticated user associated with this session with the
* given authenticated user.
*
* @param credentials
* The credentials to associate with this session.
* @param authenticatedUser
* The authenticated user to associated with this session.
*/
public void setCredentials(Credentials credentials) {
this.credentials = credentials;
public void setAuthenticatedUser(AuthenticatedUser authenticatedUser) {
this.authenticatedUser = authenticatedUser;
}
/**

View File

@@ -24,6 +24,7 @@ package org.glyptodon.guacamole.net.basic.extension;
import java.lang.reflect.InvocationTargetException;
import org.glyptodon.guacamole.GuacamoleException;
import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
import org.glyptodon.guacamole.net.auth.Credentials;
import org.glyptodon.guacamole.net.auth.UserContext;
@@ -118,7 +119,7 @@ public class AuthenticationProviderFacade implements AuthenticationProvider {
}
@Override
public UserContext getUserContext(Credentials credentials)
public AuthenticatedUser authenticateUser(Credentials credentials)
throws GuacamoleException {
// Ignore auth attempts if no auth provider could be loaded
@@ -128,13 +129,13 @@ public class AuthenticationProviderFacade implements AuthenticationProvider {
}
// Delegate to underlying auth provider
return authProvider.getUserContext(credentials);
return authProvider.authenticateUser(credentials);
}
@Override
public UserContext updateUserContext(UserContext context, Credentials credentials)
throws GuacamoleException {
public AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
Credentials credentials) throws GuacamoleException {
// Ignore auth attempts if no auth provider could be loaded
if (authProvider == null) {
@@ -143,7 +144,38 @@ public class AuthenticationProviderFacade implements AuthenticationProvider {
}
// Delegate to underlying auth provider
return authProvider.updateUserContext(context, credentials);
return authProvider.updateAuthenticatedUser(authenticatedUser, credentials);
}
@Override
public UserContext getUserContext(AuthenticatedUser authenticatedUser)
throws GuacamoleException {
// Ignore auth attempts if no auth provider could be loaded
if (authProvider == null) {
logger.warn("User data retrieval attempt denied because the authentication system could not be loaded. Please check for errors earlier in the logs.");
return null;
}
// Delegate to underlying auth provider
return authProvider.getUserContext(authenticatedUser);
}
@Override
public UserContext updateUserContext(UserContext context,
AuthenticatedUser authenticatedUser)
throws GuacamoleException {
// Ignore auth attempts if no auth provider could be loaded
if (authProvider == null) {
logger.warn("User data refresh attempt denied because the authentication system could not be loaded. Please check for errors earlier in the logs.");
return null;
}
// Delegate to underlying auth provider
return authProvider.updateUserContext(context, authenticatedUser);
}

View File

@@ -38,6 +38,7 @@ import javax.ws.rs.core.MultivaluedMap;
import javax.xml.bind.DatatypeConverter;
import org.glyptodon.guacamole.GuacamoleException;
import org.glyptodon.guacamole.environment.Environment;
import org.glyptodon.guacamole.net.auth.AuthenticatedUser;
import org.glyptodon.guacamole.net.auth.AuthenticationProvider;
import org.glyptodon.guacamole.net.auth.Credentials;
import org.glyptodon.guacamole.net.auth.UserContext;
@@ -223,27 +224,27 @@ public class TokenRESTService {
credentials.setRequest(request);
credentials.setSession(request.getSession(true));
UserContext userContext;
AuthenticatedUser authenticatedUser;
try {
// Update existing user context if session already exists
// Re-authenticate user if session exists
if (existingSession != null)
userContext = authProvider.updateUserContext(existingSession.getUserContext(), credentials);
authenticatedUser = authProvider.updateAuthenticatedUser(existingSession.getAuthenticatedUser(), credentials);
/// Otherwise, generate a new user context
/// Otherwise, authenticate as a new user
else {
userContext = authProvider.getUserContext(credentials);
authenticatedUser = authProvider.authenticateUser(credentials);
// Log successful authentication
if (userContext != null && logger.isInfoEnabled())
if (authenticatedUser != null && logger.isInfoEnabled())
logger.info("User \"{}\" successfully authenticated from {}.",
userContext.self().getIdentifier(), getLoggableAddress(request));
authenticatedUser.getIdentifier(), getLoggableAddress(request));
}
// Request standard username/password if no user context was produced
if (userContext == null)
// Request standard username/password if no user was produced
if (authenticatedUser == null)
throw new GuacamoleInvalidCredentialsException("Permission Denied.",
CredentialsInfo.USERNAME_PASSWORD);
@@ -265,22 +266,34 @@ public class TokenRESTService {
throw e;
}
// Generate or update user context
UserContext userContext;
if (existingSession != null)
userContext = authProvider.updateUserContext(existingSession.getUserContext(), authenticatedUser);
else
userContext = authProvider.getUserContext(authenticatedUser);
// STUB: Request standard username/password if no user context was produced
if (userContext == null)
throw new GuacamoleInvalidCredentialsException("Permission Denied.",
CredentialsInfo.USERNAME_PASSWORD);
// Update existing session, if it exists
String authToken;
if (existingSession != null) {
authToken = token;
existingSession.setCredentials(credentials);
existingSession.setAuthenticatedUser(authenticatedUser);
existingSession.setUserContext(userContext);
}
// If no existing session, generate a new token/session pair
else {
authToken = authTokenGenerator.getToken();
tokenSessionMap.put(authToken, new GuacamoleSession(environment, credentials, userContext));
tokenSessionMap.put(authToken, new GuacamoleSession(environment, authenticatedUser, userContext));
}
logger.debug("Login was successful for user \"{}\".", userContext.self().getIdentifier());
return new APIAuthToken(authToken, userContext.self().getIdentifier());
logger.debug("Login was successful for user \"{}\".", authenticatedUser.getIdentifier());
return new APIAuthToken(authToken, authenticatedUser.getIdentifier());
}

View File

@@ -46,6 +46,7 @@ import org.glyptodon.guacamole.net.auth.Credentials;
import org.glyptodon.guacamole.net.auth.Directory;
import org.glyptodon.guacamole.net.auth.User;
import org.glyptodon.guacamole.net.auth.UserContext;
import org.glyptodon.guacamole.net.auth.credentials.GuacamoleCredentialsException;
import org.glyptodon.guacamole.net.auth.permission.ObjectPermission;
import org.glyptodon.guacamole.net.auth.permission.ObjectPermissionSet;
import org.glyptodon.guacamole.net.auth.permission.Permission;
@@ -338,7 +339,15 @@ public class UserRESTService {
credentials.setSession(request.getSession(true));
// Verify that the old password was correct
if (authProvider.getUserContext(credentials) == null) {
try {
if (authProvider.authenticateUser(credentials) == null) {
throw new APIException(APIError.Type.PERMISSION_DENIED,
"Permission denied.");
}
}
// Pass through any credentials exceptions as simple permission denied
catch (GuacamoleCredentialsException e) {
throw new APIException(APIError.Type.PERMISSION_DENIED,
"Permission denied.");
}