/*
* The MIT License
*
* Copyright (c) 2016, CloudBees, Inc.
*
* 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 jenkins.security;
import com.google.common.cache.Cache;
import com.google.common.util.concurrent.UncheckedExecutionException;
import hudson.Extension;
import hudson.ExtensionList;
import hudson.security.UserMayOrMayNotExistException;
import jenkins.model.Jenkins;
import jenkins.util.SystemProperties;
import org.acegisecurity.userdetails.UserDetails;
import org.acegisecurity.userdetails.UsernameNotFoundException;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.springframework.dao.DataAccessException;
import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import static com.google.common.cache.CacheBuilder.newBuilder;
/**
* Cache layer for {@link org.acegisecurity.userdetails.UserDetails} lookup.
*
* @since TODO
*/
@Extension
public final class UserDetailsCache {
private static final String SYS_PROP_NAME = UserDetailsCache.class.getName() + ".EXPIRE_AFTER_WRITE_SEC";
/**
* Nr of seconds before a value expires after being cached, note full GC will also clear the cache.
* Should be able to set this value in script and then reload from disk to change in runtime.
*/
private static /*not final*/ Integer EXPIRE_AFTER_WRITE_SEC = SystemProperties.getInteger(SYS_PROP_NAME, (int)TimeUnit.MINUTES.toSeconds(2));
private final Cache<String, UserDetails> detailsCache;
private final Cache<String, Boolean> existanceCache;
/**
* Constructor intended to be instantiated by Jenkins only.
*/
@Restricted(NoExternalUse.class)
public UserDetailsCache() {
if (EXPIRE_AFTER_WRITE_SEC == null || EXPIRE_AFTER_WRITE_SEC <= 0) {
//just in case someone is trying to trick us
EXPIRE_AFTER_WRITE_SEC = SystemProperties.getInteger(SYS_PROP_NAME, (int)TimeUnit.MINUTES.toSeconds(2));
if (EXPIRE_AFTER_WRITE_SEC <= 0) {
//The property could also be set to a negative value
EXPIRE_AFTER_WRITE_SEC = (int)TimeUnit.MINUTES.toSeconds(2);
}
}
detailsCache = newBuilder().softValues().expireAfterWrite(EXPIRE_AFTER_WRITE_SEC, TimeUnit.SECONDS).build();
existanceCache = newBuilder().softValues().expireAfterWrite(EXPIRE_AFTER_WRITE_SEC, TimeUnit.SECONDS).build();
}
/**
* The singleton instance registered in Jenkins.
* @return the cache
*/
public static UserDetailsCache get() {
return ExtensionList.lookup(UserDetailsCache.class).get(UserDetailsCache.class);
}
/**
* Gets the cached UserDetails for the given username.
* Similar to {@link #loadUserByUsername(String)} except it doesn't perform the actual lookup if there is a cache miss.
*
* @param idOrFullName the username
*
* @return {@code null} if the cache doesn't contain any data for the key or the user details cached for the key.
* @throws UsernameNotFoundException if a previous lookup resulted in the same
*/
@CheckForNull
public UserDetails getCached(String idOrFullName) throws UsernameNotFoundException {
Boolean exists = existanceCache.getIfPresent(idOrFullName);
if (exists != null && !exists) {
throw new UserMayOrMayNotExistException(String.format("\"%s\" does not exist", idOrFullName));
} else {
return detailsCache.getIfPresent(idOrFullName);
}
}
/**
* Locates the user based on the username, by first looking in the cache and then delegate to
* {@link hudson.security.SecurityRealm#loadUserByUsername(String)}.
*
* @param idOrFullName the username
* @return the details
*
* @throws UsernameNotFoundException (normally a {@link hudson.security.UserMayOrMayNotExistException})
* if the user could not be found or the user has no GrantedAuthority
* @throws DataAccessException if user could not be found for a repository-specific reason
* @throws ExecutionException if anything else went wrong in the cache lookup/retrieval
*/
@Nonnull
public UserDetails loadUserByUsername(String idOrFullName) throws UsernameNotFoundException, DataAccessException, ExecutionException {
Boolean exists = existanceCache.getIfPresent(idOrFullName);
if(exists != null && !exists) {
throw new UsernameNotFoundException(String.format("\"%s\" does not exist", idOrFullName));
} else {
try {
return detailsCache.get(idOrFullName, new Retriever(idOrFullName));
} catch (ExecutionException | UncheckedExecutionException e) {
if (e.getCause() instanceof UsernameNotFoundException) {
throw ((UsernameNotFoundException)e.getCause());
} else if (e.getCause() instanceof DataAccessException) {
throw ((DataAccessException)e.getCause());
} else {
throw e;
}
}
}
}
/**
* Discards all entries in the cache.
*/
public void invalidateAll() {
existanceCache.invalidateAll();
detailsCache.invalidateAll();
}
/**
* Discards any cached value for key.
* @param idOrFullName the key
*/
public void invalidate(final String idOrFullName) {
existanceCache.invalidate(idOrFullName);
detailsCache.invalidate(idOrFullName);
}
/**
* Callable that performs the actual lookup if there is a cache miss.
* @see #loadUserByUsername(String)
*/
private class Retriever implements Callable<UserDetails> {
private final String idOrFullName;
private Retriever(final String idOrFullName) {
this.idOrFullName = idOrFullName;
}
@Override
public UserDetails call() throws Exception {
try {
Jenkins jenkins = Jenkins.getInstance();
UserDetails userDetails = jenkins.getSecurityRealm().loadUserByUsername(idOrFullName);
if (userDetails == null) {
existanceCache.put(this.idOrFullName, Boolean.FALSE);
throw new NullPointerException("hudson.security.SecurityRealm should never return null. "
+ jenkins.getSecurityRealm() + " returned null for idOrFullName='" + idOrFullName + "'");
}
existanceCache.put(this.idOrFullName, Boolean.TRUE);
return userDetails;
} catch (UsernameNotFoundException e) {
existanceCache.put(this.idOrFullName, Boolean.FALSE);
throw e;
} catch (DataAccessException e) {
existanceCache.invalidate(this.idOrFullName);
throw e;
}
}
}
}