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.
+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")