Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add PermissionsAllowed security annotation and default simple string comparison permission #22

Merged
merged 1 commit into from
Mar 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions src/main/java/io/quarkus/security/PermissionsAllowed.java
Original file line number Diff line number Diff line change
@@ -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:
*
* <pre>
* &#64;PermissionsAllowed("create")
* &#64;PermissionsAllowed("update")
* public Resource createOrUpdate(Long id) {
* // business logic
* }
* </pre>
*
* 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:
*
* <pre>
* public class UserPermission extends Permission {
*
* private final User user;
*
* public UserPermission(String name, User user) {
* super(name);
* this.user = user;
* }
*
* ...
* }
* </pre>
*
* 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.
*
* <pre>
* &#64;PermissionsAllowed(permission = UserPermission.class, value = "resource")
* public Resource getResource(User user1) {
* // business logic
* }
* </pre>
*
* 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 = "<<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':
*
* <pre>
* &#64;PermissionsAllowed("resource:retrieve")
* public Resource getResource() {
* // business logic
* }
* </pre>
* is equal to the {@code perm}:
* <pre>
* var perm = new StringPermission("resource", "retrieve");
* </pre>
*/
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':
*
* <pre>
* &#64;PermissionsAllowed({"resource:crud", "resource:retrieve", "system-resource:retrieve"})
* public Resource getResource() {
* // business logic
* }
* </pre>
*
* Two {@link StringPermission}s will be created:
*
* <pre>
* var pem1 = new StringPermission("resource", "crud", "retrieve");
* var pem2 = new StringPermission("system-resource", "retrieve");
* </pre>
*
* 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:
*
* <pre>
* &#64;PermissionsAllowed({"resource:crud", "resource:retrieve", "natural-resource"})
* public Resource getResource() {
* // business logic
* }
* </pre>
*
* Quarkus will create two permissions:
*
* <pre>
* var pem1 = new StringPermission("resource", "crud", "retrieve");
* var pem2 = new StringPermission("natural-resource");
* </pre>
*
* 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:
*
* <pre>
* &#64;PermissionsAllowed(value = {"resource:crud", "resource:retrieve", "natural-resource"}, inclusive = true)
* public Resource getResource() {
* // business logic
* }
* </pre>
*
* Two {@link StringPermission}s will be created:
*
* <pre>
* var pem1 = new StringPermission("resource", "crud", "retrieve");
* var pem2 = new StringPermission("system-resource", "retrieve");
* </pre>
*
* And the permission check will pass if <b>both</b> {@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:
*
* <pre>
* class ResourceIdentity { }
* class User extends ResourceIdentity { }
* class Admin extends ResourceIdentity { }
* </pre>
*
* Now that we have defined parameter data types, please consider the secured method 'getResource':
*
* <pre>
* &#64;PermissionsAllowed(permission = UserPermission.class, value = "resource", params = {user1, admin1})
* public Resource getResource(User user, User user1, Admin admin, Admin admin1) {
* // business logic
* }
* </pre>
*
* In the example above, we marked parameters {@code user1} and {@code admin1} as {@link #permission()} constructor
* arguments:
*
* <pre>
* 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;
* }
*
* ...
* }
* </pre>
*
* Please mention that:
* <ul>
* <li>constructor parameter names {@code user1} and {@code admin1} must exactly match respective "params",</li>
* <li>"ResourceIdentity" could be used as constructor parameter data type, for "User" and "Admin" are assignable
* from "ResourceIdentity",</li>
* <li>"getResource" parameters {@code user} and {@code admin} are not passed to the "UserPermission" constructor.</li>
* </ul>
*
* When this annotation is used as the class-level annotation, same requirements are put on every single secured method.
*
* <p>
* <b>WARNING:</b> "params" attribute is only supported in the scenarios explicitly named in the Quarkus documentation.
* </p>
*
* @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:
*
* <pre>
* public class UserPermission extends Permission {
*
* private final String[] permissions;
*
* public UserPermission(String name, String... actions) {
* super(name);
* this.actions = actions;
* }
*
* ...
* }
* </pre>
*
* {@code actions} parameter is optional and may be omitted.
*
* @return permission class
*/
Class<? extends Permission> 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();
}
}
122 changes: 122 additions & 0 deletions src/main/java/io/quarkus/security/StringPermission.java
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> checkActions(String[] actions) {
Set<String> 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.
* <p>
* More precisely, this method returns true if:
* <ul>
* <li> {@code p} is an instance of the StringPermission
* <li> {@code p}'s name equals this object's name
* <li> compared permissions have no actions, or this object's actions contains at least one of the {@code p} actions
* </ul>
*
* @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);
}

}