Dynamic Roles
Introduction
Dynamic roles are a way to write custom authorization logic (as opposed to authentication logic).
The "Dynamic Roles" feature works similarly to the Authentication Classes feature, but while authentication classes are assigned to a backend and are for general authentication, the dynamic roles are assigned to classes (MetaModels) and enable custom role checks. These custom role checks can be used instead of default role checks (simple roles "Guest", "User", "Owner" etc.) and ACLs. ACL role objects can still be assigned, but you have to check their contents yourself.
Here is what the process looks like:
-
You or someone else overwrites the isUserInRole() method which is defined in IModelHooksCommon (the superclass of all Native Module hook classes) and uploads the module
-
The class appears as a candidate for custom role checks (in the Dashboard or via REST API)
-
You assign one of those classes to another class that you want to "protect" (i.e. where you want the role check method to be called) and build the module (e.g. via Dashboard notification)
-
Now when requests to your "protected" class are sent, the custom role check method is called
The name for the above mentioned candidate classes is "role class", borrowing from "auth classes".
In the context of dynamic roles, "role class" doesn't mean the Basics. Role class or a subclass of it. It's the equivalent of the term "auth class", which describes classes whose hook classes contain a custom auth method. Here, a "role class" is a class whose hook class contains a custom role check method.
Developing your own custom role check
You can create your own "role classes", meaning you can implement your own isUserInRole() method in the related hook class.
In order to do so, create and download a native module (see Create your own for information on Native Module development) with the classes you intend to utilize for authorization. Afterwards, import the downloaded project into your IDE. You will notice that every class you added to your module is now extended by two Hook-classes named <yourClassName>HooksTransient and <yourClassName>HooksNonTransient.
Depending on how you design your module-class (either setting the isTransient-flag true or false), add to the transient or non-transient hook class a method called isUserInRole() with the correct signature and the @Override annotation, like this:
@Override
public
boolean
isUserInRoles( com.apiomat.nativemodule.DynamicRoleWrapper roleData,
com.apiomat.nativemodule.Request request )
{
return
false
;
}
The parameters should contain all information you need to decide whether the user is allowed to access the resource or not. "Accessing the resource" means the user is doing a CRUD operation on an object.
-
roleData is a DynamicRoleWrapper object with the following getters:
-
getOperation(), which returns an Operation enum with one of these values:
-
CREATE: HTTP POST request for creating an object
-
READ: HTTP GET request for reading an object
-
WRITE: HTTP PUT request for updating an object or HTTP DELETE request for deleting an object
-
GRANT: HTTP PUT request with new permissions for an existing object
-
-
getModuleName(): Returns the module's name. Can be null if the role check is for an image or file that hasn't been attached to an object.
-
getModelName(): Returns the class name. Can be null if the role check is for an image or file that hasn't been attached to an object.
-
getModelObject(): Returns the object the user wants access to. Can be null when the role check is for an image or file.
-
getStaticData(): Returns the image or file the user wants access to. Can be null if the role check is for an object.
-
getAllowedRoles(): Returns a set of role names of the roles that are assigned for the given operation. For example, when you create a role object with the name "foo" and add it to the allowed roles for the WRITE operation for a class, then when your isUserInRoles() method gets called when a user makes an HTTP PUT request, getAllowedRoles() will return a set with one String element "foo".
-
-
request is the same Request object that gets passed to all the other hook methods
After implementing this method you can upload the respective Native Module again. ApiOmat will then automatically switch the ownRoleCheck flag to true on the class whose hook class contains the implementation.
Now you're able to assign your module's role classes to other classes in any of your other modules. Releasing your module will allow other customers to utilize the same role classes
You should always handle ALL operations, because as soon as a role class is assigned to protect another class, the custom role check method is called for ALL operations. So if you only handle READ with some checking logic and otherwise return false by default, all operations other than READ will always fail.
Fetching available role classes
There are two ways to fetch the available role classes. An available role class is a class that:
-
Has access to (meaning you either created it yourself, someone else created it and granted you access or someone else created it and released the module)
-
Has a related hook class where the method isUserInRoles() is overwritten
The two ways are:
-
Via Dashboard: In the class editor below the attributes is the section for roles. Here you can see if the class and objects of it are protected by simple roles (like "User", "Owner" etc.), ACLs / role objects or custom role check methods
-
Via REST API: Via Apidocs or with an HTTP client like cURL with an HTTP GET request to the URL https://yourApiomatHost/yambas/rest/modules/roleMetaModels
The response body is a JSON array of JSON objects, with the JSON objects representing MetaModel objects.
Assigning role classes to a class (MetaModel)
You can assign multiple role classes to one class and set the order in which the custom role check methods should be called.
For example, if you want to protect your class X by role classes A and B, with all classes being in your module M version 1.0.0, you set:
{
"roleClassesMap"
:
{
"LIVE"
:
{
"1"
:
"M$1.0.0$A"
,
"2"
:
"M$1.0.0$B"
}
}
}
Now, when a CRUD request is sent to an object of your class X, first the A.isUserInRoles() is called. If it returns true, the authorization is successful. If it returns false, B.isUserInRoles() is called. If this returns true, the authorization is successful. If it returns false, the authorization fails.
To use the default ApiOmat role check, either don't assign any role class at all, or include "Basics$1.0.0$Role" in the roleClassesMap
To actually set the role classes map, you have two possibilities:
-
Via Dashboard: In the class editor below the attributes is the section for roles. Here you can set the authorization for the class and objects of it. You can choose between simple roles (like "User", "Owner" etc.), ACLs / role objects or custom role check methods
Simply choose the dynamic role classes you'd like to apply to your class from the dropdown-selection. Afterwards you can order them by using the arrows left of the classes' names.
-
Via REST API: Via Apidocs or with an HTTP client like cURL with an HTTP PUT request to the URL https://yourApiomatHost/yambas/rest/modules/YourModule/metamodels/YourMetaModelId
When sending an HTTP PUT request to a MetaModel, you should include ALL attributes of that MetaModel with their old values, because all attributes that aren't included in the PUT request will be set to their default values. This is similar to sending a PUT request to an object of a class with the HTTP header "x-apiomat-fullupdate: true" and omitting some attributes - the values of those omitted attributes will be set to their defaults as well.
As soon as you assign a role class to a class, the authorization for ALL operations (CREATE, READ, WRITE, GRANT) will be set to "AppAdmin" and the custom role check method(s) will be called for ALL operations. This is why you should always handle ALL operations in the isUserInRole() method. If you only handle READ with some checking logic and otherwise return false by default, all operations other than READ will always fail.
Example
Let's say you create a Module M with a class A and B. Your users' credentials are stored in a 3rd party system X and the user roles are stored in a 3rd party system Y.
-
Create a class C that you want to use for custom authentication and authorization
-
Implement CHooksNonTransient.auth() like this:
@Override
public
boolean
auth( String httpVerb, String moduleName, String modelName, String modelForeignId, String userNameOrEmail,
String password, Request r )
{
return
areCredentialsValidInExternalSystemX( userNameOrEmail, password );
// pure authentication - only the credentials are checked in 3rd party system X
}
-
You implement CHooksNonTransient.isUserInRoles() like this:
@Override
public
boolean
isUserInRoles( com.apiomat.nativemodule.DynamicRoleWrapper roleData,
com.apiomat.nativemodule.Request request )
{
switch
(roleData.getOperation())
{
case
CREATE:
return
isUserAllowedToCreateObjectInExternalSystemY(request.getUserEmail(), roleData.getModelName());
// Makes SOAP call into 3rd party legacy system Y to check if the user is allowed to create an object of class "roleData.getModelName()"
case
READ:
return
isUserAllowedToReadObjectInExternalSystemY(request.getUserEmail(), roleData.getModelObject());
// Makes SOAP call into 3rd party legacy system Y to check if the user is allowed to read an object of class "roleData.getModelName()" and provides the object itself because it may contain object-specific roles that allow additional users READ permissions (in addition to the roles on the class)
default
:
// WRITE, GRANT
break
;
}
return
false
;
}
Returning false for all operations but CREATE and READ leads to having immutable, non-deletable objects of the class that you protect with this method, with only users being allowed to CREATE and READ them that are allowed by your 3rd party legacy system Y
-
You upload the Native Module
-
You set C as auth class to your app, see Authentication Classes
-
You set C as role class to A
Now user Alice sends an HTTP POST request to A. This happens:
-
auth() is called, which sends her credentials to X (3rd party credential storage).
-
Her credentials are valid, which means she is authenticated. Now isUserInRoles() is called, leading to checking in Y (3rd party role system) if she is allowed to create an object of type "A".
-
She is allowed, so the object is created.
Now Alice sends an HTTP PUT request to the previously created object. This happens:
-
auth() is called, which sends her credentials to X (3rd party credential storage).
-
Her credentials are valid, which means she is authenticated. Now isUserInRoles() is called, where in the switch-case WRITE leads to "false" being returned. An authorization error response is sent.
Now Alice sends an HTTP POST request to B. This happens:
-
auth() is called, which sends her credentials to X (3rd party credential storage).
-
Her credentials are valid, which means she is authenticated. Nothing else is checked, because the role class C is not assigned to protect B. Not even the default roles are checked (which would be "GUEST" for the CREATE operation)!
-
To use the default role check you have two options:
-
You can set "Basics$User" as auth class, but this requires the previous auth classes to return false and has other implications as well
-
You can manually call AOM.checkRoles() from within the auth() method. In case the class of the object is protected by role classes, this also leads to the custom role check method to be called! In case you pass your own "memberId" values to checkRoles(), the custom role check method must read this value from the DynamicRoleWrapper parameter, instead of using request.getUserEmail().
-
-
Now Bob sends an HTTP POST request to A. This happens:
-
auth() is called, which sends his credentials to X (3rd party credential storage).
-
His credentials are valid, which means he is authenticated. Now isUserInRoles() is called, leading to checking in Y (3rd party role system) if he is allowed to create an object of type "A".
-
He is not allowed. An authorization error response is sent.
Now Eve sends an HTTP POST request to A. This happens:
-
auth() is called, which sends her credentials to X (3rd party credential storage).
-
Her credentials are not valid. An authentication error response is sent. isUserInRoles() is never called.
This is only ONE example. Contact the support if you need assistance with setting up auth and role classes for specific requirements.
Assigning role classes via Native Module Upload
You are also able to assign role classes as map code first when uploading your module. All you have to do is to define your role classes within the @Model annotation of your MetaModel that should be checked:
@Model
( moduleName =
"TestModule"
, [...], roleClassesMap={
"1=MyModule$1.0.0$RoleChecker"
,
"2=Basics$1.0.0$Role"
})
The role classes map is a string array containing entries with the following format: <orderNumber>=<moduleName>$<moduleVersion>$<className> . The order number defines the dynamic role check order and the combination of moduleName, moduleVersion and className defines the class that implements the custom isUserInRoles check. If the specified moduleVersion is not available, the latest version of the module will be used.
For more detailled information about the other @Model annotation parameters visit Development Basics.
Important info
-
In contrary to auth classes, where you can reject app admins, you can't reject app admins with role classes. So for example when you set M$1.0.0$A as role class to protect your class X, and an app admin sends an HTTP GET request to X to fetch all objects of that class, then even if A.isUserInRoles() returns false, the app admin gets a valid response. So to reject app admin requests, use auth classes.
-
Because the dynamic roles feature was introduced after the auth classes feature and to maintain backward compatibility, there is a difference in the order of authentication and authorization:
-
When using custom authentication, custom auth methods are always called first. Then:
-
When using ACLs or custom role check, authorization is done (this includes calling your isUserInRoles() method)
-
When using simple roles (guest, user, owner), no further checks are done
-
-
When using standard auth, it depends on the use of custom role check:
-
When using ACLs or custom role check, authorization is always done first and then followed by authentication
-
When using simple roles: "Guest" and "user" only require authentication; for "owner" an ownership check is done first and then authentication is done
-
-
Performance Improvement - Skipping Custom Role Checks
When a user requests a list of objects, and the class of the object has a role class configured to be used as protection, the custom role check is executed on each object by default. If the list contains 1000 objects, and your custom role check method contains complex logic or even contacts a third party system for role information, this might turn into a performance bottleneck.
To avoid this, ApiOmat provides you with a mechanism to preconditionally skip following custom role check calls within getAll and deleteAll operations.
Java Predicates
You can set Java Predicates within the DynamicRoleWrapper to inform ApiOmat if the next class objects of the same request need a custom role check or not. In other words, the predicates contain logic to determine if a custom role check call is necessary or not.
To improve or refresh your knowledge about Java predicates you can read the official reference documentation at: https://docs.oracle.com/javase/8/docs/api/java/util/function/Predicate.html or read other tutorials like https://howtodoinjava.com/java-8/how-to-use-predicate-in-java-8/ .
Scenario - skipping custom role checks on objects that have the same role and return true
You've implemented a custom role check that returns true if an object contains a specifc role and the request user is member of this role.
Now, a user calls getAllObjects e.g. via REST.
Implement the following code to execute your custom role check only on the first object of the list:
import
java.util.function.BiPredicate;
...
@Override
public
boolean
isUserInRoles( DynamicRoleWrapper roleData, Request request )
{
this
.model.log(
"Custom Role Check executing ..."
,
false
);
final
String neededRole =
"UserRoleThirdParty"
final
boolean
hasUserRole = checkThirdPartySystemForRole( request.getUserEmail( ), roleData.getAllowedRoles(), neededRole );
final
BiPredicate<DynamicRoleWrapper, Request> truePredicate = (d, r) -> ( d.getAllowedRoles( ).contains( neededRole ) );
final
List<BiPredicate<DynamicRoleWrapper, Request>> predicates =
new
ArrayList<>( roleData.getSkipCustomRoleCheckWithTruePredicates( ) );
predicates.add( truePredicate );
roleData.setSkipCustomRoleCheckWithTruePredicates( predicates );
if
( hasUserRole && truePredicate.test( roleData, request ) )
{
return
true
;
}
return
false
;
}
private
static
boolean
checkThirdPartySystemForRole( ... )
{
// Do some role checks for the request user here
return
result;
}
First, check if the request user is authorized against any third-party system of your choice (see hasUserRole in example). After that construct your predicate that defines a role check skip condition, e.g. if the object contains a specific role (see neededRole in example).
This predicate then needs to be set to the DynamicRoleWrapper. Setting the predicate to the SkipCustomRoleCheckWithTruePredicates means whenever one of the next objects of the GetAll-Request matches the given predicate, the role check will return TRUE for this request user.
So your custom role check is only called once for all the objects in the list that match the same conditions.
Scenario - skipping custom role checks but returning false
Additionally you are able to skip custom role checks and reject the request by returning false. Imagine a user wants to delete all objects of a class but the list contains an important object that should not be deleted.
Simply define a predicate and add it to the SkipCustomRoleCheckWithFalsePredicates of the DynamicRoleWrapper and the next object that matches the predicate will return a failed authorization check without calling your custom role method again.
import
java.util.function.BiPredicate;
...
private
static
BiPredicate<DynamicRoleWrapper, Request> falsePredicate = (d, r) -> d.getModelName( ).contains(
"IMPORTANT"
) ;
@Override
public
boolean
isUserInRoles( DynamicRoleWrapper roleData, Request request )
{
this
.model.log(
"Custom Role Check executing ..."
,
false
);
final
List<BiPredicate<DynamicRoleWrapper, Request>> predicates =
new
ArrayList<>( roleData.getSkipCustomRoleCheckWithFalsePredicates( ) );
predicates.add( falsePredicate );
roleData.setSkipCustomRoleCheckWithFalsePredicates( predicates );
if
( falsePredicate.test( roleData, request ) )
{
result =
false
;
}
//...do some other stuff
return
true
;
}