test repo
This commit is contained in:
1 parent
7c0a85f00f
commit
1eff3aa2ea
27 files changed
+1161
-322
No files matched your search
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")
|
||||
Reference in new issue
Block a user