test repo

This commit is contained in:
Нехорошев Михаил Владимирович committed 2026-08-31 16:18:11 +03:00
1 parent 7c0a85f00f
commit 1eff3aa2ea
27 files changed
+1161 -322

No files matched your search

View File
Whitespace-only changes.
+166
View File
@@ -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
+7
View File
@@ -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")),
]
+7
View File
@@ -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()
+18
View File
@@ -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()
View File
Whitespace-only changes.
+26
View File
@@ -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}"))
+57
View File
@@ -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}"
+61
View File
@@ -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 }} &middot; <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 }} &middot; {{ 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 %}
+13
View File
@@ -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"),
]
+62
View File
@@ -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")
+9
View File
@@ -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
+17
View File
@@ -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: "Панель управления кластером хранения (пример реального ресурса)"