diff --git a/src/main/java/io/quarkus/security/PermissionsAllowed.java b/src/main/java/io/quarkus/security/PermissionsAllowed.java new file mode 100644 index 0000000..578f2bc --- /dev/null +++ b/src/main/java/io/quarkus/security/PermissionsAllowed.java @@ -0,0 +1,253 @@ +package io.quarkus.security; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.security.Permission; + +/** + * Indicates that a resource can only be accessed by a user with one of permissions specified through {@link #value()}. + * There are some situations where you want to require more than one permission, this can be achieved by repeating + * annotation. Please see an example below: + * + *
+ * @PermissionsAllowed("create")
+ * @PermissionsAllowed("update")
+ * public Resource createOrUpdate(Long id) {
+ *     // business logic
+ * }
+ * 
+ * + * To put it another way, permissions specified by one annotation instance are disjunctive and the permission check is + * only true if all annotation instances are evaluated as true. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +@Repeatable(PermissionsAllowed.List.class) +public @interface PermissionsAllowed { + + /** + * Constant value for {@link #params()} indicating that the constructor parameters of the {@link #permission()} + * should be autodetected. That is, each constructor argument data type must exactly match a data type of at least + * one argument of the secured method. + * + * For example consider following permission: + * + *
+     * public class UserPermission extends Permission {
+     *
+     *     private final User user;
+     *
+     *     public UserPermission(String name, User user) {
+     *         super(name);
+     *         this.user = user;
+     *     }
+     *
+     *     ...
+     * }
+     * 
+ * + * Constructor parameter {@code user} is in fact object passed to a secured method. + * In the example below, {@code user1} parameter of the 'getResource' method is passed to the constructor. + * + *
+     * @PermissionsAllowed(permission = UserPermission.class, value = "resource")
+     * public Resource getResource(User user1) {
+     *     // business logic
+     * }
+     * 
+ * + * Constructor parameters are always selected as the first secured method parameter with exactly matching data type. + * There is no limit to a reasonable number of parameters passed to the permission constructor this way. + * Please see {@link #params()} for more complex matching. + */ + String AUTODETECTED = "<>"; + + /** + * Colon is used to separate a {@link Permission#getName()} and an element of the {@link Permission#getActions()}. + * For example, {@link StringPermission} created for method 'getResource': + * + *
+     * @PermissionsAllowed("resource:retrieve")
+     * public Resource getResource() {
+     *     // business logic
+     * }
+     * 
+ * is equal to the {@code perm}: + *
+     * var perm = new StringPermission("resource", "retrieve");
+     * 
+ */ + String PERMISSION_TO_ACTION_SEPARATOR = ":"; + + /** + * Specifies a list of permissions that grants the access to the resource. It is also possible to define permission's + * actions that are permitted for the resource. Yet again, consider method 'getResource': + * + *
+     * @PermissionsAllowed({"resource:crud", "resource:retrieve", "system-resource:retrieve"})
+     * public Resource getResource() {
+     *     // business logic
+     * }
+     * 
+ * + * Two {@link StringPermission}s will be created: + * + *
+     * var pem1 = new StringPermission("resource", "crud", "retrieve");
+     * var pem2 = new StringPermission("system-resource", "retrieve");
+     * 
+ * + * And the permission check will pass if either {@code pem1} or {@code pem2} implies user permissions. + * Technically, it is also possible to both define actions and no action for same-named permission like this: + * + *
+     * @PermissionsAllowed({"resource:crud", "resource:retrieve", "natural-resource"})
+     * public Resource getResource() {
+     *     // business logic
+     * }
+     * 
+ * + * Quarkus will create two permissions: + * + *
+     * var pem1 = new StringPermission("resource", "crud", "retrieve");
+     * var pem2 = new StringPermission("natural-resource");
+     * 
+ * + * To see how the example above is evaluated, please see "implies" method of your {@link #permission()}. + * + * @see StringPermission#implies(Permission) for more details on how above-mentioned example is evaluated + * + * @return permissions linked to respective actions + */ + String[] value(); + + /** + * Choose a relation between permissions specified via {@link #value()}. By default, at least one of permissions + * is required (please see the example above). You can require all of them by setting `inclusive` to `true`. + * Let's re-use same example and make permissions inclusive: + * + *
+     * @PermissionsAllowed(value = {"resource:crud", "resource:retrieve", "natural-resource"}, inclusive = true)
+     * public Resource getResource() {
+     *     // business logic
+     * }
+     * 
+ * + * Two {@link StringPermission}s will be created: + * + *
+     * var pem1 = new StringPermission("resource", "crud", "retrieve");
+     * var pem2 = new StringPermission("system-resource", "retrieve");
+     * 
+ * + * And the permission check will pass if both {@code pem1} and {@code pem2} implies user permissions. + * + * @return `true` if permissions should be inclusive + */ + boolean inclusive() default false; + + /** + * Mark parameters of the annotated method that should be passed to the constructor of the {@link #permission()}. + * First, let's define ourselves three classes: + * + *
+     * class ResourceIdentity { }
+     * class User extends ResourceIdentity { }
+     * class Admin extends ResourceIdentity { }
+     * 
+ * + * Now that we have defined parameter data types, please consider the secured method 'getResource': + * + *
+     * @PermissionsAllowed(permission = UserPermission.class, value = "resource", params = {user1, admin1})
+     * public Resource getResource(User user, User user1, Admin admin, Admin admin1) {
+     *     // business logic
+     * }
+     * 
+ * + * In the example above, we marked parameters {@code user1} and {@code admin1} as {@link #permission()} constructor + * arguments: + * + *
+     * public class UserPermission extends Permission {
+     *
+     *     private final ResourceIdentity user;
+     *     private final ResourceIdentity admin;
+     *
+     *     public UserPermission(String name, ResourceIdentity user1, ResourceIdentity admin1) {
+     *         super(name);
+     *         this.user = user1;
+     *         this.admin = admin1;
+     *     }
+     *
+     *     ...
+     * }
+     * 
+ * + * Please mention that: + * + * + * When this annotation is used as the class-level annotation, same requirements are put on every single secured method. + * + *

+ * WARNING: "params" attribute is only supported in the scenarios explicitly named in the Quarkus documentation. + *

+ * + * @see #AUTODETECTED + * + * @return constructor parameters passed to the {@link #permission()} + */ + String[] params() default AUTODETECTED; + + /** + * The class that extends the {@link Permission} class, used to create permissions specified via {@link #value()}. + * + * For example: + * + *
+     * public class UserPermission extends Permission {
+     *
+     *     private final String[] permissions;
+     *
+     *     public UserPermission(String name, String... actions) {
+     *         super(name);
+     *         this.actions = actions;
+     *     }
+     *
+     *     ...
+     * }
+     * 
+ * + * {@code actions} parameter is optional and may be omitted. + * + * @return permission class + */ + Class permission() default StringPermission.class; + + /** + * The repeatable holder for {@link PermissionsAllowed}. The annotation is not repeatable on class-level as + * repeatable interceptor bindings declared on classes are not supported by Quarkus. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @interface List { + /** + * The {@link PermissionsAllowed} instances. + * + * @return the instances + */ + PermissionsAllowed[] value(); + } +} diff --git a/src/main/java/io/quarkus/security/StringPermission.java b/src/main/java/io/quarkus/security/StringPermission.java new file mode 100644 index 0000000..f2d6fdf --- /dev/null +++ b/src/main/java/io/quarkus/security/StringPermission.java @@ -0,0 +1,122 @@ +package io.quarkus.security; + +import java.security.Permission; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Represents permission based on simple string comparison. + * + * @see Permission + */ +public final class StringPermission extends Permission { + + public static final String ACTIONS_SEPARATOR = ","; + private final Set actions; + + /** + * Constructs a permission with the specified name and actions. + * + * @param permissionName must not be null or empty and must not contain comma + * @param actions optional actions; action itself must not be null or empty and must not contain comma + */ + public StringPermission(String permissionName, String... actions) { + super(validateAndTrim(permissionName, "Permission name")); + if (actions != null && actions.length != 0) { // OPTIONAL + this.actions = checkActions(actions); + } else { + this.actions = Collections.emptySet(); + } + } + + private static Set checkActions(String[] actions) { + Set validActions = new HashSet<>(actions.length, 1); + for (String action : actions) { + validActions.add(validateAndTrim(action, "Action")); + } + return Collections.unmodifiableSet(validActions); + } + + private static String validateAndTrim(String str, String paramName) { + if (str == null) { + throw new IllegalArgumentException(String.format("%s must not be null", paramName)); + } + str = str.trim(); + if (str.isEmpty()) { + throw new IllegalArgumentException(String.format("%s must not be empty", paramName)); + } + if (str.contains(ACTIONS_SEPARATOR)) { + // important for equals and hashCode + throw new IllegalArgumentException(String.format("%s must not contain '%s'", paramName, ACTIONS_SEPARATOR)); + } + return str; + } + + /** + * Checks if this StringPermission object "implies" the specified permission. + *

+ * More precisely, this method returns true if: + *

    + *
  • {@code p} is an instance of the StringPermission + *
  • {@code p}'s name equals this object's name + *
  • compared permissions have no actions, or this object's actions contains at least one of the {@code p} actions + *
+ * + * @param p the permission to check against + * + * @return true if the specified permission is implied by this object + */ + @Override + public boolean implies(Permission p) { + if (!(p instanceof StringPermission) || !getName().equals(p.getName())) { + return false; + } + StringPermission that = (StringPermission) p; + + // actions are optional, however if at least one action was specified, + // an intersection of compared sets must not be empty + if (that.actions.isEmpty()) { + // no required actions + return true; + } + if (actions.isEmpty()) { + // no possessed actions + return false; + } + for (String action : that.actions) { + if (actions.contains(action)) { + // has at least one of required actions + return true; + } + } + return false; + } + + @Override + public boolean equals(Object p) { + if (this == p) { + return true; + } + if (!(p instanceof StringPermission)) { + return false; + } + StringPermission that = (StringPermission) p; + return getName().equals(that.getName()) && actions.equals(that.actions); + } + + @Override + public int hashCode() { + return Arrays.hashCode(toString().toCharArray()); + } + + /** + * @return null if no actions were specified, or actions joined together with the {@link #ACTIONS_SEPARATOR} + */ + @Override + public String getActions() { + return actions.isEmpty() ? null : String.join(ACTIONS_SEPARATOR, actions); + } + +}