In many projects, there are authentication processes (more or less). A lot of “best practices” were written in all known technologies and so on, and so forth.
But what happens after the user made the login? After all, he by far cannot do everything. How to define what he can see and what not. What buttons he has the rights to click on, what to change, create and delete.
In this article, I want to consider the approach used for solving these problems in a web application.
To begin with, the present/effective authorization can take place only on the server. On front-end, we are able just to improve UI and UX. We can hide the buttons on which the user has no rights to click, or prevent him from reaching the pages, or to show the message that he does not have the rights to do a certain action.
And here arises the question, how to make it as correctly as possible? Let’s start by defining the problem.
We created a Todo App and it has different types of users:
USER — can see and update all the tasks (check/uncheck), but cannot delete, create and see statistics page.
ADMIN — can see all the tasks and create new ones, but cannot see statistics.
SUPER_ADMIN — can see all the tasks, create new ones and delete them, and also can see statistics.
In this a situation, we can use the “roles”. However, the situation can change a lot. Imagine ADMIN user with permissions to delete tasks. Or USER with access to see statistics. A simple solution is to create new roles.
But on large applications, with massive user role system, we will quickly get lost in a huge amount of roles…
And here we recall the “user rights” permissions. For easier management, we can create groups of several permissions and attach them to the user. It is always possible to add specific permission to a specific user.
This kind of solutions can be found in many large services: AWS, Google Cloud, SalesForce and so on. Also, similar solutions have already been implemented in many frameworks, for example, Django (Python).
I want to give an example of implementation for Angular applications. (On the example of the same ToDo App).
First, let’s define all possible permissions.
- Divide into features. We have tasks and statistics.
- We define possible actions with each of them. Task: create, read, update, delete. Statistics: read (in our example only viewing).
- Create a MAP of roles and permissions.
export const permissionsMap = {
todos:{
create:'*',
read:'*',
update:'*',
delete:'*'
},
stats:'*'
}
In my opinion, this is the best option, but in most cases, the server will return something like this:
export permissions = [
'todos_create',
'todos_read',
'todos_update',
'todos_delete',
'stats',
]
Less readable, but also not bad at all.
This is how our application looks when the user is already authenticated but UI/UX is not yet adopted according to user’s permissions:
Let’s look at the USER’s permissions:
export const USERpermissionsMap = {
todos:{
read:'*',
update:'*',
}
}
Navigation:
USER cannot see the statistics, it means he basically cannot navigate to this page.
For such situations in Angular, there are Guards, which are used at the Routes level (documentation).
@Injectable({
providedIn: 'root'
})
export class PermissionsGuardService implements CanActivate {
constructor() {
}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean {
const userPerms = getPermissions();
const required = route.data.permission;
const isPermitted = checkPermissions(required, userPerms);
if (!isPermitted) {
alert('ROUTE GUARD SAYS: \n You don\'t have permissions to see this page');
}
return isPermitted;
}
}
Pay attention to the object in
data.permissions = 'stats'
, these are exactly the permission that the user needs to have, in order to have access to this page.
Based on the required data.permissions
and USER’s permissions
PermissionsGuardService will decide whether or not to allow access on page ‘/stats’.
getPermissions();
a helper function that returns an object with user's permissions.
checkPermissions();
checks if required permission is part of user’s permissions.
export function getPermissions() {
let userPerms;
// In our example we using stor as a single source of truth.
// Full implementation can be found here https://github.com/danduh/ngx-to-do-permissions.
const store: Store<any> = AppInjector.get(Store);
store
.pipe(
select(userPermissionsState),
take(1)
)
.subscribe((_p) => {
userPerms = _p ? _p : {};
});
return userPerms;
}
export function checkPermissions(required: string, userPerms) {
// 1) Separate feature and action
const [feature, action] = required.split('_');
// 2) Check if user have any type of access to the feature
if (!userPerms.hasOwnProperty(feature)) {
return false;
}
// 3) Check if user have permission for required action
if (!userPerms[feature].hasOwnProperty(action)) {
return false;
}
return true;
}
Hiding Elements
Now, our USER cannot navigate to the statistics page. However, he still can create tasks and delete them.
To make USER unable to delete tasks, it will be enough to remove the red (X) from the task line.
For this purpose, we will create a custom Structural Directive.
@Directive({
selector: '[appPermissions]'
})
export class PermissionsDirective {
private _required: string;
private _viewRef: EmbeddedViewRef<any> | null = null;
private _templateRef: TemplateRef<any> | null = null;
@Input()
set appPermissions(permission: string) {
this._required = permission;
this._viewRef = null;
this.init();
}
constructor(private templateRef: TemplateRef<any>,
private viewContainerRef: ViewContainerRef) {
this._templateRef = templateRef;
}
init() {
const userPerms = getPermmisions();
const isPermitted = checkPermissions(this._required, userPerms);
if (isPermitted) {
this._viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
} else {
console.log('PERMISSIONS DIRECTIVE says \n You don\'t have permissions to see it');
}
}
}
<!--
Similar to other Structural Directives in angular
(*ngIF, *ngFor..) we have '*' in directive name
'todos_delete' -> is an Input() for directive
as a required permission to see this btn.
-->
<button
*appPermissions="'todos_delete'"
class="delete-button"
(click)="removeSingleTodo()"> X
</button>
Now USER does not see the DELETE button but can still add new tasks.
Adopt element state:
If we remove the input field, the whole look of our application will ruin. The correct solution in this situation would be to disable the input field.
Let’s try pipe for this.
<!--
as a value `permissions` pipe will get required permission
`permissions` pipe return true or false, that's why we have !('todos_create' | permissions)
to set disable=true if pipe returns false
-->
<input class="centered-block"
[disabled]="!('todos_create' | permissions)"
placeholder="What needs to be done?" autofocus/>
@Pipe({
name: 'permissions'
})
export class PermissionsPipe implements PipeTransform {
constructor() {
}
transform(required: any, args?: any): any {
const userPerms = getPermissions();
const isPermitted = checkPermissions(required, userPerms);
if (isPermitted) {
return true;
} else {
console.log('[PERMISSIONS PIPE] You don\'t have permissions');
return false;
}
}
}
And now the USER can only see the tasks and change them (check/uncheck). However, we still have Clear completed button fully functional and available for USER.
Decorators:
Suppose that we have the following requirements from our Product Manager:
The ‘Clear Completed’ button should be visible to everyone and always.
It should also be clickable.
In case USER without corresponding permissions presses the button, an error message should appear.
Constructional directive will not help us, nor does pipes.
It is also not very convenient to register the check for permissions into component functions. All we need is to check permissions between the click and the execution of the bound function.
In my opinion here it is worth to use the decorators.
@Component({
selector: 'app-actions',
templateUrl: './actions.component.html',
styleUrls: ['./actions.component.css']
})
export class ActionsComponent implements OnInit {
@Output() deleteCompleted = new EventEmitter();
constructor() {
}
// Our custom decorator, as a param we pass required permission
@Permissions('todos_delete')
public deleteCompleted() {
this.deleteCompleted.emit();
}
}
export function Permissions(required) {
return (classProto, propertyKey, descriptor) => {
const originalFunction = descriptor.value;
descriptor.value = function (...args: any[]) {
const userPerms = getPermissions();
const isPermitted = checkPermissions(required, userPerms);
if (isPermitted) {
originalFunction.apply(this, args);
} else {
// Should throw/log error, but for better visibility will use alert()
alert('PERMISSIONS DECORATOR says \n you have no permissions');
}
};
return descriptor;
};
}
And our final result:
Conclusion
Maybe it’s not the best solutions. But this approach allows us to easily and dynamically adapt our UX in accordance with the permissions that the user has.
Thanks to Peter Pshenichny
Top comments (0)