/**
* 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.apache.aurora.scheduler.http.api.security;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import javax.inject.Inject;
import javax.inject.Provider;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.base.Optional;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.AbstractSequentialIterator;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.apache.aurora.common.stats.StatsProvider;
import org.apache.aurora.gen.InstanceKey;
import org.apache.aurora.gen.JobConfiguration;
import org.apache.aurora.gen.JobKey;
import org.apache.aurora.gen.JobUpdateKey;
import org.apache.aurora.gen.JobUpdateRequest;
import org.apache.aurora.gen.Lock;
import org.apache.aurora.gen.LockKey;
import org.apache.aurora.gen.Response;
import org.apache.aurora.gen.ResponseCode;
import org.apache.aurora.gen.TaskConfig;
import org.apache.aurora.scheduler.base.JobKeys;
import org.apache.aurora.scheduler.http.api.security.FieldGetter.IdentityFieldGetter;
import org.apache.aurora.scheduler.spi.Permissions;
import org.apache.aurora.scheduler.storage.entities.IJobKey;
import org.apache.aurora.scheduler.thrift.Responses;
import org.apache.shiro.authz.Permission;
import org.apache.shiro.subject.Subject;
import static java.util.Objects.requireNonNull;
import static com.google.common.base.Preconditions.checkState;
/**
* Interceptor that extracts and validates job keys from parameters annotated with
* {@link org.apache.aurora.scheduler.http.api.security.AuthorizingParam} and performs permission
* checks scoped to it.
*
* <p>
* For example, if intercepting a class that implements {@code A}:
*
* <pre>
* public interface A {
* Response setInstances(@AuthorizingParam JobKey jobKey, int instances);
* }
* </pre>
*
* This interceptor will check that the current {@link org.apache.shiro.subject.Subject} has the
* permission (prefix + ":setInstances:role:env:name").
*
* <p>
* It is important that this interceptor only be applied to methods returning
* {@link org.apache.aurora.gen.Response} and that authentication is called before this interceptor
* is invoked, otherwise this interceptor will not allow the invocation to proceed.
*/
class ShiroAuthorizingParamInterceptor implements MethodInterceptor {
private static class JobKeyGetter {
private final int index;
private final Function<Object, Optional<JobKey>> func;
JobKeyGetter(int index, Function<Object, Optional<JobKey>> func) {
this.index = index;
this.func = func;
}
}
private static final FieldGetter<JobUpdateRequest, TaskConfig> UPDATE_REQUEST_GETTER =
new ThriftFieldGetter<>(
JobUpdateRequest.class,
JobUpdateRequest._Fields.TASK_CONFIG,
TaskConfig.class);
private static final FieldGetter<TaskConfig, JobKey> TASK_CONFIG_GETTER =
new ThriftFieldGetter<>(TaskConfig.class, TaskConfig._Fields.JOB, JobKey.class);
private static final FieldGetter<JobConfiguration, JobKey> JOB_CONFIGURATION_GETTER =
new ThriftFieldGetter<>(JobConfiguration.class, JobConfiguration._Fields.KEY, JobKey.class);
private static final FieldGetter<Lock, LockKey> LOCK_GETTER =
new ThriftFieldGetter<>(Lock.class, Lock._Fields.KEY, LockKey.class);
private static final FieldGetter<LockKey, JobKey> LOCK_KEY_GETTER =
new ThriftFieldGetter<>(LockKey.class, LockKey._Fields.JOB, JobKey.class);
private static final FieldGetter<JobUpdateKey, JobKey> JOB_UPDATE_KEY_GETTER =
new ThriftFieldGetter<>(JobUpdateKey.class, JobUpdateKey._Fields.JOB, JobKey.class);
private static final FieldGetter<InstanceKey, JobKey> INSTANCE_KEY_GETTER =
new ThriftFieldGetter<>(InstanceKey.class, InstanceKey._Fields.JOB_KEY, JobKey.class);
@SuppressWarnings("unchecked")
private static final Set<FieldGetter<?, JobKey>> FIELD_GETTERS =
ImmutableSet.of(
FieldGetters.compose(UPDATE_REQUEST_GETTER, TASK_CONFIG_GETTER),
TASK_CONFIG_GETTER,
JOB_CONFIGURATION_GETTER,
FieldGetters.compose(LOCK_GETTER, LOCK_KEY_GETTER),
LOCK_KEY_GETTER,
JOB_UPDATE_KEY_GETTER,
INSTANCE_KEY_GETTER,
new IdentityFieldGetter<>(JobKey.class));
private static final Map<Class<?>, Function<?, Optional<JobKey>>> FIELD_GETTERS_BY_TYPE =
ImmutableMap.<Class<?>, Function<?, Optional<JobKey>>>builder()
.putAll(Maps.uniqueIndex(FIELD_GETTERS, FieldGetter::getStructClass))
.build();
@VisibleForTesting
static final String SHIRO_AUTHORIZATION_FAILURES = "shiro_authorization_failures";
@VisibleForTesting
static final String SHIRO_BAD_REQUESTS = "shiro_bad_requests";
/**
* Return each method in the inheritance hierarchy of method in the order described by
* {@link AuthorizingParam}.
*
* @see org.apache.aurora.scheduler.http.api.security.AuthorizingParam
*/
private static Iterable<Method> getCandidateMethods(final Method method) {
return () -> new AbstractSequentialIterator<Method>(method) {
@Override
protected Method computeNext(Method previous) {
String name = previous.getName();
Class<?>[] parameterTypes = previous.getParameterTypes();
Class<?> declaringClass = previous.getDeclaringClass();
if (declaringClass.isInterface()) {
return null;
}
Iterable<Class<?>> searchOrder = ImmutableList.<Class<?>>builder()
.addAll(Optional.fromNullable(declaringClass.getSuperclass()).asSet())
.addAll(ImmutableList.copyOf(declaringClass.getInterfaces()))
.build();
for (Class<?> klazz : searchOrder) {
try {
return klazz.getMethod(name, parameterTypes);
} catch (NoSuchMethodException ignored) {
// Expected.
}
}
return null;
}
};
}
private static Iterable<JobKeyGetter> annotatedParameterGetters(Method method) {
for (Method candidateMethod : getCandidateMethods(method)) {
Parameter[] parameters = candidateMethod.getParameters();
ImmutableList.Builder<JobKeyGetter> jobKeyGetters = ImmutableList.builder();
for (int i = 0; i < parameters.length; i++) {
Parameter param = parameters[i];
if (param.isAnnotationPresent(AuthorizingParam.class)) {
Class<?> parameterType = param.getType();
@SuppressWarnings("unchecked")
Optional<Function<Object, Optional<JobKey>>> jobKeyGetter =
Optional.fromNullable(
(Function<Object, Optional<JobKey>>) FIELD_GETTERS_BY_TYPE.get(parameterType));
if (!jobKeyGetter.isPresent()) {
throw new UnsupportedOperationException(
"No "
+ JobKey.class.getName()
+ " field getter was supplied for "
+ parameterType.getName());
}
jobKeyGetters.add(new JobKeyGetter(i, jobKeyGetter.get()));
}
}
ImmutableList<JobKeyGetter> getters = jobKeyGetters.build();
if (!Iterables.isEmpty(getters)) {
return getters;
}
}
throw new UnsupportedOperationException(
"No parameter annotated with "
+ AuthorizingParam.class.getName()
+ " found on method "
+ method.getName()
+ " of "
+ method.getDeclaringClass().getName()
+ " or any of its superclasses.");
}
private static final CacheLoader<Method, Function<Object[], Optional<JobKey>>> LOADER =
new CacheLoader<Method, Function<Object[], Optional<JobKey>>>() {
@Override
public Function<Object[], Optional<JobKey>> load(Method method) {
if (!Response.class.isAssignableFrom(method.getReturnType())) {
throw new UnsupportedOperationException(
"Method "
+ method.getName()
+ " of class "
+ method.getDeclaringClass().getName()
+ " does not return "
+ Response.class.getName());
}
Iterable<JobKeyGetter> getters = annotatedParameterGetters(method);
return arguments -> {
Iterable<JobKeyGetter> nonNullArgGetters =
Iterables.filter(getters, getter -> arguments[getter.index] != null);
if (Iterables.isEmpty(nonNullArgGetters)) {
return Optional.absent();
} else {
if (Iterables.size(nonNullArgGetters) > 1) {
throw new IllegalStateException(
"Too many non-null arguments annotated with "
+ AuthorizingParam.class.getName()
+ " passed to "
+ method.getName()
+ " of "
+ method.getDeclaringClass().getName());
}
JobKeyGetter getter = Iterables.getOnlyElement(nonNullArgGetters);
return getter.func.apply(arguments[getter.index]);
}
};
}
};
private final LoadingCache<Method, Function<Object[], Optional<JobKey>>> authorizingParamGetters =
CacheBuilder.newBuilder().build(LOADER);
private volatile boolean initialized;
private Provider<Subject> subjectProvider;
private AtomicLong authorizationFailures;
private AtomicLong badRequests;
@Inject
void initialize(Provider<Subject> newSubjectProvider, StatsProvider statsProvider) {
checkState(!initialized);
this.subjectProvider = requireNonNull(newSubjectProvider);
authorizationFailures = statsProvider.makeCounter(SHIRO_AUTHORIZATION_FAILURES);
badRequests = statsProvider.makeCounter(SHIRO_BAD_REQUESTS);
initialized = true;
}
@VisibleForTesting
Permission makeTargetPermission(String methodName, IJobKey jobKey) {
return Permissions.createJobScopedPermission(methodName, jobKey);
}
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
checkState(initialized);
Method method = invocation.getMethod();
Subject subject = subjectProvider.get();
Optional<IJobKey> jobKey = authorizingParamGetters
.getUnchecked(invocation.getMethod())
.apply(invocation.getArguments())
.transform(IJobKey::build);
if (jobKey.isPresent() && JobKeys.isValid(jobKey.get())) {
Permission targetPermission = makeTargetPermission(method.getName(), jobKey.get());
if (subject.isPermitted(targetPermission)) {
return invocation.proceed();
} else {
authorizationFailures.incrementAndGet();
return Responses.addMessage(
Responses.empty(),
ResponseCode.AUTH_FAILED,
"Subject " + subject.getPrincipal() + " is not permitted to " + targetPermission + ".");
}
} else {
badRequests.incrementAndGet();
return Responses.addMessage(
Responses.empty(),
ResponseCode.INVALID_REQUEST,
"Missing or invalid job key from request.");
}
}
@VisibleForTesting
LoadingCache<Method, Function<Object[], Optional<JobKey>>> getAuthorizingParamGetters() {
return authorizingParamGetters;
}
}