test repo
This commit is contained in:
1 parent
7c0a85f00f
commit
1eff3aa2ea
27 files changed
+843
-4
No files matched your search
@@ -1,12 +1,12 @@
|
||||
# Портал администрирования: HAProxy + Django + AD → Backend admin panels
|
||||
## Общее описание реализованного проекта
|
||||
## Установка на виртуальную машину (без Docker)
|
||||
|
||||
Единая точка входа для администраторов: логин через Active Directory,
|
||||
персональный список доступных ресурсов по AD-группам, переход в нужную
|
||||
админку по адресу `https://<FQDN>/<adminusergroup>/<resource>/...`.
|
||||
|
||||
Все компоненты устанавливаются как обычные пакеты/systemd-сервисы
|
||||
непосредственно на одну ВМ.
|
||||
непосредственно на одну ВМ (Ubuntu 22.04/24.04 или Debian 12).
|
||||
|
||||
## 1. Состав решения
|
||||
|
||||
@@ -61,8 +61,8 @@ admin-portal-vm/
|
||||
|
||||
## 3. Требования к ВМ
|
||||
|
||||
- РедОС либо АстраЛинукс
|
||||
- 2 vCPU / 4 GB RAM достаточно для старта (PostgreSQL, Redis, Gunicorn,
|
||||
- Ubuntu 22.04/24.04 LTS или Debian 12, root/sudo-доступ
|
||||
- 2 vCPU / 2 GB RAM достаточно для старта (PostgreSQL, Redis, Gunicorn,
|
||||
HAProxy, Nginx — все на одной машине)
|
||||
- Сетевой доступ до контроллера домена AD (LDAP/LDAPS, порт 389/636)
|
||||
- Технический пользователь AD с правом bind + поиска (service account),
|
||||
@@ -224,6 +224,14 @@ cat fullchain.pem privkey.pem | sudo tee /etc/haproxy/certs/fqdn.pem >/dev/null
|
||||
sudo chmod 600 /etc/haproxy/certs/fqdn.pem
|
||||
```
|
||||
|
||||
Для теста — self-signed:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
|
||||
-keyout /tmp/key.pem -out /tmp/cert.pem -subj "/CN=portal.example.com"
|
||||
cat /tmp/cert.pem /tmp/key.pem | sudo tee /etc/haproxy/certs/fqdn.pem >/dev/null
|
||||
```
|
||||
|
||||
Проверить и включить:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru"><head><meta charset="UTF-8"><title>Demo Resource 1</title></head>
|
||||
<body style="font-family:sans-serif;padding:40px;">
|
||||
<h1>Demo Resource 1</h1>
|
||||
<p>Это заглушка реальной админ-панели, доступная по пути /admins-network/resource1/.
|
||||
Если вы видите эту страницу — маршрутизация HAProxy и авторизация через портал работают верно.</p>
|
||||
</body></html>
|
||||
@@ -0,0 +1,13 @@
|
||||
# Копируется в /etc/nginx/sites-available/admin-demo-resource1
|
||||
# и линкуется в /etc/nginx/sites-enabled/ (см. README.md, шаг 8)
|
||||
server {
|
||||
listen 127.0.0.1:8081;
|
||||
server_name _;
|
||||
|
||||
root /var/www/admin-demo-resource1;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru"><head><meta charset="UTF-8"><title>Demo Resource 2</title></head>
|
||||
<body style="font-family:sans-serif;padding:40px;">
|
||||
<h1>Demo Resource 2</h1>
|
||||
<p>Это заглушка реальной админ-панели, доступная по пути /admins-network/resource2/.</p>
|
||||
</body></html>
|
||||
@@ -0,0 +1,13 @@
|
||||
# Копируется в /etc/nginx/sites-available/admin-demo-resource2
|
||||
# и линкуется в /etc/nginx/sites-enabled/ (см. README.md, шаг 8)
|
||||
server {
|
||||
listen 127.0.0.1:8082;
|
||||
server_name _;
|
||||
|
||||
root /var/www/admin-demo-resource2;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Настройки портала администрирования.
|
||||
Все секреты и параметры окружения читаются через python-decouple.
|
||||
При запуске под systemd переменные передаются через EnvironmentFile
|
||||
(/etc/admin-portal/admin-portal.env, см. systemd/admin-portal.service),
|
||||
поэтому наличие .env-файла рядом с manage.py не обязательно, но
|
||||
поддерживается для локальной разработки/отладки на самой ВМ.
|
||||
"""
|
||||
import ldap
|
||||
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType, LDAPGroupQuery
|
||||
from decouple import Config, RepositoryEnv, config as env_config, Csv
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Если рядом лежит .env (локальная разработка) — используем его, иначе
|
||||
# полагаемся на переменные окружения процесса (боевой режим, systemd).
|
||||
_dotenv_path = BASE_DIR / ".env"
|
||||
config = Config(RepositoryEnv(_dotenv_path)) if _dotenv_path.exists() else env_config
|
||||
|
||||
SECRET_KEY = config("DJANGO_SECRET_KEY")
|
||||
DEBUG = config("DJANGO_DEBUG", default=False, cast=bool)
|
||||
ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOSTS", cast=Csv(), default="*")
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"portal",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "portal" / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# База данных — локальный PostgreSQL на этой же ВМ (или указанный хост)
|
||||
# ---------------------------------------------------------------------------
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": config("POSTGRES_DB"),
|
||||
"USER": config("POSTGRES_USER"),
|
||||
"PASSWORD": config("POSTGRES_PASSWORD"),
|
||||
"HOST": config("POSTGRES_HOST", default="127.0.0.1"),
|
||||
"PORT": config("POSTGRES_PORT", default="5432"),
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Кэш и сессии — локальный Redis на этой же ВМ (или указанный хост)
|
||||
# ---------------------------------------------------------------------------
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/1"),
|
||||
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
|
||||
}
|
||||
}
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
SESSION_COOKIE_NAME = "sessionid"
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 8 # 8 часов
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Авторизация через Active Directory (LDAP)
|
||||
# ---------------------------------------------------------------------------
|
||||
AUTH_LDAP_SERVER_URI = config("AD_SERVER_URI")
|
||||
AUTH_LDAP_BIND_DN = config("AD_BIND_DN")
|
||||
AUTH_LDAP_BIND_PASSWORD = config("AD_BIND_PASSWORD")
|
||||
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
config("AD_USER_SEARCH_BASE"),
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(sAMAccountName=%(user)s)",
|
||||
)
|
||||
|
||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
|
||||
config("AD_GROUP_SEARCH_BASE"),
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(objectClass=group)",
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType(name_attr="cn")
|
||||
|
||||
# Атрибуты, которые тянем из AD в модель User при первом логине/обновлении
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
"email": "mail",
|
||||
}
|
||||
|
||||
# В Django группы AD не мапятся на Django Group автоматически (нам это не нужно —
|
||||
# группы читаются напрямую из LDAP через .ldap_user.group_names при построении
|
||||
# каталога ресурсов, см. portal/services.py), но кэшируем сам LDAP-объект юзера.
|
||||
AUTH_LDAP_ALWAYS_UPDATE_USER = True
|
||||
AUTH_LDAP_CACHE_TIMEOUT = config("AD_GROUP_CACHE_TTL_SECONDS", default=600, cast=int)
|
||||
|
||||
AUTH_LDAP_CONNECTION_OPTIONS = {
|
||||
ldap.OPT_REFERRALS: 0,
|
||||
ldap.OPT_NETWORK_TIMEOUT: 5,
|
||||
}
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django_auth_ldap.backend.LDAPBackend",
|
||||
"django.contrib.auth.backends.ModelBackend", # для локального суперпользователя
|
||||
]
|
||||
|
||||
LOGIN_URL = "/login/"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
LOGOUT_REDIRECT_URL = "/login/"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Токен для backend admin panels (см. docs/architecture.md, раздел 6)
|
||||
# ---------------------------------------------------------------------------
|
||||
RESOURCE_TOKEN_SECRET = config("RESOURCE_TOKEN_SECRET")
|
||||
RESOURCE_TOKEN_TTL_SECONDS = config("RESOURCE_TOKEN_TTL_SECONDS", default=60, cast=int)
|
||||
RESOURCE_TOKEN_COOKIE_NAME = "resource_token"
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LANGUAGE_CODE = "ru-ru"
|
||||
TIME_ZONE = "Europe/Moscow"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("django-admin/", admin.site.urls),
|
||||
path("", include("portal.urls")),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Не удалось импортировать Django. Убедитесь, что окружение активно."
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Whitespace-only changes.
@@ -0,0 +1,26 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import AccessLog, Resource, ResourceGroup
|
||||
|
||||
|
||||
@admin.register(ResourceGroup)
|
||||
class ResourceGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ("display_name", "ad_group_name")
|
||||
search_fields = ("display_name", "ad_group_name")
|
||||
|
||||
|
||||
@admin.register(Resource)
|
||||
class ResourceAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "group", "slug", "is_active")
|
||||
list_filter = ("group", "is_active")
|
||||
search_fields = ("name", "slug")
|
||||
|
||||
|
||||
@admin.register(AccessLog)
|
||||
class AccessLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "resource", "ip_address", "created_at")
|
||||
list_filter = ("resource",)
|
||||
readonly_fields = [f.name for f in AccessLog._meta.fields]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Позволяет декларативно описать группы/ресурсы в YAML-файле и синхронизировать
|
||||
с базой одной командой — удобно для версионирования конфигурации в git.
|
||||
|
||||
Использование:
|
||||
python manage.py sync_resources resources.yaml
|
||||
|
||||
Формат resources.yaml:
|
||||
|
||||
groups:
|
||||
- ad_group_name: admins-network
|
||||
display_name: "Сетевые администраторы"
|
||||
resources:
|
||||
- slug: resource1
|
||||
name: "Панель управления коммутаторами"
|
||||
description: "Cisco DNA Center"
|
||||
- slug: resource2
|
||||
name: "Мониторинг сети"
|
||||
description: "LibreNMS"
|
||||
"""
|
||||
import yaml
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from portal.models import Resource, ResourceGroup
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Синхронизирует ResourceGroup/Resource с декларативным YAML-файлом"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("yaml_path", type=str)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
path = options["yaml_path"]
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
except FileNotFoundError as exc:
|
||||
raise CommandError(f"Файл не найден: {path}") from exc
|
||||
|
||||
for group_data in data.get("groups", []):
|
||||
group, _ = ResourceGroup.objects.update_or_create(
|
||||
ad_group_name=group_data["ad_group_name"],
|
||||
defaults={"display_name": group_data["display_name"]},
|
||||
)
|
||||
for res_data in group_data.get("resources", []):
|
||||
Resource.objects.update_or_create(
|
||||
group=group,
|
||||
slug=res_data["slug"],
|
||||
defaults={
|
||||
"name": res_data["name"],
|
||||
"description": res_data.get("description", ""),
|
||||
"is_active": res_data.get("is_active", True),
|
||||
},
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Синхронизирована группа: {group.ad_group_name}"))
|
||||
@@ -0,0 +1,57 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ResourceGroup(models.Model):
|
||||
"""Привязка к реальной группе AD (adminusergroup из пути URL)."""
|
||||
|
||||
ad_group_name = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Точное имя группы AD (cn), используется как сегмент пути в URL",
|
||||
)
|
||||
display_name = models.CharField(max_length=200)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Группа ресурсов (AD-группа)"
|
||||
verbose_name_plural = "Группы ресурсов (AD-группы)"
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
|
||||
|
||||
class Resource(models.Model):
|
||||
"""Конкретный административный ресурс внутри группы."""
|
||||
|
||||
group = models.ForeignKey(ResourceGroup, on_delete=models.CASCADE, related_name="resources")
|
||||
slug = models.SlugField(max_length=100, help_text="Сегмент пути, например 'resource1'")
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("group", "slug")
|
||||
verbose_name = "Ресурс"
|
||||
verbose_name_plural = "Ресурсы"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.group.ad_group_name}/{self.slug}"
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return f"/{self.group.ad_group_name}/{self.slug}/"
|
||||
|
||||
|
||||
class AccessLog(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
|
||||
resource = models.ForeignKey(Resource, on_delete=models.SET_NULL, null=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Запись аудита доступа"
|
||||
verbose_name_plural = "Аудит доступа"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} -> {self.resource} @ {self.created_at:%Y-%m-%d %H:%M}"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Вспомогательная логика портала:
|
||||
- получение списка AD-групп текущего пользователя (из объекта django-auth-ldap)
|
||||
- подбор доступных Resource по этим группам
|
||||
- выпуск короткоживущего подписанного токена для backend admin panel
|
||||
"""
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
|
||||
from .models import Resource, ResourceGroup
|
||||
|
||||
|
||||
def get_user_ad_groups(user) -> set[str]:
|
||||
"""
|
||||
django-auth-ldap прикрепляет к user объект `ldap_user` с атрибутом group_names
|
||||
(набор cn всех групп, в которых состоит пользователь в AD).
|
||||
Для локального суперпользователя (не через LDAP) атрибута не будет —
|
||||
возвращаем пустой набор, дальше он видит только ресурсы, явно открытые ModelBackend-группам.
|
||||
"""
|
||||
ldap_user = getattr(user, "ldap_user", None)
|
||||
if ldap_user is None:
|
||||
return set()
|
||||
return set(ldap_user.group_names)
|
||||
|
||||
|
||||
def get_available_resources(user) -> Iterable[Resource]:
|
||||
if user.is_superuser:
|
||||
return Resource.objects.filter(is_active=True).select_related("group")
|
||||
|
||||
ad_groups = get_user_ad_groups(user)
|
||||
if not ad_groups:
|
||||
return Resource.objects.none()
|
||||
|
||||
group_ids = ResourceGroup.objects.filter(ad_group_name__in=ad_groups).values_list("id", flat=True)
|
||||
return Resource.objects.filter(group_id__in=group_ids, is_active=True).select_related("group")
|
||||
|
||||
|
||||
def user_can_access_resource(user, resource: Resource) -> bool:
|
||||
if user.is_superuser:
|
||||
return True
|
||||
return resource.group.ad_group_name in get_user_ad_groups(user)
|
||||
|
||||
|
||||
def issue_resource_token(user, resource: Resource) -> str:
|
||||
"""
|
||||
Короткоживущий JWT, который backend admin panel может провалидировать
|
||||
тем же RESOURCE_TOKEN_SECRET (см. docs/architecture.md, раздел 6).
|
||||
"""
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": user.username,
|
||||
"email": getattr(user, "email", ""),
|
||||
"group": resource.group.ad_group_name,
|
||||
"resource": resource.slug,
|
||||
"iat": now,
|
||||
"exp": now + settings.RESOURCE_TOKEN_TTL_SECONDS,
|
||||
}
|
||||
return jwt.encode(payload, settings.RESOURCE_TOKEN_SECRET, algorithm="HS256")
|
||||
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Портал администрирования{% endblock %}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background:#f4f5f7; margin:0; }
|
||||
header { background:#1f2937; color:#fff; padding:16px 32px; display:flex; justify-content:space-between; align-items:center; }
|
||||
header a { color:#fff; text-decoration:none; font-size:14px; }
|
||||
main { max-width:960px; margin:32px auto; padding:0 16px; }
|
||||
.card { background:#fff; border-radius:10px; padding:20px; margin-bottom:14px; box-shadow:0 1px 3px rgba(0,0,0,.08); display:flex; justify-content:space-between; align-items:center; }
|
||||
.card h3 { margin:0 0 4px; font-size:16px; }
|
||||
.card p { margin:0; color:#6b7280; font-size:13px; }
|
||||
.btn { background:#2563eb; color:#fff; padding:8px 16px; border-radius:6px; text-decoration:none; font-size:13px; }
|
||||
.empty { color:#6b7280; text-align:center; margin-top:64px; }
|
||||
form.login { max-width:340px; margin:80px auto; background:#fff; padding:32px; border-radius:10px; box-shadow:0 1px 3px rgba(0,0,0,.08); }
|
||||
form.login input { width:100%; padding:10px; margin-bottom:12px; border:1px solid #d1d5db; border-radius:6px; box-sizing:border-box; }
|
||||
form.login button { width:100%; padding:10px; background:#2563eb; color:#fff; border:0; border-radius:6px; cursor:pointer; }
|
||||
.errors { color:#dc2626; font-size:13px; margin-bottom:12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if user.is_authenticated %}
|
||||
<header>
|
||||
<span>Портал администрирования</span>
|
||||
<span>{{ user.get_username }} · <a href="{% url 'logout' %}">Выйти</a></span>
|
||||
</header>
|
||||
{% endif %}
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "portal/base.html" %}
|
||||
{% block title %}Мои ресурсы{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Доступные ресурсы</h2>
|
||||
|
||||
{% if resources %}
|
||||
{% for resource in resources %}
|
||||
<div class="card">
|
||||
<div>
|
||||
<h3>{{ resource.name }}</h3>
|
||||
<p>{{ resource.group.display_name }} · {{ resource.description }}</p>
|
||||
</div>
|
||||
<a class="btn" href="{{ resource.path }}">Открыть</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty">Вашей AD-группе не назначено ни одного ресурса. Обратитесь к администратору портала.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "portal/base.html" %}
|
||||
{% block title %}Вход — Портал администрирования{% endblock %}
|
||||
{% block content %}
|
||||
<form class="login" method="post">
|
||||
{% csrf_token %}
|
||||
<h2>Вход через AD</h2>
|
||||
{% if form.errors %}
|
||||
<div class="errors">Неверный логин или пароль.</div>
|
||||
{% endif %}
|
||||
<input type="text" name="username" placeholder="Логин AD" autofocus required>
|
||||
<input type="password" name="password" placeholder="Пароль" required>
|
||||
{% if request.GET.next %}
|
||||
<input type="hidden" name="next" value="{{ request.GET.next }}">
|
||||
{% endif %}
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("login/", views.PortalLoginView.as_view(), name="login"),
|
||||
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||
path("healthz", views.healthz, name="healthz"),
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
# Должен идти последним — самый общий шаблон пути ресурса
|
||||
path("<slug:group_slug>/<slug:resource_slug>/", views.open_resource, name="open_resource"),
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
from .models import AccessLog, Resource
|
||||
from .services import get_available_resources, issue_resource_token, user_can_access_resource
|
||||
|
||||
|
||||
class PortalLoginView(auth_views.LoginView):
|
||||
template_name = "portal/login.html"
|
||||
redirect_authenticated_user = True
|
||||
|
||||
def get_success_url(self):
|
||||
# Если пользователь пришёл по прямой ссылке на ресурс (?next=/group/resource/),
|
||||
# HAProxy направил его на портал для логина — после успеха возвращаем туда же.
|
||||
next_url = self.request.POST.get("next") or self.request.GET.get("next")
|
||||
return next_url or reverse_lazy("dashboard")
|
||||
|
||||
|
||||
@login_required
|
||||
def dashboard(request):
|
||||
resources = get_available_resources(request.user)
|
||||
return render(request, "portal/dashboard.html", {"resources": resources})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def open_resource(request, group_slug: str, resource_slug: str):
|
||||
resource = get_object_or_404(
|
||||
Resource, group__ad_group_name=group_slug, slug=resource_slug, is_active=True
|
||||
)
|
||||
|
||||
if not user_can_access_resource(request.user, resource):
|
||||
return HttpResponseForbidden("Доступ к этому ресурсу не предоставлен вашей AD-группе.")
|
||||
|
||||
AccessLog.objects.create(
|
||||
user=request.user,
|
||||
resource=resource,
|
||||
ip_address=request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
|
||||
token = issue_resource_token(request.user, resource)
|
||||
|
||||
response = HttpResponseRedirect(resource.path)
|
||||
response.set_cookie(
|
||||
key="resource_token",
|
||||
value=token,
|
||||
max_age=60, # HAProxy снимет cookie на первом же запросе к backend'у
|
||||
path=resource.path, # cookie видна только на пути конкретного ресурса
|
||||
secure=True,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@require_GET
|
||||
def healthz(request):
|
||||
return HttpResponse("ok")
|
||||
@@ -0,0 +1,9 @@
|
||||
Django==5.0.9
|
||||
django-auth-ldap==4.8.0
|
||||
psycopg2-binary==2.9.9
|
||||
django-redis==5.4.0
|
||||
gunicorn==22.0.0
|
||||
python-decouple==3.8
|
||||
PyJWT==2.9.0
|
||||
PyYAML==6.0.2
|
||||
whitenoise==6.7.0
|
||||
@@ -0,0 +1,17 @@
|
||||
groups:
|
||||
- ad_group_name: admins-network
|
||||
display_name: "Сетевые администраторы"
|
||||
resources:
|
||||
- slug: resource1
|
||||
name: "Демо-ресурс 1"
|
||||
description: "Демонстрационная админка для проверки маршрутизации"
|
||||
- slug: resource2
|
||||
name: "Демо-ресурс 2"
|
||||
description: "Демонстрационная админка для проверки маршрутизации"
|
||||
|
||||
- ad_group_name: admins-storage
|
||||
display_name: "Администраторы систем хранения"
|
||||
resources:
|
||||
- slug: ceph-dashboard
|
||||
name: "Ceph Dashboard"
|
||||
description: "Панель управления кластером хранения (пример реального ресурса)"
|
||||
@@ -0,0 +1,224 @@
|
||||
# Архитектура решения: портал администрирования HAProxy + Django + AD
|
||||
## Вариант развёртывания: одна ВМ, без контейнеризации
|
||||
|
||||
## 1. Назначение
|
||||
|
||||
Единая точка входа для администраторов инфраструктуры: пользователь логинится
|
||||
своей доменной учётной записью, видит только те административные ресурсы,
|
||||
которые открыты его AD-группе, и одним кликом переходит в конкретную
|
||||
систему по адресу `https://<FQDN>/<adminusergroup>/<resource>/...`, не
|
||||
запоминая адреса и не имея прямого доступа к внутренним админкам, минуя
|
||||
портал.
|
||||
|
||||
Этот документ описывает вариант развёртывания **без Docker** — все
|
||||
компоненты ставятся как обычные пакеты дистрибутива и управляются через
|
||||
`systemd` на одной виртуальной машине. Это упрощает эксплуатацию в
|
||||
организациях, где контейнеризация не принята, и снимает зависимость от
|
||||
Docker-инфраструктуры.
|
||||
|
||||
## 2. Компоненты и то, как они запущены на ВМ
|
||||
|
||||
| Компонент | Как установлен | Как запущен | Слушает |
|
||||
|---|---|---|---|
|
||||
| **HAProxy** | пакет `haproxy` из репозитория дистрибутива | systemd-юнит `haproxy.service` (штатный, из пакета) | `0.0.0.0:80`, `0.0.0.0:443`, `127.0.0.1:8404` (статистика) |
|
||||
| **Django frontend (portal)** | Python venv в `/opt/admin-portal/venv`, код в `/opt/admin-portal/django_app` | Gunicorn под собственным systemd-юнитом `admin-portal.service`, от имени системного пользователя `adminportal` | `127.0.0.1:8000` |
|
||||
| **Active Directory** | внешняя система, ничего не устанавливаем | — | LDAP/LDAPS (389/636) на контроллере домена |
|
||||
| **PostgreSQL** | пакет `postgresql` | systemd-юнит `postgresql.service` (штатный) | `127.0.0.1:5432` |
|
||||
| **Redis** | пакет `redis-server` | systemd-юнит `redis-server.service` (штатный) | `127.0.0.1:6379` |
|
||||
| **Backend admin panels** | в решении — 2 демо-заглушки на Nginx; в проде — реальные системы | демо: `nginx.service`, отдельные `server`-блоки на `127.0.0.1:8081/8082`; прод: на других хостах сети | только внутри периметра, не публикуются наружу |
|
||||
|
||||
Все процессы, кроме HAProxy, слушают исключительно `127.0.0.1` — наружу
|
||||
из ВМ открыты только порты 80/443 (см. `ufw` в README, шаг 11). Это заменяет
|
||||
собой изоляцию, которую в контейнерном варианте давала Docker-сеть.
|
||||
|
||||
## 3. Общая схема
|
||||
|
||||
```
|
||||
HTTPS
|
||||
Браузер администратора ────────────────► HAProxy :443 (systemd: haproxy)
|
||||
│
|
||||
TLS termination, ACL-роутинг по path, HSTS, rate-limit
|
||||
│
|
||||
┌──────────────────────────────┼───────────────────────────────┐
|
||||
│ путь НЕ ресурсный, │ путь ресурсный + нет │ путь ресурсный + есть
|
||||
│ или /login /static/... │ resource_token │ resource_token
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────┐ ┌──────────────────────┐
|
||||
│ Gunicorn 127.0.0.1:8000 (systemd: │ │ Nginx 127.0.0.1:808x │
|
||||
│ admin-portal.service) │ │ (демо) или реальная │
|
||||
│ - django-auth-ldap -> AD bind │ │ админка на др. хосте │
|
||||
│ - каталог ресурсов из PostgreSQL │ └──────────────────────┘
|
||||
│ - сессии в Redis │ ▲
|
||||
│ - выпуск JWT для перехода на ресурс │──── redirect + cookie ───┘
|
||||
└─────────────────────────────────────────┘ resource_token
|
||||
│
|
||||
▼
|
||||
PostgreSQL 127.0.0.1:5432 (ResourceGroup, Resource, AccessLog)
|
||||
Redis 127.0.0.1:6379 (сессии Django, кэш групп LDAP)
|
||||
```
|
||||
|
||||
## 4. Поток аутентификации
|
||||
|
||||
1. Браузер отправляет запрос на `https://fqdn/`.
|
||||
2. HAProxy (см. `haproxy.cfg`) не находит cookie `resource_token` на
|
||||
нересурсном пути → направляет на `backend be_django` (Gunicorn,
|
||||
`127.0.0.1:8000`).
|
||||
3. Django рендерит форму логина. Пользователь вводит `sAMAccountName` + пароль.
|
||||
4. `django-auth-ldap`:
|
||||
- выполняет bind сервисным аккаунтом (`AUTH_LDAP_BIND_DN` из
|
||||
`/etc/admin-portal/admin-portal.env`),
|
||||
- ищет пользователя по `AUTH_LDAP_USER_SEARCH` (`sAMAccountName=%(user)s`),
|
||||
- выполняет повторный bind уже от имени найденного DN с введённым паролем —
|
||||
это и есть проверка пароля,
|
||||
- при успехе подтягивает атрибуты (`givenName`, `sn`, `mail`) и список
|
||||
групп (`AUTH_LDAP_GROUP_SEARCH`), кладёт их в `request.user.ldap_user`.
|
||||
5. Django создаёт сессию в Redis (локальный, `127.0.0.1:6379`), выставляет
|
||||
cookie `sessionid`.
|
||||
6. Пользователь видит дашборд со списком `Resource`, чьи
|
||||
`ResourceGroup.ad_group_name` входят в набор групп пользователя из AD.
|
||||
|
||||
## 5. Поток перехода на ресурс
|
||||
|
||||
1. Клик по карточке ресурса → `GET /{group}/{resource}/`.
|
||||
2. Первый переход всегда обрабатывается Django (`open_resource` view),
|
||||
поскольку у HAProxy ещё нет cookie `resource_token` для этого пути —
|
||||
маршрут `is_resource_path !has_resource_token` в `haproxy.cfg` уводит
|
||||
запрос на `be_django`.
|
||||
3. `open_resource` view:
|
||||
- проверяет, что ресурс существует, активен и открыт AD-группе
|
||||
пользователя,
|
||||
- пишет запись в `AccessLog` (кто, когда, какой ресурс, IP — таблица в
|
||||
PostgreSQL),
|
||||
- выпускает JWT (`RESOURCE_TOKEN_SECRET`, TTL по умолчанию 60 секунд),
|
||||
- кладёт токен в cookie `resource_token`, ограниченную `path` конкретного
|
||||
ресурса,
|
||||
- делает 302 редирект на тот же путь.
|
||||
4. HAProxy на повторном запросе видит `cook(resource_token) -m found` →
|
||||
маршрутизация по `group_map.cfg` на нужный `backend` (демо-Nginx на
|
||||
`127.0.0.1:808x` или реальная система на другом хосте). Перед
|
||||
проксированием HAProxy снимает cookie в заголовок `X-Resource-Token` и
|
||||
удаляет исходный `Cookie` — backend никогда не видит `sessionid` портала.
|
||||
5. Backend admin panel (при наличии соответствующей интеграции) валидирует
|
||||
JWT тем же `RESOURCE_TOKEN_SECRET` и на основании `sub`
|
||||
логинит/находит пользователя в своей системе.
|
||||
|
||||
### Важно: токен — это разовый хэндшейк, а не сессия внутри backend'а
|
||||
|
||||
`resource_token` живёт всего 60 секунд и привязан к `path` конкретного
|
||||
ресурса. Backend admin panel, приняв и провалидировав токен один раз,
|
||||
должен завести собственную сессию для дальнейшей работы пользователя — так
|
||||
делает подавляющее большинство систем при интеграции с внешним SSO. Если
|
||||
полагаться на `resource_token` при каждом запросе, через 60 секунд любой
|
||||
запрос без свежего токена уйдёт обратно в Django на переоформление. При
|
||||
необходимости TTL можно увеличить через `RESOURCE_TOKEN_TTL_SECONDS` в
|
||||
`.env`, но это не заменяет полноценную сессию backend'а.
|
||||
|
||||
## 6. Варианты передачи identity в backend (сравнение)
|
||||
|
||||
| Вариант | Как работает | Когда применять |
|
||||
|---|---|---|
|
||||
| **A. Доверенный заголовок** (`X-Remote-User`) | HAProxy/Django прокидывает имя пользователя как есть, backend доверяет заголовку | Полностью изолированный сегмент сети, backend не имеет других входов |
|
||||
| **B. Подписанный JWT** (реализовано в этом решении) | Django выпускает короткоживущий токен, backend проверяет подпись | Рекомендуемый вариант по умолчанию |
|
||||
| **C. Полноценный SSO (SAML/OIDC)** | Backend сам является Service Provider, IdP — AD FS/Keycloak | Backend уже умеет SAML/OIDC "из коробки" (Grafana, GitLab и т.п.) |
|
||||
|
||||
## 7. Модель данных
|
||||
|
||||
```
|
||||
ResourceGroup
|
||||
ad_group_name (slug, unique) -- реальное имя группы AD, сегмент пути
|
||||
display_name (str)
|
||||
|
||||
Resource
|
||||
group -> ResourceGroup
|
||||
slug (str) -- второй сегмент пути
|
||||
name (str)
|
||||
description (text)
|
||||
is_active (bool)
|
||||
unique(group, slug)
|
||||
|
||||
AccessLog
|
||||
user -> auth.User (nullable)
|
||||
resource -> Resource (nullable)
|
||||
ip_address (inet)
|
||||
created_at (datetime, auto)
|
||||
```
|
||||
|
||||
Хранится в PostgreSQL, установленном локально на ВМ (`/var/lib/postgresql`).
|
||||
|
||||
## 8. Наполнение и сопровождение каталога ресурсов
|
||||
|
||||
1. **Django admin** (`https://<FQDN>/django-admin/`) — для точечных правок.
|
||||
2. **Декларативный YAML + management-команда** `sync_resources`:
|
||||
```bash
|
||||
sudo -u adminportal bash -c \
|
||||
'set -a; source /etc/admin-portal/admin-portal.env; set +a; \
|
||||
/opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py \
|
||||
sync_resources /opt/admin-portal/django_app/resources.example.yaml'
|
||||
```
|
||||
Команда идемпотентна, можно хранить YAML в git и гонять через CI/CD по SSH.
|
||||
|
||||
## 9. Безопасность
|
||||
|
||||
- TLS 1.2+/1.3, HSTS, современные cipher suites — терминируются на HAProxy;
|
||||
все внутренние компоненты общаются по `127.0.0.1` в открытом виде (трафик
|
||||
не покидает ВМ) либо по внутренней сети без выхода наружу.
|
||||
- Пароли пользователей не хранятся на портале — только bind-запрос к AD.
|
||||
- CSRF-защита на всех формах Django.
|
||||
- Сессионные cookie: `Secure`, `HttpOnly`, `SameSite=Lax`.
|
||||
- `resource_token` — отдельный секрет, TTL 60 секунд, `path`-scope,
|
||||
`HttpOnly`.
|
||||
- HAProxy снимает cookie сессии портала перед проксированием на backend.
|
||||
- Аудит: каждый переход на ресурс логируется в `AccessLog`.
|
||||
- Rate-limit на `/login/` через `stick-table` в HAProxy.
|
||||
- Файл `/etc/admin-portal/admin-portal.env` с секретами — права `640`,
|
||||
владелец `root:adminportal`, читать может только процесс приложения.
|
||||
- systemd-юнит `admin-portal.service` запущен от непривилегированного
|
||||
пользователя `adminportal` (`nologin`), с `NoNewPrivileges`, `PrivateTmp`,
|
||||
`ProtectSystem=full`.
|
||||
- `ufw` открывает наружу только 80/443; PostgreSQL, Redis, Gunicorn и
|
||||
демо-Nginx слушают исключительно loopback — недоступны из сети в принципе,
|
||||
даже без дополнительных правил фаервола.
|
||||
- Backend admin panels в проде должны быть в отдельном сетевом сегменте без
|
||||
прямого доступа из интернета — HAProxy как единственная точка входа
|
||||
снаружи.
|
||||
|
||||
## 10. Отказоустойчивость и масштабирование без Docker
|
||||
|
||||
Однонодовый вариант (этот пакет) подходит для старта и для организаций
|
||||
среднего размера. Пути роста при увеличении нагрузки:
|
||||
|
||||
- **HAProxy**: поднять второй экземпляр HAProxy на второй ВМ + `keepalived`
|
||||
(VRRP) для плавающего IP, либо использовать внешний L4-балансировщик перед
|
||||
парой ВМ с HAProxy.
|
||||
- **Django/Gunicorn**: поскольку сессии и кэш групп уже вынесены в Redis,
|
||||
можно поднять `admin-portal.service` на второй ВМ тем же способом
|
||||
(шаги 3–8 из README) и добавить в `backend be_django` вторую строку
|
||||
`server django2 10.0.0.11:8000 check` — балансировка без изменения
|
||||
остальной конфигурации.
|
||||
- **PostgreSQL**: для прод-контура — вынести на отдельную ВМ с репликой
|
||||
(потоковая репликация PostgreSQL), поменять `POSTGRES_HOST` в `.env` на
|
||||
обеих Django-нодах.
|
||||
- **Redis**: при росте нагрузки — Redis Sentinel (2–3 ВМ), либо вынос на
|
||||
отдельную ВМ без кластеризации, если нагрузка умеренная (сессии — лёгкая
|
||||
нагрузка для Redis).
|
||||
- **LDAP**: кэш групп на `AUTH_LDAP_CACHE_TIMEOUT` (по умолчанию 600 секунд)
|
||||
снижает нагрузку на контроллеры домена независимо от количества Django-нод.
|
||||
|
||||
## 11. Ограничения и что осознанно вынесено за рамки
|
||||
|
||||
- Установочный скрипт (`scripts/install.sh`) рассчитан на Ubuntu/Debian
|
||||
(`apt`); для RHEL/CentOS/Alma потребуется адаптировать имена пакетов
|
||||
(`dnf install haproxy nginx postgresql-server redis python3 ...`) и путь
|
||||
до `python3-venv` — сама архитектура и код Django не меняются.
|
||||
- TLS-сертификат от внешнего центра сертификации подкладывается вручную —
|
||||
автоматический выпуск/продление (например, через `certbot`) в рамках
|
||||
данного пакета не настраивается, так как процедура сильно зависит от
|
||||
того, публичный это FQDN или внутренний корпоративный CA.
|
||||
- Мультиузловая отказоустойчивость (второй HAProxy, реплика PostgreSQL,
|
||||
Redis Sentinel) описана на уровне принципов в разделе 10, но конкретные
|
||||
Ansible/Terraform-плейбуки под неё не входят в этот пакет — количество ВМ
|
||||
и топология сильно зависят от инфраструктуры конкретной организации.
|
||||
- Интеграция конкретных backend admin panel с валидацией JWT специфична для
|
||||
каждой системы; в решении заложен универсальный контракт (заголовок
|
||||
`X-Resource-Token`, алгоритм `HS256`, общий секрет `RESOURCE_TOKEN_SECRET`),
|
||||
но код валидации на стороне внутренних систем — задача их владельцев.
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Reference in new issue
Block a user