The problems I want to solve here:
- It should be really really easy for business code check whether the current user is able to something.
- That "something" should be expressed in a type-safe way, where possible. It should also be expressed in a way that allows different levels of granularity, such that broadly-scoped permission rule can allow a user access to a very narrowly-scoped 'thing'.
- The rules that explain who has permission to what should be able to be expressed in a way that is pretty readable.
- Permission checks should be easily disabled for tests and such.
- It should be pretty easy to implement.
Permissions are expressed as a combination of a target and an action. An action is any object, and represents the type of thing the user is trying to do. A target is an ordered list of objects that describes upon what the user is trying to act. The first element in the list represents a 'domain', and each successive element represents a 'subdomain' of the previous element. The semantics of a domain aren't precisely defined by the framework.
Business code will check for permissions according to the following hopefully-self-explanatory examples:
// the framework would provide an AuthorizationService available for injection, to
// be used as a starting point for auth checks:
@Inject AuthorizationService authorizationService;
// in some business method:
DesignationNumber desig = ...
authorizationService
.target(DesignationEntity.class, desig) // [1]
.action(new UpdateSecureStatusAction()) // [2]
.checkAuthorization(); // [3]
- target() takes a variable number of arguments, which together form the target list for this check
- UpdateSecureStatusAction is a custom class representing this specific action
- throws an exception if the user isn't authorized to do this
Set<DesignationNumber> designations = ...
authorizationService
.target(
DesignationEntity.class,
new DesignationSet(designations), // [1]
new ColumnSet("secureStartDate", "secureEndDate"))
.action(StandardAction.UPDATE) // [2]
.checkAuthorization();
- DesignationSet is a simple wrapper class for Set; it's needed since I'm not (yet?) handling generics
- the framework would have a built-in StandardAction enum
Set<DesignationNumber> designations = ...
authorizationService
.target(
DesignationEntity.class,
new DesignationSet(designations),
new ColumnSet("description"))
.action(StandardAction.READ).and(StandardAction.UPDATE) // [1]
.isAuthorized(); // [2]
- you can check multiple actions at the same time for the same target
- return false if the user isn't authorized to do this
The rules that determine who has access to what will be expressed as a set of methods in a single class. When the framework is performing a permission check, it will see which rules apply by using java type assignability rules for both the action and the elements of the target list. However, if the rule target list (of size N
) is shorter than the check target list, it will only consider the first N
elements in the check list. I'll explain this more below.
Some example permission rules follow. (These would all exist in a single class that is known to the framework; I haven't figured out yet how the framework will find this class.)
@PermissionRule
boolean summerProjectToolCanUpdateSecureStatusForSummerProjectDesignations(
@Action UpdateSecureStatusAction action,
// the non-@Action method parameters form the target:
Class<?> entityClass,
DesignationSet designations)
{
if (entityClass != DesignationEntity.class)
return false;
Optional<String> systemName = authenticationService.getSystemName();
return
systemName.isPresent() &&
systemName.get().equals("summer-project-site") &&
allDesignationsAreSpDesignations(designations));
}
Rule 1 is not queried by any of Check 1, 2, or 3, because none of those checks have an action that matches UpdateSecureStatusAction
and a target that matches [Class<?>, DesignationSet]
. However, this rule will come into play in another example below.
@PermissionRule
@ForStandardActions({StandardAction.UPDATE, StandardAction.READ}) // [1]
boolean summerProjectToolCanReadAndUpdateSecureDatesForAllDesignations(
Class<?> entityClass,
DesignationSet designationSet,
ColumnSet columns)
{
if (entityClass != DesignationEntity.class)
return false;
Optional<String> systemName = authenticationService.getSystemName();
return
systemName.isPresent() &&
systemName.get().equals("summer-project-site") &&
ImmutableSet.of("secureStartDate", "secureEndDate").contains(columns.asSet()));
}
- shortcut to specify the rule only applies to some of the built-in StandardActions
Rule 2 is queried by Check 2 and Check 3, whose targets match [Class<?>, DesignationSet, ColumnSet]
and whose actions match StandardAction
.
In addition, because of the @ForStandardActions
annotation, the framework will only query this rule if the specific enum values in the checked action (UPDATE
in Check 2 and both READ
and UPDATE
in Check 3) are one of the values declared in @ForStandardActions
.
@PermissionRule
@ForStandardActions(StandardAction.READ)
boolean staffCanReadTheirOwnDesignationInformation(
Class<?> entityClass,
DesignationSet designationSet
)
{
if (entityClass != DesignationEntity.class)
return false;
if (designationSet.asSet().size() != 1 )
return false;
DesignationNumber queriedDesignation = Iterables.getOnlyElement(designationSet.asSet());
Optional<DesignationNumber> usersDesignation = authenticationService.getDesignationNumber();
return
usersDesignation.isPresent() &&
queriedDesignation.equals(userDesignation.get());
}
Rule 3 is queried only by the first part of Check 3, whose target matches [Class<?>, DesignationSet]
, whose action matches StandardAction
, and whose StandardAction enum value (READ
) is one of the actions declared in this @ForStandardActions
annotation (READ
). Check 3 also checks for UPDATE
permissions in addition to READ
permissions, but this is treated as a separate check. Rule 3 will not be queried for the UPDATE
permission check.
Note that when evaluating Check 3, the size of the check target list is 3, and the size of the rule target list is 2. So only the first two elements of the check target list (the Class<?>
element and the DesignationSet
element) are compared with the rule target list for type assignability. This enables Rule 3 to apply to any ColumnSet.
// permission rules can recursively check permissions
@PermissionRule
boolean ifYouCanActOnADesignationSetThenYouCanActOnAnIndividualDesignation(
@Action Object action,
Class<?> entityClass,
DesignationNumber designationNumber)
{
// Check 4
return authorizationService
.target(entityClass, new DesignationSet(designationNumber))
.action(action)
.isAuthorized();
}
Rule 4 is queried only by Check 1, whose target matches [Class<?>, DesignationSet]
and whose action matches Object
When this rule is queried, its internal check (Check 4) will cause the framework to query Rule 1, because the target matches [Class<?>, DesignationSet]
and the action matches UpdateSecureStatusAction
.
This enables rules to employ other rules pretty easily. For example, this enables you to easily express that if a user can UPDATE
something, then he can also READ
it.
@PermissionRule
boolean mattCanDoAnythingHeWantsTo(@Action Object action)
{
Optional<SsoGuid> mattsGuid = authenticationService.getSsoGuid();
return
mattsGuid.isPresent() &&
mattsGuid.get().equals(new SsoGuid("BB20A5DB-D31E-65B5-3629-E24504A00942"));
}
Rule 5 is queried by all of Check 1, 2, and 3, since their targets match the target [ ]
and the action Object
.
In this case the rule's target list is empty, which matches all possible targets. This is just a special case of the different-size-target-list rule.
This solution does seem to handle all the cases, but I think it's fair to say it's also pretty complex. Then again, permissions systems always tend to get complex quickly. Can you link to the relevant lines of the WSAPI that are currently ugly that this would improve?