killbill-uncached

util: first pass at RBAC proper Signed-off-by: Pierre-Alexandre

8/13/2013 11:33:37 AM

Details

util/pom.xml 8(+8 -0)

diff --git a/util/pom.xml b/util/pom.xml
index b23d0db..7116c83 100644
--- a/util/pom.xml
+++ b/util/pom.xml
@@ -160,6 +160,14 @@
             <artifactId>commons-email</artifactId>
         </dependency>
         <dependency>
+            <groupId>org.apache.shiro</groupId>
+            <artifactId>shiro-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.shiro</groupId>
+            <artifactId>shiro-guice</artifactId>
+        </dependency>
+        <dependency>
             <groupId>org.jdbi</groupId>
             <artifactId>jdbi</artifactId>
         </dependency>
diff --git a/util/src/main/java/com/ning/billing/util/glue/SecurityModule.java b/util/src/main/java/com/ning/billing/util/glue/SecurityModule.java
new file mode 100644
index 0000000..ff0d279
--- /dev/null
+++ b/util/src/main/java/com/ning/billing/util/glue/SecurityModule.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning licenses this file to you 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 com.ning.billing.util.glue;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+
+import org.apache.shiro.aop.AnnotationMethodInterceptor;
+import org.apache.shiro.aop.AnnotationResolver;
+import org.apache.shiro.guice.aop.ShiroAopModule;
+
+import com.ning.billing.util.security.AnnotationHierarchicalResolver;
+import com.ning.billing.util.security.AopAllianceMethodInterceptorAdapter;
+import com.ning.billing.util.security.PermissionAnnotationMethodInterceptor;
+
+import com.google.inject.matcher.AbstractMatcher;
+import com.google.inject.matcher.Matchers;
+
+public class SecurityModule extends ShiroAopModule {
+
+    private final AnnotationHierarchicalResolver resolver = new AnnotationHierarchicalResolver();
+
+    @Override
+    protected AnnotationResolver createAnnotationResolver() {
+        return resolver;
+    }
+
+    @Override
+    protected void configureInterceptors(final AnnotationResolver resolver) {
+        super.configureInterceptors(resolver);
+
+        bindShiroInterceptorWithHierarchy(new PermissionAnnotationMethodInterceptor(resolver));
+    }
+
+    // Similar to bindShiroInterceptor but will look for annotations in the class hierarchy
+    protected final void bindShiroInterceptorWithHierarchy(final AnnotationMethodInterceptor methodInterceptor) {
+        bindInterceptor(Matchers.any(),
+                        new AbstractMatcher<Method>() {
+                            public boolean matches(final Method method) {
+                                final Class<? extends Annotation> annotation = methodInterceptor.getHandler().getAnnotationClass();
+                                return resolver.getAnnotationFromMethod(method, annotation) != null;
+                            }
+                        }, new AopAllianceMethodInterceptorAdapter(methodInterceptor));
+    }
+}
diff --git a/util/src/main/java/com/ning/billing/util/security/AnnotationHierarchicalResolver.java b/util/src/main/java/com/ning/billing/util/security/AnnotationHierarchicalResolver.java
new file mode 100644
index 0000000..0f7b563
--- /dev/null
+++ b/util/src/main/java/com/ning/billing/util/security/AnnotationHierarchicalResolver.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning licenses this file to you 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 com.ning.billing.util.security;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+import org.apache.shiro.aop.AnnotationResolver;
+import org.apache.shiro.aop.MethodInvocation;
+
+public class AnnotationHierarchicalResolver implements AnnotationResolver {
+
+    @Override
+    public Annotation getAnnotation(final MethodInvocation mi, final Class<? extends Annotation> clazz) {
+        return getAnnotationFromMethod(mi.getMethod(), clazz);
+    }
+
+    public Annotation getAnnotationFromMethod(final Method method, final Class<? extends Annotation> clazz) {
+        return findAnnotation(method, clazz);
+    }
+
+    // The following comes from spring-core (AnnotationUtils) to handle annotations on interfaces
+
+    /**
+     * Get a single {@link Annotation} of <code>annotationType</code> from the supplied {@link java.lang.reflect.Method},
+     * traversing its super methods if no annotation can be found on the given method itself.
+     * <p>Annotations on methods are not inherited by default, so we need to handle this explicitly.
+     *
+     * @param method         the method to look for annotations on
+     * @param annotationType the annotation class to look for
+     * @return the annotation found, or <code>null</code> if none found
+     */
+    public static <A extends Annotation> A findAnnotation(final Method method, final Class<A> annotationType) {
+        A annotation = getAnnotation(method, annotationType);
+        Class<?> cl = method.getDeclaringClass();
+        if (annotation == null) {
+            annotation = searchOnInterfaces(method, annotationType, cl.getInterfaces());
+        }
+        while (annotation == null) {
+            cl = cl.getSuperclass();
+            if (cl == null || cl == Object.class) {
+                break;
+            }
+            try {
+                final Method equivalentMethod = cl.getDeclaredMethod(method.getName(), method.getParameterTypes());
+                annotation = getAnnotation(equivalentMethod, annotationType);
+                if (annotation == null) {
+                    annotation = searchOnInterfaces(method, annotationType, cl.getInterfaces());
+                }
+            } catch (NoSuchMethodException ex) {
+                // We're done...
+            }
+        }
+        return annotation;
+    }
+
+    /**
+     * Get a single {@link Annotation} of <code>annotationType</code> from the supplied {@link Method}.
+     *
+     * @param method         the method to look for annotations on
+     * @param annotationType the annotation class to look for
+     * @return the annotations found
+     */
+    public static <A extends Annotation> A getAnnotation(final Method method, final Class<A> annotationType) {
+        A ann = method.getAnnotation(annotationType);
+        if (ann == null) {
+            for (final Annotation metaAnn : method.getAnnotations()) {
+                ann = metaAnn.annotationType().getAnnotation(annotationType);
+                if (ann != null) {
+                    break;
+                }
+            }
+        }
+        return ann;
+    }
+
+    private static <A extends Annotation> A searchOnInterfaces(final Method method, final Class<A> annotationType, final Class<?>[] ifcs) {
+        A annotation = null;
+        for (final Class<?> iface : ifcs) {
+            if (isInterfaceWithAnnotatedMethods(iface)) {
+                try {
+                    final Method equivalentMethod = iface.getMethod(method.getName(), method.getParameterTypes());
+                    annotation = getAnnotation(equivalentMethod, annotationType);
+                } catch (NoSuchMethodException ex) {
+                    // Skip this interface - it doesn't have the method...
+                }
+                if (annotation != null) {
+                    break;
+                }
+            }
+        }
+        return annotation;
+    }
+
+    private static final Map<Class<?>, Boolean> annotatedInterfaceCache = new WeakHashMap<Class<?>, Boolean>();
+
+    private static boolean isInterfaceWithAnnotatedMethods(final Class<?> iface) {
+        synchronized (annotatedInterfaceCache) {
+            final Boolean flag = annotatedInterfaceCache.get(iface);
+            if (flag != null) {
+                return flag;
+            }
+            boolean found = false;
+            for (final Method ifcMethod : iface.getMethods()) {
+                if (ifcMethod.getAnnotations().length > 0) {
+                    found = true;
+                    break;
+                }
+            }
+            annotatedInterfaceCache.put(iface, found);
+            return found;
+        }
+    }
+}
diff --git a/util/src/main/java/com/ning/billing/util/security/AopAllianceMethodInterceptorAdapter.java b/util/src/main/java/com/ning/billing/util/security/AopAllianceMethodInterceptorAdapter.java
new file mode 100644
index 0000000..a45005c
--- /dev/null
+++ b/util/src/main/java/com/ning/billing/util/security/AopAllianceMethodInterceptorAdapter.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning licenses this file to you 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 com.ning.billing.util.security;
+
+import org.aopalliance.intercept.MethodInterceptor;
+import org.aopalliance.intercept.MethodInvocation;
+
+// Taken from Shiro - the original class is private :(
+public class AopAllianceMethodInterceptorAdapter implements MethodInterceptor {
+
+    org.apache.shiro.aop.MethodInterceptor shiroInterceptor;
+
+    public AopAllianceMethodInterceptorAdapter(org.apache.shiro.aop.MethodInterceptor shiroInterceptor) {
+        this.shiroInterceptor = shiroInterceptor;
+    }
+
+    public Object invoke(final MethodInvocation invocation) throws Throwable {
+        return shiroInterceptor.invoke(new AopAllianceMethodInvocationAdapter(invocation));
+    }
+
+    @Override
+    public String toString() {
+        return "AopAlliance Adapter for " + shiroInterceptor.toString();
+    }
+}
\ No newline at end of file
diff --git a/util/src/main/java/com/ning/billing/util/security/AopAllianceMethodInvocationAdapter.java b/util/src/main/java/com/ning/billing/util/security/AopAllianceMethodInvocationAdapter.java
new file mode 100644
index 0000000..1b93956
--- /dev/null
+++ b/util/src/main/java/com/ning/billing/util/security/AopAllianceMethodInvocationAdapter.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning licenses this file to you 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 com.ning.billing.util.security;
+
+import java.lang.reflect.Method;
+
+import org.aopalliance.intercept.MethodInvocation;
+
+// Taken from Shiro - the original class is private :(
+public class AopAllianceMethodInvocationAdapter implements org.apache.shiro.aop.MethodInvocation {
+
+    private final MethodInvocation mi;
+
+    public AopAllianceMethodInvocationAdapter(final MethodInvocation mi) {
+        this.mi = mi;
+    }
+
+    public Method getMethod() {
+        return mi.getMethod();
+    }
+
+    public Object[] getArguments() {
+        return mi.getArguments();
+    }
+
+    public String toString() {
+        return "Method invocation [" + mi.getMethod() + "]";
+    }
+
+    public Object proceed() throws Throwable {
+        return mi.proceed();
+    }
+
+    public Object getThis() {
+        return mi.getThis();
+    }
+}
diff --git a/util/src/main/java/com/ning/billing/util/security/PermissionAnnotationHandler.java b/util/src/main/java/com/ning/billing/util/security/PermissionAnnotationHandler.java
new file mode 100644
index 0000000..b9b1c50
--- /dev/null
+++ b/util/src/main/java/com/ning/billing/util/security/PermissionAnnotationHandler.java
@@ -0,0 +1,68 @@
+package com.ning.billing.util.security;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import java.lang.annotation.Annotation;
+
+import org.apache.shiro.authz.AuthorizationException;
+import org.apache.shiro.authz.aop.AuthorizingAnnotationHandler;
+import org.apache.shiro.subject.Subject;
+
+import com.ning.billing.security.Logical;
+import com.ning.billing.security.RequiresPermissions;
+
+public class PermissionAnnotationHandler extends AuthorizingAnnotationHandler {
+
+    public PermissionAnnotationHandler() {
+        super(RequiresPermissions.class);
+    }
+
+    public void assertAuthorized(final Annotation annotation) throws AuthorizationException {
+        if (!(annotation instanceof RequiresPermissions)) {
+            return;
+        }
+
+        final RequiresPermissions requiresPermissions = (RequiresPermissions) annotation;
+        final String[] permissions = new String[requiresPermissions.value().length];
+        for (int i = 0; i < permissions.length; i++) {
+            permissions[i] = requiresPermissions.value()[i].toString();
+        }
+
+        final Subject subject = getSubject();
+        if (permissions.length == 1) {
+            subject.checkPermission(permissions[0]);
+        } else if (Logical.AND.equals(requiresPermissions.logical())) {
+            subject.checkPermissions(permissions);
+        } else if (Logical.OR.equals(requiresPermissions.logical())) {
+            boolean hasAtLeastOnePermission = false;
+            for (final String permission : permissions) {
+                if (subject.isPermitted(permission)) {
+                    hasAtLeastOnePermission = true;
+                    break;
+                }
+            }
+
+            // Cause the exception if none match
+            if (!hasAtLeastOnePermission) {
+                getSubject().checkPermission(permissions[0]);
+            }
+        }
+    }
+}
diff --git a/util/src/main/java/com/ning/billing/util/security/PermissionAnnotationMethodInterceptor.java b/util/src/main/java/com/ning/billing/util/security/PermissionAnnotationMethodInterceptor.java
new file mode 100644
index 0000000..cc3c837
--- /dev/null
+++ b/util/src/main/java/com/ning/billing/util/security/PermissionAnnotationMethodInterceptor.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning licenses this file to you 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 com.ning.billing.util.security;
+
+import org.apache.shiro.aop.AnnotationResolver;
+import org.apache.shiro.authz.aop.AuthorizingAnnotationMethodInterceptor;
+
+public class PermissionAnnotationMethodInterceptor extends AuthorizingAnnotationMethodInterceptor {
+
+    public PermissionAnnotationMethodInterceptor(final AnnotationResolver resolver) {
+        super(new PermissionAnnotationHandler(), resolver);
+    }
+}
\ No newline at end of file
diff --git a/util/src/test/java/com/ning/billing/util/security/TestPermissionAnnotationMethodInterceptor.java b/util/src/test/java/com/ning/billing/util/security/TestPermissionAnnotationMethodInterceptor.java
new file mode 100644
index 0000000..8978060
--- /dev/null
+++ b/util/src/test/java/com/ning/billing/util/security/TestPermissionAnnotationMethodInterceptor.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2010-2013 Ning, Inc.
+ *
+ * Ning licenses this file to you 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 com.ning.billing.util.security;
+
+import javax.inject.Singleton;
+
+import org.apache.shiro.SecurityUtils;
+import org.apache.shiro.authc.AuthenticationToken;
+import org.apache.shiro.authc.UsernamePasswordToken;
+import org.apache.shiro.authz.AuthorizationException;
+import org.apache.shiro.authz.UnauthenticatedException;
+import org.apache.shiro.config.Ini;
+import org.apache.shiro.config.IniSecurityManagerFactory;
+import org.apache.shiro.mgt.SecurityManager;
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.util.Factory;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.ning.billing.security.Permission;
+import com.ning.billing.security.RequiresPermissions;
+import com.ning.billing.util.UtilTestSuiteNoDB;
+import com.ning.billing.util.glue.SecurityModule;
+
+import com.google.inject.Binder;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Module;
+import com.google.inject.Stage;
+
+public class TestPermissionAnnotationMethodInterceptor extends UtilTestSuiteNoDB {
+
+    public static interface IAopTester {
+
+        @RequiresPermissions(Permission.PAYMENT_CAN_REFUND)
+        public void createRefund();
+    }
+
+    public static class AopTesterImpl implements IAopTester {
+
+        @Override
+        public void createRefund() {}
+    }
+
+    @Singleton
+    public static class AopTester implements IAopTester {
+
+        @RequiresPermissions(Permission.PAYMENT_CAN_REFUND)
+        public void createRefund() {}
+    }
+
+    @Test(groups = "fast")
+    public void testAOPForClass() throws Exception {
+        // Make sure it works as expected without any AOP magic
+        final IAopTester simpleTester = new AopTester();
+        try {
+            simpleTester.createRefund();
+        } catch (Exception e) {
+            Assert.fail(e.getLocalizedMessage());
+        }
+
+        // Now, verify the interception works
+        configureShiro();
+        final Injector injector = Guice.createInjector(Stage.PRODUCTION, new SecurityModule());
+        final AopTester aopedTester = injector.getInstance(AopTester.class);
+        verifyAopedTester(aopedTester);
+    }
+
+    @Test(groups = "fast")
+    public void testAOPForInterface() throws Exception {
+        // Make sure it works as expected without any AOP magic
+        final IAopTester simpleTester = new AopTesterImpl();
+        try {
+            simpleTester.createRefund();
+        } catch (Exception e) {
+            Assert.fail(e.getLocalizedMessage());
+        }
+
+        // Now, verify the interception works
+        configureShiro();
+        final Injector injector = Guice.createInjector(Stage.PRODUCTION,
+                                                       new SecurityModule(),
+                                                       new Module() {
+                                                           @Override
+                                                           public void configure(final Binder binder) {
+                                                               binder.bind(IAopTester.class).to(AopTesterImpl.class).asEagerSingleton();
+                                                           }
+                                                       });
+        final IAopTester aopedTester = injector.getInstance(IAopTester.class);
+        verifyAopedTester(aopedTester);
+    }
+
+    private void verifyAopedTester(final IAopTester aopedTester) {
+        // Anonymous user
+        logout();
+        try {
+            aopedTester.createRefund();
+            Assert.fail();
+        } catch (UnauthenticatedException e) {
+            // Good!
+        } catch (Exception e) {
+            Assert.fail(e.getLocalizedMessage());
+        }
+
+        // pierre can credit, but not refund
+        login("pierre");
+        try {
+            aopedTester.createRefund();
+            Assert.fail();
+        } catch (AuthorizationException e) {
+            // Good!
+        } catch (Exception e) {
+            Assert.fail(e.getLocalizedMessage());
+        }
+
+        // stephane can refund
+        login("stephane");
+        aopedTester.createRefund();
+    }
+
+    private void login(final String username) {
+        logout();
+
+        final AuthenticationToken token = new UsernamePasswordToken(username, "password");
+        final Subject currentUser = SecurityUtils.getSubject();
+        currentUser.login(token);
+    }
+
+    private void logout() {
+        final Subject currentUser = SecurityUtils.getSubject();
+        if (currentUser.isAuthenticated()) {
+            currentUser.logout();
+        }
+    }
+
+    private void configureShiro() {
+        final Ini config = new Ini();
+        config.addSection("users");
+        config.getSection("users").put("pierre", "password, creditor");
+        config.getSection("users").put("stephane", "password, refunder");
+        config.addSection("roles");
+        config.getSection("roles").put("creditor", Permission.INVOICE_CAN_CREDIT.toString());
+        config.getSection("roles").put("refunder", Permission.PAYMENT_CAN_REFUND.toString());
+
+        final Factory<SecurityManager> factory = new IniSecurityManagerFactory(config);
+        final SecurityManager securityManager = factory.getInstance();
+        SecurityUtils.setSecurityManager(securityManager);
+    }
+}