Элементы контроля доступа

Зачем ето нужно ?

В идеальном мире мы не должны были бы делать вещи, которые нам не полагается делать. Но так как наш мир не идеален, иногда люди занимаются нелегальными делами. Это все также применимо и к компьютерному миру, к компьютерным системам, которые используются больше, чем одним пользователем. Почти все пытаются прочитать чужую почту, просмотреть отчеты о жаловании или получить доступ к скрытой информации.

Я знаю, Вы этого никогда не делали, но многие этим занимаются.

Простейшие методы

Самый простой способ разрешить или запретить определенные действия для учетной записи пользователя – это проверять, находится ли данная учетная запись в списке тех, кому данные действия позволены. Если Вы хотите запретить все действия кроме явно позволенных, функция доступа может быть достаточно простой, например:

  # access_check() return 1 or undef
  sub access_check
  {
    my $user_id     = shift;
    my @allow_users = @_;
    my %quick_allow = map { $_ => 1 } @allow_users;
    return $quick_allow{ $user_id };
  }
  my @allowed = ( 11, 12, 23, 45 );
  print "User 23 allowed\n" if access_check( 23, @allowed );
  print "User 13 allowed\n" if access_check( 13, @allowed );
  print "User 99 allowed\n" if access_check( 99, @allowed );
  # only "User 23 allowed" will be printed

Обычно контроль доступа настолько же прост, как и эта функция. Использование ID пользователей для контроля доступа – это просто, но иногда такой метод сложно поддерживать и обслуживать. Проблема возникает в системах с большим количеством пользователей или же в системах свободного доступа, где в любой момент могут быть созданы новые пользователи. Для каждой функции, которая требует контроля, список доступа становится огромным.

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

  # mimic real system environment:
  # %ALL_USER_GROUPS represents "storage" that contains all
  # groups that each user is attached to
  my %ALL_USER_GROUPS = (
                    23 => [ qw( g1  g4 ) ],
                    13 => [ qw( g3  g5 ) ],
                    );
  # user 23 is in groups g1 and g4
  # user 13 -- in g3 and g5
  # return list of user's groups. read data from storage or
  # from %ALL_USER_GROUPS in this example
  sub get_user_groups
  {
    my $user_id     = shift;
    return @{ $ALL_USER_GROUPS{ $user_id } || [] };
  }
  # access_check_grp() return 1 or 0
  sub access_check_grp
  {
    my $user_id     = shift;
    my @allow_users = @_;
    my %quick_allow = map { $_ => 1 } @allow_users;
    my @user_groups = get_user_groups( $user_id );
    for my $group ( @user_groups )
    {
      # user groups is listed, allow
      return 1 if $quick_allow{ $group };
    }
    # user group not found, deny
    return 0;
  }
  # this groups list is static and will not be altered
  # when users are added or removed from the system
  my @allowed = qw( g1  g2  g7  g9 );
  print "User 23 allowed\n" if access_check_grp( 23, @allowed );
  print "User 13 allowed\n" if access_check_grp( 13, @allowed );
  print "User 99 allowed\n" if access_check_grp( 99, @allowed );
  # only "User 23 allowed" will be printed

Хранение

Наверное, наиболее популярные хранилища для системных данных, на данный момент, это базы данных SQL. Вот простейший пример хранения пользователей, групп и связей между ними. Необходимо всего три таблицы.

  SQL CREATE statements:
  create table user  ( id integer primary key, name char(64), pass char(64) );
  create table group ( id integer primary key, name char(64) );
  create table map   ( user integer, group integer );
  TABLE USER:
   Column |     Type      | Modifiers
  --------+---------------+-----------
   id     | integer       | not null
   name   | character(64) |
   pass   | character(64) |
  TABLE GROUP:
   Column |     Type      | Modifiers
  --------+---------------+-----------
   id     | integer       | not null
   name   | character(64) |
  TABLE MAP:
   Column |  Type   | Modifiers
  --------+---------+-----------
   user   | integer |
   group  | integer |

Заполним таблички данными:

  letme=# select id, name from user;
   id |       name
  ----+------------------
    1 | Damian
    2 | Clive
    3 | Lana
  (3 rows)
  letme=# select * from group;
   id |       name
  ----+------------------
    1 | Admin
    2 | Users
    3 | Moderators
  (3 rows)
  letme=# select * from map;
   user | group
  -----+-----
     1 |   1
     1 |   2
     3 |   2
     3 |   3
     2 |   2
  (4 rows)

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

  Damian: Users, Admin
  Clive:  Users
  Lana:   Users, Moderators

Контроль во время выполнения

Приложение контролирует права доступа, после того как пользователем был произведен вход в систему. Поэтому, Вы можете скомбинировать контроль доступа с функцией входа в систему. Для примера: разрешить доступ для определенной группы пользователей в выходные дни. Если так, то проверка доступа производится только после успешного входа в систему, когда имя пользователя и пароль введены верно.

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

Все проверки на наличие прав доступа к функциям происходят позже.

Во время выполнения, хранилищем для групп пользователей может служить простой хэш. Он может быть как глобальным, так и включенным в объект сессии пользователя – в зависимости от дизайна системы. Для упрощения, в примерах используется глобальный хэш, но если Вы будете использовать этот код, запомните, что обязательно нужно очищать и создавать заново этот глобальный хэш, для каждого запроса пользователя, сразу же после входа в систему, или изменении пользовательской сессии. Можно также удалять все данные сессии пользователя после выхода из системы, но это можно рассматривать только как опцию, а не как единственно верный способ решения.

( В «правильных» системах пароли пользователей хранятся в захэшированном виде, но ето тема для отдельной статьи )

  #!/usr/bin/perl
  use strict;
  use DBI;
  use Data::Dumper;
  our $USER_NAME;
  our $USER_ID;
  our %USER_GROUPS;
  my $DBH = DBI->connect( "dbi:Pg:dbname=letme", "postgres", "",
      { AutoCommit => 0 } );
  # this is just an example!
  # username and password acquiring depends on the specific application
  user_login( 'Damian', 'secret4' );
  print "User logged in: $USER_NAME\n";
  print "User id:        $USER_ID\n";
  print "User groups:    " . join( ', ', keys %USER_GROUPS ) . "\n";
  sub user_login
  {
    my $user_name = shift;
    my $user_pass = shift;
    $USER_NAME   = undef;
    $USER_ID     = undef;
    %USER_GROUPS = ();
    # both name and password are required
    die "Empty user name"     if $user_name eq '';
    die "Empty user password" if $user_pass eq '';
    eval
    {
      my $ar = $DBH->selectcol_arrayref(
          'SELECT ID FROM USER WHERE NAME = ? AND PASS = ?',
                                        {},
                                        $user_name, $user_pass );
      $USER_ID   = shift @$ar;
      die "Wrong user name or password" unless $USER_ID > 0;
      $USER_NAME = $user_name;
      # loading groups
      my $ar = $DBH->selectcol_arrayref( 'SELECT GROUP FROM MAP WHERE USER = ?',
                                        {},
                                        $USER_ID );
      %USER_GROUPS = map { $_ => 1 } @$ar;
    };
    if( $@ )
    {
      # something failed, it is important to clear user data here
      $USER_NAME   = undef;
      $USER_ID     = undef;
      %USER_GROUPS = ();
      # propagate error
      die $@;
    }
  }

Если пароль Damian правильный, данный код выведет:

  User logged in: Damian
  User id:        1
  User groups:    1, 2

Функция проверки группового доступа стала еще проще:

  sub check_access
  {
    my $group = shift;
    return 0 unless $group > 0;
    return $USER_GROUPS{ $group };
  }

Код, который проверяет права доступа после входа в систему, может быть таким:

  sub edit_data
  {
    # require user to be in group 1 (admin) to edit data...
    die "Access denied" unless check_access( 1 );
    # user allowed, group 1 check successful
    ...
  }
or
  if( check_access( 1 ) )
  {
    # user ok
  }
  else
  {
    # access denied
  }

Команды доступа

Следующая проблема – определение функций, которые доступны для групп. Если эта информация статичная ( в большинстве случаев это так), ее можна хранить в конфигурационных файлах.

  LOGIN: 2
  EDIT:  1

Команда EDIT доступна для группы 1 ( администраторы) а команда LOGIN – группе 2 ( все пользователи).

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

  # all users for mon-fri
  LOGIN_WEEKDAYS: 2
  # only admin for sat-sun
  LOGIN_WEEKENDS: 1

Администраторы входят в обе группы (1, 2), таким образом, они получают доступ к системе в любое время. Все остальные пользователи на выходных доступа не имеют.

Этот список групп включает в себя группу модераторов. Полезно для выполнения модераторами своей роботы на выходных. Подразумевается использование оператора OR.

  # only admin or moderators for sat-sun
  LOGIN_WEEKENDS: 1, 3

Такой набор групп называется политикой прав доступа.

Сейчас политика содержит в себе только один уровень, и только один оператор OR между группами. В реальности же, политики могут быть комплексными. Но нет причин переделывать их, каждая большая система может работать с несколькими уровнями, а также, использовать оператор AND.

 LOGIN_WEEKENDS: 1+3, 4, 1+5+9

Такая политика разрешает доступ на выходных только для пользователей следующих групп:

   1 AND 3
  OR 4
  OR 1 AND 5 AND 9

Функция входа должна проверять политику LOGIN_WEEKENDS до того, как позволять пользователю работать в системе. Поэтому Вам нужна функция для считывания политик прав доступа из конфигурационных файлов:

  our %ACCESS_POLICY;
  sub read_access_config
  {
    my $fn = shift; # config file name
    open( my $f, $fn );
    while( <$f> )
    {
      chomp;
      next unless /\S/; # skip whitespace
      next if  /^[;#]/; # skip comments
      die "Syntax error: $_\n" unless /^\s*(\S+?):\s*(.+)$/;
      my $n = uc $1; # policy name: LOGIN_WEEKENDS
      my $v =    $2; # groups lsit: 1+3, 4, 1+5+9
      # return list of lists:
      # outer list uses comma separator, inner lists use plus sign separator
      $ACCESS_POLICY{ $n } = access_policy_parse( $v );
    }
    close( $f );
  }
  sub access_policy_parse
  {
    my $policy = shift;
    return [ map { [ split /[\s\+]+/ ] } split /[\s,]+/, $policy ];
  }

Для политики LOGIN_WEEKENDS, результатом в %ACCESS_POLICY будет:

  $ACCESS_POLICY{ 'LOGIN_WEEKENDS' } =>
                [
                  [ '1', '3' ],
                  [ '4' ],
                  [ '1', '5', '9' ]
                ];

Чтобы соответствовать данной политике, пользователь должен состоять в группах, перечисленных в любом из внутренних списков :

  sub check_policy
  {
    my $policy = shift;
    my $out_arr = $ACCESS_POLICY{ $policy };
    die "Invalid policy name; $policy\n" unless $out_arr;
    return check_policy_tree( $out_arr );
  }
  sub check_policy_tree
  {
    my $out_arr = shift;
    for my $in_arr ( @$out_arr )
    {
      my $c = 0; # matching groups count
      for my $group ( @$in_arr )
      {
        $c++ if $USER_GROUPS{ $group };
      }
      # matching groups is equal to all groups count in this list
      # policy match!
      return 1 if $c == @$in_arr;
    }
    # if this code is reached then policy didn't match
    return 0;
  }

Пример:

  sub user_login
  {
      # login checks here
      ...
      # login ok, check weekday policy
      my $wday = (localtime())[6];
      my $policy;
      if( $wday == 0 or $wday == 6 )
      {
        $policy = 'LOGIN_WEEKEND';
      }
      else
      {
        $policy = 'LOGIN_WEEKDAY';
      }
      die "Login denied" unless check_policy( $policy );
  }
  sub edit_data
  {
    # require user to be in group 1 (admin) to edit data...
    die "Access denied" unless check_policy( 'EDIT' );
    # user allowed, 'EDIT' policy match
    ...
  }

Теперь у Вас есть все части схемы контроля доступа:
Конфигурация синтаксиса политик
Парсер политик
Хранилище групп пользователей и связей между ними
Загрузчик групп пользователей
Функция проверки политик

Разделение информации

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

Решение этой проблемы никак не связано с политиками прав доступа. Каждая запись должна включать одно или несколько полей, заполненных группами, которые имеют доступ к этим данным. Все SQL запросы для чтения информации также должны учитывать эти поля:

  my $rg  = join ',', grep { $USER_GROUPS{ $_ } } keys %USER_GROUPS;
  my $ug  = join ',', grep { $USER_GROUPS{ $_ } } keys %USER_GROUPS;
  my $sql = "SELECT * FROM TABLE_NAME
             WHERE READ_GROUP IN ( $rg ) AND UPDATE_GROUP IN ( $ug )";

Результат будет включать только те записи, доступ на чтение и редактирование к которым разрешен для группы, в которой состоит пользователь. Иногда есть необходимость вывести все записи с определенной группой чтения, несмотря на то, что пользователь не имеет права изменять эти записи. В таком случае нужно использовать только поле READ_GROUP для выборки, и останавливать действия пользователей, которые пытаются изменить запись, не имея соответствующих прав:
  my $rg  = join ',', grep { $USER_GROUPS{ $_ } } keys %USER_GROUPS;
  my $sql = "SELECT * FROM TABLE_NAME WHERE READ_GROUP IN ( $rg )";
  $sth = $dbh->prepare( $sql );
  $sth->execute();
  $hr = $sth->fetchrow_hashref();
  die "Edit access denied" unless check_access( $hr->{ 'UPDATE_GROUP' } );

Когда проверка доступа производится непосредственно после вызова SELECT, можно хранить полные строки политик доступа внутри CHAR полей:
  $hr = $sth->fetchrow_hashref();
  die "Edit access denied" unless check_policy_record( $hr, 'UPDATE_GROUP' );
  sub check_policy_record
  {
      my $hr     = shift; # hash with record data
      my $field  = shift; # field containing policy string
      my $policy = $hr->{ $field };
      my $tree   = access_policy_parse( $policy );
      return check_policy_tree( $tree );
  }

Заключение

Описанная схема контроля доступа проста и удобна в использовании. Она не покрывает всех возможных случаев, но каждое приложение имеет свои уникальные потребности. В некоторых случаях, контроль доступа можно проводить и на нижних уровнях, например на уровне базы данных. Удачи в построении Вашей собственной системы безопасности!

Статья является переводом Elements of Access Control и публикуется с любезного разрешения автора Vladi Belperchinov-Shabanski