Настройка мандатного управления доступом(МРД) с Kerberos аутентификацией в ЕПП, Apache2 WSGI, Postgres#

Исходные данные#

Имеется сервер контроллера домена FreeIPA:

  • имя домена astra.aaa

  • администратор домена admin@astra.aaa

  • пользователь домена user-01@astra.aaa

  • имя сервера dc-01.astra.aaa

  • сервер имеет постоянный IP-адрес, например, 192.168.1.20

Веб-сервер располагается отдельно:

  • имя сервера websrv-01.astra.aaa

  • сервер должен быть введен в домен

  • на сервере установлен и настроен web-сервер Apache2

  • сервер имеет постоянный IP-адрес, например, 192.168.1.21

Сервер базы данных располагается отдельно:

  • имя сервера dbsrv-01.astra.aaa

  • сервер должен быть введен в домен

  • на сервере должна быть установлена и настроенная СУБД Postgresql

  • сервер имеет постоянный IP-адрес, например, 192.168.1.23

Пользовательский компьютер располагается на отдельном компьютере:

  • имя компьютера pc-01.astra.aaa

  • компьютер должен быть введен в домен

  • компьютер имеет постоянный IP-адрес, например, 192.168.1.22

Основная концепция, реализация веб-приложения на языке программирования Python с МРД#

Контроллер домена Freeipa, веб сервер Apache2 и СУБД Postgres предварительно настроены для использования МРД и kerberos аутентификации. Пользователь домена при входе в сеанс на своём компьютере выбирает уровень и категорию из доступных для него, далее при отправке запроса из браузера веб сервер Apache2 получает классификационную метку пользователя и его билет kerberos. Веб сервер Apache2 производит аутентификацию пользователя и если она прошла успешно, то обработчик запроса переключается в контекст пользователя, включая классификационную метку МРД его сеанса. Далее запускается WSGI приложение и создается делегируемый kerberos кэш. Если аутентификации прошла неуспешно, выдается ошибка. Далее веб сервер Apache2 передаёт запрос веб приложению Flask. Веб приложение Flask добавляет в окружение переменную KRB5CCNAME. Далее коннектор Psycopg2, в режиме GSS, производит запрос к БД Postgresql с передачей контекста пользователя, включая классификационную метку МРД. СУБД Postgres так же производит kerberos аутентификацию, авторизацию по правилам МРД и возвращает результаты запроса.

Настройка контроллера домена#

Для настройки контроллера домена необходимо перейти по ссылке и выполнить действия по инструкции:

Настройка компьютера пользователя#

Для настройки компьютера пользователя необходимо перейти по ссылке и выполнить действия по инструкции:

Настройка сервера базы данных#

Для настройки сервера базы данных необходимо перейти по ссылке и выполнить действия по инструкции:

Установка и настройка веб-сервера Apache2#

Для установки и настройки веб-сервера Apache2 необходимо перейти по ссылке и выполнить действия по инструкции:

Установка веб-приложения#

Для установки и настройки веб-приложения необходимо:

Пункт 1#

  • установить следующие пакеты пакет:

sudo apt install python3-flask python3-psycopg2

Пункт 2#

  • создать по пути /var/www папку flask_app:

sudo mkdir /var/www/flask_app

Пункт 3#

  • развернуть приложение Flask, со структурой:

flask_app/
├── wsgi.py               # Точка входа в WSGI приложение
├── app/                  # Приложение
    ├── __init.py__       # Основной файл приложения
    ├── templates/        # HTML-шаблоны
    │   └── index.html
    └── static/           # Статические файлы (CSS, JS, изображения)
        ├── css/
        ├── js/
        └── images/

Пункт 4#

  • файл wsgi.py должен иметь следующее содержимое:

import os
import sys

# Добавляем директорию текущего файла в Python path
# Это необходимо для корректного импорта модуля app
sys.path.append(os.path.dirname(__file__))

# Импортируем функцию создания Flask-приложения
from app import get_app

# Создаем экземпляр Flask-приложения
flask_app = get_app()

def application(environ, start_response):
    """
    WSGI-интерфейс для интеграции с веб-сервером

    Args:
        environ (dict): Переменные окружения и параметры запроса
        start_response (callable): Функция для отправки HTTP-заголовков

    Returns:
        Ответ Flask-приложения

    Особенности:
        - Устанавливает переменную KRB5CCNAME для Kerberos-аутентификации
        - Передает управление основному Flask-приложению
    """
    # Настраиваем Kerberos credentials из окружения WSGI
    os.environ['KRB5CCNAME'] = environ['KRB5CCNAME']

    # Делегируем обработку запроса Flask-приложению
    return flask_app(environ, start_response)

Пункт 5#

  • файл __init.py__ должен иметь следующее содержимое:

from flask import Flask, render_template, request, redirect, url_for, flash, get_flashed_messages
import psycopg2
from psycopg2 import sql
import os
from contextlib import contextmanager


def get_app():
    app = Flask(__name__)
    app.secret_key = os.getenv('SECRET_KEY', 'your-secret-key-here')

    # Конфигурация подключения к БД
    DB_CONFIG = {
        'host': 'dbsrv-01.astra.aaa',
        'port': '5432',
        'dbname': 'demoprimer'
    }

    @contextmanager
    def db_connection():
        """Контекстный менеджер для работы с БД"""
        conn = None
        try:
            if 'KRB5CCNAME' in os.environ:
                os.environ["KRB5CCNAME"] = os.environ.get('KRB5CCNAME', '')

            conn = psycopg2.connect(**DB_CONFIG)
            yield conn
        except Exception as e:
            flash(f"Ошибка подключения к базе данных: {str(e)}", "error")
            raise
        finally:
            if conn:
                conn.close()

    @contextmanager
    def db_cursor():
        """Контекстный менеджер для работы с курсором"""
        with db_connection() as conn:
            cursor = conn.cursor()
            try:
                yield cursor
                conn.commit()
            except Exception:
                conn.rollback()
                raise

    def get_current_user():
        """Получение текущего пользователя"""
        return request.remote_user or 'anonymous'

    def format_record(record):
        """Форматирование записи из БД"""
        return {
            'id': record[1],
            'maclabel': record[0],
            'insert_user': record[2],
            'classificator': record[4],
            'insert_ts': record[3].strftime('%d-%m-%Y %H:%M:%S')
        }

    @app.route('/', methods=['GET', 'POST'])
    def index():
        try:
            # Обработка GET параметров
            if request.method == 'GET':
                return handle_get_requests()

            # Обработка POST запросов
            return handle_post_requests()

        except Exception as e:
            flash(f"Ошибка: {str(e)}", "error")
            return render_empty_page()

    def handle_get_requests():
        """Обработка GET запросов"""
        delete_id = request.args.get('delete')
        if delete_id:
            return delete_record(delete_id)

        edit_id = request.args.get('edit')
        records = fetch_all_records()
        edit_record = fetch_edit_record(edit_id) if edit_id else None

        return render_template('index.html',
                               records=records,
                               edit_record=edit_record,
                               messages=get_flashed_messages(with_categories=True))

    def handle_post_requests():
        """Обработка POST запросов"""
        current_user = get_current_user()

        if 'create' in request.form:
            return create_record(current_user, request.form['classificator'])

        if 'update' in request.form:
            return update_record(
                request.form['id'],
                request.form['classificator']
            )

        return redirect(url_for('index'))

    def delete_record(record_id):
        """Удаление записи"""
        with db_cursor() as cursor:
            cursor.execute(
                sql.SQL("DELETE FROM s1.t1 WHERE id = %s RETURNING id, classificator"),
                (record_id,)
            )
            if cursor.fetchone():
                flash("Запись успешно удалена", "success")
            else:
                flash("Запись не удалена", "warning")
        return redirect(url_for('index'))

    def create_record(user, classificator):
        """Создание новой записи"""
        with db_cursor() as cursor:
            cursor.execute(
                sql.SQL("""
                    INSERT INTO s1.t1 (insert_user, insert_date, classificator)
                    VALUES (%s, CURRENT_TIMESTAMP, %s)
                """),
                (user, classificator)
            )
            flash("Запись успешно создана", "success")
        return redirect(url_for('index'))

    def update_record(record_id, classificator):
        """Обновление записи"""
        with db_cursor() as cursor:
            cursor.execute(
                sql.SQL("""
                    UPDATE s1.t1
                    SET classificator = %s
                    WHERE id = %s
                    RETURNING id, classificator
                """),
                (classificator, record_id)
            )
            if cursor.fetchone():
                flash("Запись успешно обновлена", "success")
            else:
                flash("Запись не обновлена", "warning")
        return redirect(url_for('index'))

    def fetch_all_records():
        """Получение всех записей"""
        with db_cursor() as cursor:
            cursor.execute("""
                SELECT maclabel, *, extract(epoch from insert_date) as insert_ts
                FROM s1.t1
                ORDER BY insert_date DESC
            """)
            return [format_record(record) for record in cursor.fetchall()]

    def fetch_edit_record(record_id):
        """Получение записи для редактирования"""
        with db_cursor() as cursor:
            cursor.execute(
                sql.SQL("SELECT * FROM s1.t1 WHERE id = %s"),
                (record_id,)
            )
            record = cursor.fetchone()
            if record:
                return {
                    'id': record[0],
                    'insert_user': record[1],
                    'insert_date': record[2].strftime('%d-%m-%Y %H:%M:%S'),
                    'classificator': record[3],
                }
        return None

    def render_empty_page():
        """Рендеринг пустой страницы (при ошибках)"""
        return render_template('index.html',
                               records=[],
                               edit_record=None,
                               messages=get_flashed_messages(with_categories=True))

    return app

Пункт 6#

  • файл index.html должен иметь следующее содержимое:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Демонстрационный пример</title>
    <style>
        :root {
            --primary-color: #0079C1;
            --primary-hover: #00588D;
            --secondary-color: #6c757d;
            --secondary-hover: #5a6268;
            --danger-color: #dc3545;
            --danger-hover: #c82333;
            --success-bg: #d4edda;
            --success-text: #155724;
            --success-border: #c3e6cb;
            --error-bg: #f8d7da;
            --error-text: #721c24;
            --error-border: #f5c6cb;
            --warning-bg: #ffe8cc;
            --warning-text: #cc5500;
            --warning-border: #ffd699;
        }

        body {
            font-family: Arial, sans-serif;
            margin: 20px;
            line-height: 1.6;
        }

        h1, h2 {
            margin: 0 0 20px;
        }

        table {
            width: 100%;
            border-collapse: collapse;
            margin-bottom: 20px;
            border-radius: 8px;
            overflow: hidden;
            box-shadow: 0 0 10px rgba(0,0,0,0.1);
        }

        th, td {
            padding: 12px 15px;
            text-align: left;
            border: 1px solid #ddd;
        }

        th {
            background-color: #f2f2f2;
            font-weight: 600;
        }

        tr:nth-child(even) {
            background-color: #f9f9f9;
        }

        tr:hover {
            background-color: #f1f1f1;
        }

        form {
            margin-bottom: 20px;
            padding: 20px;
            background: #f9f9f9;
            border: 1px solid #ddd;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0,0,0,0.05);
        }

        label {
            display: block;
            margin-bottom: 8px;
            font-weight: 500;
        }

        input {
            margin-bottom: 15px;
            width: 100%;
            max-width: 500px;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 16px;
            box-sizing: border-box;
        }

        input:focus {
            outline: none;
            border-color: var(--primary-color);
            box-shadow: 0 0 5px rgba(0,121,193,0.3);
        }

        .btn {
            padding: 10px 20px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            font-weight: 500;
            text-decoration: none;
            display: inline-block;
            margin-right: 8px;
            transition: background-color 0.3s ease;
            border: none;
            color: white;
        }

        .btn-primary {
            background: var(--primary-color);
        }
        .btn-primary:hover {
            background: var(--primary-hover);
        }

        .btn-secondary {
            background: var(--secondary-color);
        }
        .btn-secondary:hover {
            background: var(--secondary-hover);
        }

        .btn-danger {
            background: var(--danger-color);
        }
        .btn-danger:hover {
            background: var(--danger-hover);
        }

        .actions {
            white-space: nowrap;
        }

        .alert {
            padding: 12px 35px 12px 15px;
            margin-bottom: 15px;
            border-radius: 4px;
            position: relative;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }

        .alert-success {
            background-color: var(--success-bg);
            color: var(--success-text);
            border: 1px solid var(--success-border);
        }

        .alert-error {
            background-color: var(--error-bg);
            color: var(--error-text);
            border: 1px solid var(--error-border);
        }

        .alert-warning {
            background-color: var(--warning-bg);
            color: var(--warning-text);
            border: 1px solid var(--warning-border);
        }

        .close-btn {
            position: absolute;
            right: 10px;
            top: 10px;
            cursor: pointer;
            font-weight: bold;
            font-size: 1.2em;
            line-height: 1;
            color: inherit;
            background: none;
            border: none;
            padding: 0;
        }

        .close-btn:hover {
            color: #000;
        }

        .form-footer {
            margin-top: 15px;
        }

        @media (max-width: 768px) {
            th, td {
                padding: 8px 10px;
            }

            .btn {
                padding: 8px 12px;
                margin-right: 5px;
                font-size: 13px;
            }
        }
    </style>
</head>
<body>
<h1>Демонстрационный пример</h1>

{% for category, message in messages %}
    <div class="alert alert-{{ category }}" role="alert">
        {{ message }}
        <button class="close-btn" aria-label="Закрыть">&times;</button>
    </div>
{% endfor %}

<form method="POST">
    <h2>{{ 'Редактировать запись' if edit_record else 'Добавить новую запись' }}</h2>
    {% if edit_record %}
        <input type="hidden" name="id" value="{{ edit_record.id }}">
    {% endif %}
    <label for="classificator">Наименование:</label>
    <input type="text" id="classificator" name="classificator" required
           placeholder="Введите наименование"
           value="{{ edit_record.classificator if edit_record else '' }}">

    <div class="form-footer">
        <button type="submit" class="btn btn-primary" name="{{ 'update' if edit_record else 'create' }}">
            {{ 'Обновить' if edit_record else 'Создать' }}
        </button>
        {% if edit_record %}
            <a href="{{ url_for('index') }}" class="btn btn-secondary">Отмена</a>
        {% endif %}
    </div>
</form>

<table>
    <thead>
        <tr>
            <th>ID</th>
            <th>MAC</th>
            <th>Наименование</th>
            <th>Создано</th>
            <th>Кем создано</th>
            <th>Действия</th>
        </tr>
    </thead>
    <tbody>
        {% for record in records %}
        <tr>
            <td>{{ record.id }}</td>
            <td>{{ record.maclabel }}</td>
            <td>{{ record.classificator }}</td>
            <td>{{ record.insert_ts }}</td>
            <td>{{ record.insert_user }}</td>
            <td class="actions">
                <a href="{{ url_for('index', edit=record.id) }}" class="btn btn-primary">Редактировать</a>
                <a href="{{ url_for('index', delete=record.id) }}"
                   class="btn btn-danger"
                   onclick="return confirm('Вы уверены, что хотите удалить запись?')">Удалить</a>
            </td>
        </tr>
        {% endfor %}
    </tbody>
</table>

<script>
    // Закрытие алертов при клике
    document.querySelectorAll('.close-btn').forEach(btn => {
        btn.addEventListener('click', function() {
            this.closest('.alert').style.display = 'none';
        });
    });

    // Автоматическое скрытие сообщений через 30 секунд
    setTimeout(() => {
        document.querySelectorAll('.alert').forEach(alert => {
            alert.style.display = 'none';
        });
    }, 30000);
</script>
</body>
</html>

Сценарий проверки работы МРД в веб-приложении#

Важно

Документация дорабатывается по мере развития продуктов Группы Астра и по пожеланиям пользователей.

Ваши пожелания и замечания направляйте на почту docs@astralinux.ru