Как адаптировать UX/UI под permissions

    Во многих проектах существует процессы аутентификации (в той или иной степени). Написано много “бест практис” во всех известных технологиях и т.д. и т.п.


    Но вот пользователь сделал логин и? Ведь он далеко не всё может сделать. Как определить что он может видеть, а что нет. На какие кнопки имеет право нажимать, что может менять, что создавать или удалять.


    В этой статье я хочу рассмотреть подход к решению этих проблем на веб аппликации.


    image


    Начнем с того, что настоящая/действенная авторизация может происходить только на сервере. На Front End мы можем только улучшить пользовательский интерфейс (хм, Русский язык могуч, но не всегда...), он же User Experience дальше UX. Мы можем скрыть кнопки на которые у пользователя нет прав нажимать, или не допустить его до страницы, или показать сообщение о том, что у него нет прав на то или иное действие.


    И вот тут возникает вопрос, как сделать это максимально правильно.


    Начнем с определения проблемы.


    Мы создали Todo App и у нее есть разные виды пользователей:


    USER — может видеть все таски и изменять их (ставить и убирать V), но не может удалять или создавать и не видит статистику.


    ADMIN — может видеть все таски и создавать новые, но не видит статистику.


    SUPER_ADMIN — может видеть все таски, создавать новые и удалять, также может видеть статистику.


    view task Create task check/uncheck task (update) delete task view stats
    USER V X V X X
    ADMIN V V V X X
    SUPER_ADMIN V V V V V

    В такой ситуации мы можем легко обойтись ролями “roles”. Однако, ситуация может сильно измениться. Представим, что есть пользователь, который должен иметь те же права что и ADMIN плюс удаление тасков. Или же просто USER с возможностью видеть статистику.


    Простое решение, это создать новые роли.
    Но на больших системах, с относительно большим количеством пользователей мы быстро потеряемся в огромном количестве ролей… И тут мы вспомним о “правах пользователя” permissions. Для более упрощенного управления мы можем создавать группы из нескольких permissions и присоединять их к пользователю. Всегда остается возможность добавить специфический permission конкретному пользователю.


    Подобные решения можно встретить во многих больших сервисах. AWS, Google Cloud, SalesForce и т.д.
    Также подобные решения уже имплементированы во многих фреймворках, например Django (python).


    Я хочу привести пример имплементации для Angular аппликаций. (На примере все той же ToDo App).


    Для начала необходимо определить все возможные permissions.


    Разделим на фичеры


    1. У нас есть таски и статистика.
    2. Определим возможные действия с каждым из них.
      • Таск: create, read, update, delete
      • Статистика: read (в нашем примере только просмотр)
    3. Создадим карту (MAP) ролей и пермишнс

    export const permissionsMap = {
        todos:{
            create:'*',
            read:'*',
            update:'*',
            delete:'*'
        },
        stats:'*'
    }

    На мой взгляд это оптимальный вариант, однако в большинстве случаев сервер будет возвращать нечто вроде этого:


    export permissions = [
        'todos_create',
        'todos_read', 
        'todos_update', 
        'todos_delete', 
        'stas'
    ]

    Менее читабельно, но тоже очень неплохо.


    Вот так выглядит наша аппликация при наличии у пользователя всех возможных permissions.


    image


    Начнем с USER, так выглядит его permissions:


    export const USERpermissionsMap = {
        todos:{
            read:'*',
            update:'*',
        }
    }

    1) USER не может видеть статистику, то есть он в принципе не может перейти страницу статистики.


    Для подобных ситуаций в Angular есть Guards, которые прописываются на уровне Routes (документация).


    В нашем случае это выглядит так:


    const routes: Routes = [
        // {...Other routes},
        {
            path: 'stats',
            component: TodoStatisticsComponent,
            canActivate: [
                PermissionsGuardService
            ],
            data: {
                permission: 'stats'
            },
        }
    ];

    Следует обратить внимание на объект в data.


    peremissions = ‘stats’, это именно те permissions которые необходимо иметь пользователю, чтобы иметь доступ к этой странице.


    Основываясь на требуемых data.permissions и тех permissions которые нам передал сервер PermissionsGuardService будет решать пускать или нет USER на страницу ‘/stats’.


    @Injectable({
        providedIn: 'root'
    })
    export class PermissionsGuardService implements CanActivate {
        constructor(
            private store: Store<any>) {
        }
    
        canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean {
            // Required permission to continue navigation
            const required = route.data.permission;
    
            // User permissions that we got from server
            const userPerms = this.getPermissions();
    
            // verification
            const isPermitted = checkPermissions(required, userPerms);
    
            if (!isPermitted) {
                alert('ROUTE GUARD SAYS: \n You don\'t have permissions to see this page');
            }
    
            return isPermitted;
        }
    
        getPermissions() {
            return localStorage.get('userPermissions')
        }
    }

    Эта функция содержит логику принятия решения, — сущестует ли неободимый permission (required) среди всех permissions находящихся у USER (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;
    }

    image


    И так. Наш USER не может попасть на страницу статистики. Однако он все еще может создавать таски и удалять их.


    Для того чтобы USER не мог удалить таск, достаточно будет убрать красный (Х) из строки таска.
    Для этой цели воспользуемся Structural Directive.


    <!-- 
      Similar to other Structural Directives in angular (*ngIF, *ngFor..) we have '*' in directive name
      Input for directive is a required permission to see this btn.
    -->
    <button
        *appPermissions="'todos_delete'"
        class="delete-button"
        (click)="removeSingleTodo()"> X
    </button>

    @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 isPermitted = checkPermissions(this._required, this.getPermissions());
    
            if (isPermitted) {
                this._viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
            } else {
                console.log('PERMISSIONS DIRECTIVE says \n You don\'t have permissions to see it');
            }
        }
    
        getPermissions() {
            localStorage.get('userPermissions')
        }
    }

    image


    Теперь USER не видит кнопки DELETE но все еще может добавлять новые таски.


    Убрать поле ввода — испортит весь вид нашей аппликации. Правильным решением в данной ситуации будет disable поля ввода.


    Воспользуемся pipe.


    <!-- 
      as a value `permissions` pipe will get required permissions
      `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(private store: Store<any>) {
    
        }
    
        transform(required: any, args?: any): any {
            const isPermitted = checkPermissions(required, this.getPermissions());
    
            if (isPermitted) {
                return true;
            } else {
                console.log('[PERMISSIONS PIPE] You don\'t have permissions');
                return false;
            }
        }
    
        getPermissions() {
          return localStorage.get('userPermissions')
        }
    
    }

    И вот теперь USER может только видеть таски и изменять их (V/X). Однако у нас осталась еще одна кнопка ‘Clear completed’.


    Предположим, что у нас следующие требования Продук Менеджера:


    1. Кнопка ‘Clear Completed’ должна быть видна всем и всегда.
    2. Oна также должна быть кликабельна.
    3. В случае если USER без соответствующих пермишн нажимает на кнопку, должно появится сообщение.

    Constructional directive нам не поможет, также как и pipe.
    Прописывать в функции компоненты проверку на permissions тоже не очень удобно.


    Все что нам необходимо, — это между кликом и выполнением привязанной функции произвести проверку permissions.


    На мой взгляд здесь стоит воспользоваться декораторами.


    export function Permissions(required) {
        return (classProto, propertyKey, descriptor) => {
            const originalFunction = descriptor.value;
    
            descriptor.value = function (...args: any[]) {
    
                const userPerms = localStorage.get('userPermissions')
                const isPermitted = checkPermissions(required, userPerms);
                if (isPermitted) {
                    originalFunction.apply(this, args);
                } else {
                    alert('you have no permissions \n [PERMISSIONS DECORATOR]');
                }
    
            };
            return descriptor;
    
        };
    }

    @Component({
        selector: 'app-actions',
        templateUrl: './actions.component.html',
        styleUrls: ['./actions.component.css']
    })
    export class ActionsComponent implements OnInit {
        @Output() deleteCompleted = new EventEmitter();
        constructor() {
        }
    
        @Permissions('todos_delete')
        public deleteCompleted() {
            this.deleteCompleted.emit();
        }
    }

    Дикораторы заслуживают отдельной статьи.


    Наш финальный результат:


    image


    Итог:


    Данный подход позволяет легко и динамично адаптировать наш UX в соответствии с permissions которые есть у пользователя.


    А главное, нам для этого не надо пихать сервисы во все компоненты или забивать темплейты ‘*ngIf’.


    Вся аппликация целиком.


    Буду рад комментариям.

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 6

      +2
      У вас PermissionPipe зачем-то сделан на глобальных переменных и сам лезет в localStorage. Это нарушение принципа разделения ответственности.

      На мой взгляд, тут pipe вообще не нужен и вы бессмысленно переусложнили код. pipe ведь предназначен для всякой мелочи вроде форматирования. Можно просто сделать объект UserRights, который хранит права текущего пользователя, передать его в шаблон, и там написать:

      [disabled]="!userRights.can('todos_create')"

      Получается и разделение ответственности, и инкапсуляция, и никто не лезет в глобальные переменные. И с таким подходом при желании извратиться, можно вывести, как будут форму видеть разные пользователи.

      И декоратор тоже сделан неправильно. Он не должен лезть в глобальные переменные и тем более показывать алерты (используйте исключения). Вместо этого он должен просто объявлять нужные права доступа, а тот, кто вызывает обработчик, должен их проверить (или же: тот, кто вызывает, обязан передать UserRights, а декоратор их проверяет).
        0
        Это всего лишь пример. И он не претендует на 100% правельность всех имплиментаций.
        Посмотрите на GitHub, я не использую localStorage впринципе (не в компонентах/сервисах). Везде используется store, как единственный source of truth.

        То же самое с декоратором, — это пример.
        0
        Перевод конечно ужас, Сразу с переводчика на хабр? Приложения — аппликация, разрешения — где-то пермисионы, где-то permisions, есть же нормальный перевод этих слов. Ну и использование disabled вместо ngIf, может привести, к тому, что я просто через инспектор элементов уберу это свойство и все.
          0
          Не перевод :). Точнее перевод того что было в голове.
          Русский не мой основной язык и мне далеко не всегда удачно получается испльзовать правилно технические термины на другом языке кроме английского.
            0
            Отдавайте тогда на корректировку кто русский знает, ибо очень тяжело читать такие тексты.
          +1
          Мы данную проблему решили исключительно серверными средствами. Мы с сервера для каждого пользователя(после авторизации) выдаём дерево путь->[компонент[->компонент]] и права действия над ними FULL/VIEW. Всё что приходит в дереве, отображается в интерфейсе с пришедшими ограничениями. То, что не пришло в списке разрешённых компонентов не загружается в DOM модель.
          Да, это требует синхронизации интерфейса и сервера. Но она и так требуется при появлении нового функционала. Вопрос в том, кто инициатор синхронизации: разработчик клиента, который говорит о новых путях и компонентах в приложении или разработчик бекенда, который говорит о новых действиях.
          При этом скрытие элементов или запрет действий на интерфейсе не означает, что проверку прав доступа не надо делать на стороне сервера.
          В нашей схеме, к каждому компоненту может быть привязано действие, выполнение которого проверяется уже на стороне сервера, об этой части прав доступа интерфейс не знает вообще ни чего, но на стороне бекенда эти сущности синхронизованы.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое