АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Plane OIDC mapping для существующего пользователя

This commit is contained in:
DCCONSTRUCTIONS 2026-05-04 13:01:21 +03:00
parent 119d503d96
commit 561d1eeef5
10 changed files with 1244 additions and 1 deletions

View File

@ -51,6 +51,16 @@ x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost:8090}
DEBUG: ${DEBUG:-0}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8090}
ENABLE_SIGNUP: ${ENABLE_SIGNUP:-0}
ENABLE_EMAIL_PASSWORD: ${ENABLE_EMAIL_PASSWORD:-1}
ENABLE_MAGIC_LINK_LOGIN: ${ENABLE_MAGIC_LINK_LOGIN:-0}
PLANE_OIDC_ISSUER: ${PLANE_OIDC_ISSUER:-}
PLANE_OIDC_CLIENT_ID: ${PLANE_OIDC_CLIENT_ID:-}
PLANE_OIDC_CLIENT_SECRET: ${PLANE_OIDC_CLIENT_SECRET:-}
PLANE_OIDC_REDIRECT_URI: ${PLANE_OIDC_REDIRECT_URI:-}
PLANE_OIDC_SCOPE: ${PLANE_OIDC_SCOPE:-openid email profile groups}
PLANE_OIDC_REQUIRED_GROUPS: ${PLANE_OIDC_REQUIRED_GROUPS:-nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user}
PLANE_OIDC_AUTO_LINK_EMAIL: ${PLANE_OIDC_AUTO_LINK_EMAIL:-0}
GUNICORN_WORKERS: 1
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-}
@ -113,6 +123,8 @@ services:
api:
image: nodedc/plane-backend:local
command: ./bin/docker-entrypoint-api.sh
extra_hosts:
- "auth.local.nodedc:host-gateway"
deploy:
replicas: ${API_REPLICAS:-1}
restart_policy:
@ -129,6 +141,8 @@ services:
worker:
image: nodedc/plane-backend:local
command: ./bin/docker-entrypoint-worker.sh
extra_hosts:
- "auth.local.nodedc:host-gateway"
deploy:
replicas: ${WORKER_REPLICAS:-1}
restart_policy:
@ -146,6 +160,8 @@ services:
beat-worker:
image: nodedc/plane-backend:local
command: ./bin/docker-entrypoint-beat.sh
extra_hosts:
- "auth.local.nodedc:host-gateway"
deploy:
replicas: ${BEAT_WORKER_REPLICAS:-1}
restart_policy:
@ -163,6 +179,8 @@ services:
migrator:
image: nodedc/plane-backend:local
command: ./bin/docker-entrypoint-migrator.sh
extra_hosts:
- "auth.local.nodedc:host-gateway"
deploy:
replicas: 1
restart_policy:

View File

@ -21,6 +21,8 @@ from .views import (
MagicGenerateEndpoint,
MagicSignInEndpoint,
MagicSignUpEndpoint,
NodeDCOIDCCallbackEndpoint,
NodeDCOIDCInitiateEndpoint,
SignInAuthEndpoint,
SignOutAuthEndpoint,
SignUpAuthEndpoint,
@ -50,6 +52,9 @@ urlpatterns = [
# credentials
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
path("oidc/login/", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-oidc-login"),
path("oidc/callback/", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback"),
path("oidc/callback", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback-no-slash"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
# signout

View File

@ -12,6 +12,7 @@ from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint
from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint
from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint
from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint
from .app.oidc import NodeDCOIDCCallbackEndpoint, NodeDCOIDCInitiateEndpoint
from .app.signout import SignOutAuthEndpoint

View File

@ -0,0 +1,214 @@
import base64
import hashlib
import os
import secrets
from urllib.parse import urlencode
import jwt
import requests
from django.http import HttpResponseRedirect
from django.utils import timezone
from django.views import View
from plane.authentication.utils.host import base_host
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.db.models import ExternalIdentityLink, User
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
OIDC_SESSION_KEY = "nodedc_oidc"
OIDC_PROVIDER = "authentik"
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
class NodeDCOIDCInitiateEndpoint(View):
def get(self, request):
config = get_oidc_config()
next_path = validate_next_path(request.GET.get("next_path", ""))
discovery = load_discovery(config["issuer"])
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().rstrip("=")
request.session[OIDC_SESSION_KEY] = {
"state": state,
"nonce": nonce,
"code_verifier": code_verifier,
"next_path": next_path,
}
request.session.save()
params = {
"response_type": "code",
"client_id": config["client_id"],
"redirect_uri": config["redirect_uri"],
"scope": config["scope"],
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
if request.GET.get("prompt") == "login":
params["prompt"] = "login"
return HttpResponseRedirect(f"{discovery['authorization_endpoint']}?{urlencode(params)}")
class NodeDCOIDCCallbackEndpoint(View):
def get(self, request):
config = get_oidc_config()
oidc_session = request.session.get(OIDC_SESSION_KEY) or {}
next_path = oidc_session.get("next_path", "")
base_url = base_host(request=request, is_app=True)
if request.GET.get("error"):
return oidc_error_redirect(base_url, next_path, "oidc_provider_error")
state = request.GET.get("state")
code = request.GET.get("code")
if not state or state != oidc_session.get("state") or not code:
return oidc_error_redirect(base_url, next_path, "oidc_state_failed")
discovery = load_discovery(config["issuer"])
token_set = exchange_code(discovery, config, code, oidc_session.get("code_verifier"))
claims = verify_id_token(discovery, config, token_set["id_token"], oidc_session.get("nonce"))
groups = normalize_groups(claims.get("groups"))
if not has_required_group(groups):
return oidc_error_redirect(base_url, next_path, "oidc_access_denied")
user = resolve_linked_user(claims=claims, groups=groups, auto_link=config["auto_link_email"])
if user is None or not user.is_active:
return oidc_error_redirect(base_url, next_path, "oidc_user_not_linked")
request.session.pop(OIDC_SESSION_KEY, None)
user_login(request=request, user=user, is_app=True)
path = next_path or get_redirection_path(user=user)
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
def get_oidc_config():
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()
client_secret = os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
redirect_uri = os.environ.get("PLANE_OIDC_REDIRECT_URI", "").strip()
if not issuer or not client_id or not client_secret or not redirect_uri:
raise RuntimeError("Plane OIDC is not configured")
return {
"issuer": issuer.rstrip("/") + "/",
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"scope": os.environ.get("PLANE_OIDC_SCOPE", "openid email profile groups"),
"auto_link_email": os.environ.get("PLANE_OIDC_AUTO_LINK_EMAIL", "0") == "1",
}
def load_discovery(issuer):
response = requests.get(f"{issuer}.well-known/openid-configuration", timeout=10)
response.raise_for_status()
return response.json()
def exchange_code(discovery, config, code, code_verifier):
response = requests.post(
discovery["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": config["redirect_uri"],
"code_verifier": code_verifier,
},
auth=(config["client_id"], config["client_secret"]),
timeout=10,
)
response.raise_for_status()
token_set = response.json()
if not token_set.get("id_token"):
raise RuntimeError("OIDC token response does not contain id_token")
return token_set
def verify_id_token(discovery, config, id_token, nonce):
jwks_client = jwt.PyJWKClient(discovery["jwks_uri"])
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
claims = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
audience=config["client_id"],
issuer=discovery.get("issuer", config["issuer"]),
)
if claims.get("nonce") != nonce:
raise RuntimeError("OIDC nonce validation failed")
return claims
def normalize_groups(groups):
if isinstance(groups, list):
return list(dict.fromkeys(group for group in groups if isinstance(group, str)))
if isinstance(groups, str) and groups:
return [groups]
return []
def has_required_group(groups):
required_groups = {
group.strip()
for group in os.environ.get("PLANE_OIDC_REQUIRED_GROUPS", DEFAULT_REQUIRED_GROUPS).split(",")
if group.strip()
}
return bool(required_groups.intersection(set(groups)))
def resolve_linked_user(claims, groups, auto_link):
subject = str(claims.get("sub") or "")
email = str(claims.get("email") or "").strip().lower()
if not subject:
return None
link = ExternalIdentityLink.objects.select_related("user").filter(
provider=OIDC_PROVIDER,
subject=subject,
status=ExternalIdentityLink.Status.ACTIVE,
).first()
if link is None and auto_link and email:
user = User.objects.filter(email__iexact=email, is_active=True).first()
if user:
link, _ = ExternalIdentityLink.objects.get_or_create(
provider=OIDC_PROVIDER,
subject=subject,
defaults={"user": user, "email": email, "groups": groups},
)
if link is None:
return None
link.email = email or link.email
link.groups = groups
link.last_login_at = timezone.now()
link.save(update_fields=["email", "groups", "last_login_at", "updated_at"])
user = link.user
user.last_login_medium = OIDC_PROVIDER
user.last_login_time = timezone.now()
user.save(update_fields=["last_login_medium", "last_login_time", "updated_at"])
return user
def oidc_error_redirect(base_url, next_path, error_code):
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=next_path, params={"error": error_code}))

View File

@ -0,0 +1,48 @@
from django.core.management import BaseCommand, CommandError
from plane.db.models import ExternalIdentityLink, User
class Command(BaseCommand):
help = "Link an existing Plane user to an Authentik OIDC subject"
def add_arguments(self, parser):
parser.add_argument("--email", required=True, help="Existing Plane user email")
parser.add_argument("--sub", required=True, help="Authentik OIDC subject")
parser.add_argument("--dry-run", action="store_true", help="Validate without writing")
def handle(self, *args, **options):
email = options["email"].strip().lower()
subject = options["sub"].strip()
dry_run = options["dry_run"]
if not email or not subject:
raise CommandError("--email and --sub are required")
user = User.objects.filter(email__iexact=email).first()
if user is None:
raise CommandError(f"Plane user not found: {email}")
existing_subject_link = ExternalIdentityLink.objects.filter(provider="authentik", subject=subject).first()
if existing_subject_link and existing_subject_link.user_id != user.id:
raise CommandError(f"Subject is already linked to another Plane user: {existing_subject_link.user.email}")
existing_user_link = ExternalIdentityLink.objects.filter(provider="authentik", user=user).exclude(subject=subject).first()
if existing_user_link:
raise CommandError(f"Plane user is already linked to another Authentik subject: {existing_user_link.subject}")
if dry_run:
self.stdout.write(self.style.SUCCESS(f"Dry run OK: {email} can be linked to {subject}"))
return
link, created = ExternalIdentityLink.objects.update_or_create(
provider="authentik",
subject=subject,
defaults={
"user": user,
"email": email,
"status": ExternalIdentityLink.Status.ACTIVE,
},
)
action = "created" if created else "updated"
self.stdout.write(self.style.SUCCESS(f"Authentik link {action}: {user.email} -> {link.subject}"))

View File

@ -0,0 +1,79 @@
import uuid
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("db", "0136_workspace_member_ban"),
]
operations = [
migrations.CreateModel(
name="ExternalIdentityLink",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
),
("deleted_at", models.DateTimeField(blank=True, null=True)),
(
"id",
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, db_index=True),
),
("provider", models.CharField(max_length=64)),
("subject", models.CharField(max_length=255)),
("email", models.CharField(max_length=255)),
("groups", models.JSONField(default=list)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=32,
),
),
("last_login_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="externalidentitylink_created_by",
to="db.user",
),
),
(
"updated_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="externalidentitylink_updated_by",
to="db.user",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="external_identity_links",
to="db.user",
),
),
],
options={
"verbose_name": "External Identity Link",
"verbose_name_plural": "External Identity Links",
"db_table": "external_identity_links",
"ordering": ("-created_at",),
"unique_together": {("provider", "subject")},
},
),
]

View File

@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("db", "0137_external_identity_link"),
]
operations = [
migrations.AlterUniqueTogether(
name="externalidentitylink",
unique_together={("provider", "subject"), ("provider", "user")},
),
]

View File

@ -62,7 +62,7 @@ from .project import (
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State, StateGroup, DEFAULT_STATES
from .user import Account, Profile, User, BotTypeEnum
from .user import Account, ExternalIdentityLink, Profile, User, BotTypeEnum
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISettings

View File

@ -295,6 +295,31 @@ class Account(TimeAuditModel):
ordering = ("-created_at",)
class ExternalIdentityLink(TimeAuditModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
provider = models.CharField(max_length=64)
subject = models.CharField(max_length=255)
user = models.ForeignKey("db.User", on_delete=models.CASCADE, related_name="external_identity_links")
email = models.CharField(max_length=255)
groups = models.JSONField(default=list)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.ACTIVE)
last_login_at = models.DateTimeField(blank=True, null=True)
class Meta:
unique_together = [
["provider", "subject"],
["provider", "user"],
]
verbose_name = "External Identity Link"
verbose_name_plural = "External Identity Links"
db_table = "external_identity_links"
ordering = ("-created_at",)
@receiver(post_save, sender=User)
def create_user_notification(sender, instance, created, **kwargs):
# create preferences

View File

@ -0,0 +1,839 @@
from datetime import date
from hashlib import sha1
from django.db import transaction
from plane.db.models import (
DEFAULT_STATES,
Issue,
IssueAssignee,
IssueView,
Project,
ProjectMember,
State,
User,
Workspace,
)
SOURCE = "nodedc-platform-plan"
WORKSPACE_SLUG = "nodedc"
PROJECT_IDENTIFIER = "NDCPLATFORM"
PROJECT_NAME = "NDC platform"
CODEX_EMAIL = "codex@nodedc.local"
STATE_TEMPLATES = [
{"group": "backlog", "name": "В обсуждении", "color": "#60646C", "default": True},
{"group": "unstarted", "name": "К выполнению", "color": "#60646C", "default": False},
{"group": "started", "name": "В работе", "color": "#F59E0B", "default": False},
{"group": "completed", "name": "Готово", "color": "#46A758", "default": False},
{"group": "cancelled", "name": "Отложено", "color": "#9AA4BC", "default": False},
{"group": "triage", "name": "Триаж", "color": "#4E5355", "default": False},
]
def html(*paragraphs):
return "".join(f"<p>{paragraph}</p>" for paragraph in paragraphs)
def stable_id(*parts):
value = "::".join(str(part) for part in parts)
return sha1(value.encode("utf-8")).hexdigest()[:12]
def text_block(slug, title, body):
return {
"id": f"{slug}-text-{stable_id(slug, title)}",
"type": "text",
"title": title,
"body": body.strip(),
}
def checker(slug, title, items, checked=False):
normalized_items = []
for index, item in enumerate(items):
if isinstance(item, dict):
item_text = item["text"]
item_checked = item.get("checked", checked)
else:
item_text = item
item_checked = checked
normalized_items.append(
{
"id": f"{slug}-item-{index + 1}-{stable_id(slug, title, item_text)}",
"text": item_text,
"checked": item_checked,
}
)
return {
"id": f"{slug}-checker-{stable_id(slug, title)}",
"type": "checker",
"title": title,
"items": normalized_items,
}
CARDS = [
{
"slug": "phase-0-discovery-platform-skeleton",
"name": "Platform skeleton и discovery",
"priority": "high",
"state_group": "completed",
"assignees": [CODEX_EMAIL],
"description_html": html(
"Нулевой этап платформы NODE.DC: зафиксировать текущую структуру Launcher и Task Manager, создать platform skeleton и перенести архитектурный план из ТЗ в рабочие документы.",
"Граница scope: не переносить Launcher и Plane физически, не менять бизнес-логику приложений, не трогать Plane auth/users до отдельного backup и миграционного этапа.",
"Критерий приемки: в NODEDC есть platform/docs, отдельный git remote NODEDC_PLATFORM, понятный discovery report, auth model, local deployment plan, security checklist и migration plan для существующего Plane user.",
),
"blocks": [
text_block(
"phase0",
"Текущая архитектура",
"""
NODEDC сейчас используется как внешний workspace-корень. Launcher находится отдельным git-репозиторием в /Users/dcconstructions/Downloads/mnt/data/nodedc_launcher и является Vite/React GUI без backend слоя.
Task Manager находится отдельным git-репозиторием в /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER. Plane runtime лежит в plane-app, source fork лежит в plane-src, локальный стенд доступен на http://localhost:8090.
Физический перенос репозиториев на нулевом этапе не нужен: он может сломать Plane runtime, env, volumes и backup. Платформенный слой создается рядом, а не вместо текущих приложений.
Платформенный слой подключен как отдельный git-репозиторий: https://git.dcserve.ru/SILVER/NODEDC_PLATFORM.git. Локальная ветка: main.
""",
),
text_block(
"phase0",
"Этап 0. Discovery + platform skeleton",
"""
Статус: реализовано.
Этап фиксирует baseline без изменения Launcher и Plane. Результат должен стать стартовой точкой для последующих infra/auth задач: вся архитектура и ограничения описаны в platform/docs, а будущая реализация разбита на крупные проектные карточки в Task Manager.
""",
),
checker(
"phase0",
"Чекер этапа 0. Discovery + platform skeleton",
[
"Проверить базовые ТЗ в NODEDC/DOC/BASE.",
"Найти текущие пути Launcher и Task Manager.",
"Подтвердить, что Launcher и Task Manager являются отдельными git-репозиториями.",
"Зафиксировать решение не переносить Plane внутрь Launcher.",
"Создать platform skeleton в NODEDC/platform.",
"Подключить NODEDC/platform к отдельной repo NODEDC_PLATFORM.",
"Добавить ARCHITECTURE.md, AUTH_MODEL.md и DEPLOYMENT_LOCAL.md.",
"Добавить SECURITY_CHECKLIST.md и MIGRATION_PLANE_USER.md.",
"Добавить DISCOVERY_REPORT.md с текущими путями, рисками и следующим шагом.",
"Создать проект NDC platform и крупные карточки roadmap в Task Manager.",
],
checked=True,
),
text_block(
"phase0",
"Реализация этапа 0",
"""
Создан platform skeleton в /Users/dcconstructions/Downloads/mnt/NODEDC/platform.
Папка platform инициализирована как отдельный git-репозиторий на ветке main. Remote origin: https://git.dcserve.ru/SILVER/NODEDC_PLATFORM.git.
Первый commit создан и отправлен в origin/main: 0f89c4d, "АРХ - NODEDC PLATFORM: каркас платформенного репозитория". Remote проверен через git smart HTTP endpoint: refs/heads/main указывает на 0f89c4d.
Добавлены документы: README.md, docs/DISCOVERY_REPORT.md, docs/ARCHITECTURE.md, docs/AUTH_MODEL.md, docs/DEPLOYMENT_LOCAL.md, docs/SECURITY_CHECKLIST.md, docs/MIGRATION_PLANE_USER.md, infra/README.md, infra/.env.example, packages/auth-sdk/README.md, tasks/CODEX_PLATFORM_AUTH_TASK.md.
Discovery подтвердил: Launcher сейчас Vite/React GUI без backend, Task Manager уже запущен как Plane CE self-host на localhost:8090, workspace nodedc существует, пользователь codex@nodedc.local доступен.
""",
),
],
},
{
"slug": "infra-authentik-reverse-proxy",
"name": "Platform infra: Authentik и proxy",
"priority": "high",
"state_group": "completed",
"assignees": [CODEX_EMAIL],
"description_html": html(
"Инфраструктурный слой для локального Authentik и reverse proxy. Этот блок должен дать единые локальные домены, маршрутизацию приложений и внешний защитный слой перед Launcher и Task Manager.",
"Критерий приемки: auth.local.nodedc, launcher.local.nodedc и task.local.nodedc открываются через proxy, Authentik работает за корректными forwarded headers, прямой пользовательский вход идет через домены, а не через внутренние порты.",
),
"blocks": [
text_block(
"infra",
"Текущая архитектура",
"""
Сейчас Launcher и Task Manager живут как отдельные localhost-сервисы. Task Manager доступен через Plane proxy на http://localhost:8090. Единого Authentik, app domains и reverse proxy layer пока нет.
Этот этап не меняет Plane auth flow. Он подготавливает внешний слой и Authentik bootstrap, к которому потом подключаются Launcher и Plane.
""",
),
text_block(
"infra",
"Этап 1. Local domains + reverse proxy",
"""
Статус: реализовано.
Локальная схема доменов и proxy routing описаны в platform/infra. На первом проходе текущие Launcher и Task Manager проксируются как внешние localhost-upstream через host.docker.internal, без физического переноса репозиториев.
Runtime launch закрыт через локальный Caddy-based image nodedc/plane-proxy:ru. Docker compose поднят, Authentik server/worker/Postgres healthy, reverse proxy слушает host port 80.
Локальные домены прописаны в /etc/hosts. Прямые URL auth.local.nodedc, launcher.local.nodedc и task.local.nodedc проходят через host DNS без ручной подмены Host header.
""",
),
checker(
"infra1",
"Чекер этапа 1. Local domains + reverse proxy",
[
{"text": "Согласовать список локальных доменов auth/launcher/task.", "checked": True},
{"text": "Подготовить /etc/hosts инструкцию.", "checked": True},
{"text": "Выбрать proxy: nginx, caddy или traefik.", "checked": True},
{"text": "Настроить routing auth.local.nodedc.", "checked": True},
{"text": "Настроить routing launcher.local.nodedc.", "checked": True},
{"text": "Настроить routing task.local.nodedc.", "checked": True},
{"text": "Прокинуть Host, X-Forwarded-Proto и X-Forwarded-For.", "checked": True},
{"text": "Проверить WebSocket headers для Authentik/Plane live.", "checked": True},
{"text": "Поднять docker compose и проверить auth/task через curl.", "checked": True},
{"text": "Прописать auth/launcher/task local domains в /etc/hosts для браузерного теста.", "checked": True},
],
),
text_block(
"infra",
"Этап 2. Authentik bootstrap",
"""
Статус: реализовано.
Добавлен local compose для Authentik 2026.2 по актуальной официальной схеме: PostgreSQL 16, server и worker. Redis из раннего ТЗ не добавлен, потому что в текущем официальном compose Authentik 2026.2 Redis отсутствует.
Создан временный локальный Authentik admin-пользователь под ручную проверку входа. Ручной login через http://auth.local.nodedc подтвержден пользователем 2026-05-04.
Добавлен воспроизводимый local bootstrap для NODE.DC groups, Launcher/Task Manager OAuth2 providers, application tiles, group access bindings и OIDC client secrets. На текущем Authentik bootstrap выполнен.
""",
),
checker(
"infra2",
"Чекер этапа 2. Authentik bootstrap",
[
{"text": "Добавить Authentik server, worker и postgres в local infra.", "checked": True},
{"text": "Зафиксировать, что Redis не входит в официальный compose Authentik 2026.2.", "checked": True},
{"text": "Добавить генератор infra/.env с локальными secrets и bootstrap credentials.", "checked": True},
{"text": "Создать локального Authentik admin-пользователя для ручного теста.", "checked": True},
{"text": "Создать группы nodedc:launcher:* и nodedc:taskmanager:*.", "checked": True},
{"text": "Создать Application/Provider для Launcher.", "checked": True},
{"text": "Создать Application/Provider для Task Manager.", "checked": True},
{"text": "Настроить app access policies по группам.", "checked": True},
{"text": "Описать bootstrap/export или blueprint strategy.", "checked": True},
{"text": "Проверить login/logout в Authentik за proxy.", "checked": True},
],
),
text_block(
"infra",
"Реализация этапа 1",
"""
В platform repo добавлены infra/docker-compose.dev.yml, infra/reverse-proxy/Caddyfile, infra/authentik/README.md и infra/scripts/init-dev-env.sh.
Compose публикует только reverse-proxy port 80. Authentik server, worker и PostgreSQL остаются внутри docker network. Caddy routes: auth.local.nodedc -> authentik-server:9000, launcher.local.nodedc -> host.docker.internal:5173, task.local.nodedc -> host.docker.internal:8090.
Проверки: docker compose config проходит на infra/.env.example; init-dev-env.sh проходит sh -n и создает ignored infra/.env с правами 600. Docker Hub pull caddy:2-alpine зависал, поэтому compose переведен на локальный PLATFORM_PROXY_IMAGE=nodedc/plane-proxy:ru.
Фактический docker compose up выполнен. Контейнеры nodedc-platform-authentik-server-1, nodedc-platform-authentik-worker-1, nodedc-platform-postgresql-authentik-1 healthy; nodedc-platform-reverse-proxy-1 слушает 0.0.0.0:80. Проверки через Host header: auth.local.nodedc возвращает 302 на Authentik authentication flow, launcher.local.nodedc возвращает 200 от Vite launcher, task.local.nodedc возвращает 200 от Plane.
Для Vite launcher route зафиксирован отдельный upstream Host localhost:5173, иначе dev server отдавал 403 на внешний Host launcher.local.nodedc.
Проверен bootstrap-пользователь Authentik: akadmin / admin@nodedc.local / internal / active. Пароль хранится только в ignored platform/infra/.env.
Локальные домены прописаны в /etc/hosts. Прямые проверки без подмены Host header проходят: http://auth.local.nodedc/ -> 302, http://launcher.local.nodedc/ -> 200, http://task.local.nodedc/ -> 200.
Plane live WebSocket upgrade через proxy проверен: запрос с Upgrade headers на http://task.local.nodedc/live/ возвращает 101 Switching Protocols. Caddy reverse_proxy сохраняет Upgrade/Connection headers для live-трафика.
Изменения platform repo закоммичены и отправлены в origin/main: afa53d5, "АРХ - NODEDC PLATFORM: запуск локального proxy/Authentik стенда". Remote main обновлен с 55db22b до afa53d5.
""",
),
text_block(
"infra",
"Реализация этапа 2",
"""
В Authentik создан локальный internal user dcctouch@gmail.com с username dcctouch@gmail.com, активным статусом и usable password.
Пользователь добавлен в встроенную группу authentik Admins, у которой is_superuser=True. Это дает доступ к админке Authentik для ручной проверки и дальнейшей настройки applications/providers.
Пароль был задан из пользовательского ввода и не сохранялся в platform repo. Следующий открытый пункт: ручная проверка login/logout через http://auth.local.nodedc.
Ручной login через proxy подтвержден пользователем 2026-05-04: dcctouch@gmail.com успешно вошел в Authentik, видит My applications и кнопку Admin interface.
В platform repo добавлены infra/authentik/bootstrap-dev.py и infra/scripts/bootstrap-authentik-dev.sh. Скрипт idempotent, заполняет недостающие OIDC client secrets в ignored infra/.env и создает Authentik объекты через локальный ak shell.
Созданы группы nodedc:superadmin, nodedc:launcher:admin, nodedc:launcher:user, nodedc:taskmanager:admin и nodedc:taskmanager:user. Пользователь dcctouch@gmail.com добавлен во все NODE.DC группы для локальной проверки.
Созданы application tiles NODE.DC Launcher и NODE.DC Task Manager, OAuth2 providers NODE.DC Launcher OIDC и NODE.DC Task Manager OIDC. Provider discovery endpoints отвечают 200: /application/o/launcher/.well-known/openid-configuration и /application/o/task-manager/.well-known/openid-configuration.
Application access задан через group bindings: Launcher доступен nodedc:superadmin, nodedc:launcher:admin, nodedc:launcher:user; Task Manager доступен nodedc:superadmin, nodedc:taskmanager:admin, nodedc:taskmanager:user. OIDC tokens получают стандартные openid/email/profile/offline_access scopes и custom groups scope.
Изменения platform repo закоммичены и отправлены в origin/main: 4a10726, "АРХ - NODEDC PLATFORM: bootstrap Authentik applications". Remote main обновлен с afa53d5 до 4a10726.
""",
),
],
},
{
"slug": "launcher-control-plane-oidc",
"name": "Launcher: backend, OIDC и app access",
"priority": "high",
"state_group": "started",
"assignees": [CODEX_EMAIL],
"description_html": html(
"Launcher должен перестать быть только меню ссылок и стать control plane: login через Authentik, app registry, фильтрация доступных приложений, admin API для пользователей и audit log.",
"Критерий приемки: пользователь логинится через Authentik, Launcher видит sub/email/groups, показывает только доступные приложения, а admin действия идут через server-side backend и фиксируются в audit.",
),
"blocks": [
text_block(
"launcher",
"Текущая архитектура",
"""
Launcher сейчас является Vite/React GUI в отдельном репозитории /Users/dcconstructions/Downloads/mnt/data/nodedc_launcher.
Backend слоя не обнаружено. Значит OIDC callback, session handling, Authentik service token, app registry и audit log нужно добавлять отдельным backend/BFF слоем, а не держать во frontend.
""",
),
text_block(
"launcher",
"Этап 1. Backend/BFF shell",
"""
Статус: реализовано.
Нужно добавить минимальный backend слой для безопасной auth-интеграции и будущего admin API. Выбран путь: BFF живет в Launcher repo и в dev режиме обслуживает Vite как middleware на том же порту 5173, чтобы существующий reverse proxy launcher.local.nodedc не требовал нового внешнего домена или порта.
""",
),
checker(
"launcher1",
"Чекер этапа 1. Backend/BFF shell",
[
{"text": "Выбрать место backend слоя: Launcher repo или platform/services/launcher-api.", "checked": True},
{"text": "Добавить health endpoint.", "checked": True},
{"text": "Добавить server-side session storage.", "checked": True},
{"text": "Добавить env contract для OIDC и Authentik API.", "checked": True},
{"text": "Зафиксировать запрет service tokens во frontend.", "checked": True},
],
),
text_block(
"launcher",
"Этап 2. OIDC login/session",
"""
Статус: реализовано, ожидает ручной browser login/logout проверки.
Launcher должен проходить OIDC Authorization Code Flow + PKCE через Authentik и получать нормализованного текущего пользователя.
""",
),
checker(
"launcher2",
"Чекер этапа 2. OIDC login/session",
[
{"text": "Добавить login route.", "checked": True},
{"text": "Добавить callback route.", "checked": True},
{"text": "Проверить state/nonce/token.", "checked": True},
{"text": "Загрузить JWKS и валидировать JWT.", "checked": True},
{"text": "Нормализовать sub/email/name/groups.", "checked": True},
{"text": "Добавить logout flow.", "checked": True},
{"text": "Подтвердить browser login/logout через launcher.local.nodedc.", "checked": False},
],
),
text_block(
"launcher",
"Этап 3. App registry и фильтрация плиток",
"""
Статус: частично реализовано.
Launcher показывает приложения не статическим списком, а через registry с required_group. Источник truth по доступу остается в Authentik groups.
Продуктовое правило от 2026-05-04: Launcher не должен скрывать все недоступные плитки. Он показывает каталог приложений платформы, а отсутствие доступа отражает как disabled/Нет доступа на карточке. Это уже заложено в текущий frontend-декор и должно сохраняться при backend-интеграции.
""",
),
checker(
"launcher3",
"Чекер этапа 3. App registry и фильтрация плиток",
[
{"text": "Спроектировать app_registry модель.", "checked": True},
{"text": "Добавить GET /api/me.", "checked": True},
{"text": "Добавить GET /api/apps.", "checked": True},
{"text": "Фильтровать приложения по groups.", "checked": True},
{"text": "Скрывать disabled apps.", "checked": True},
{"text": "Проверить direct URL behavior через proxy.", "checked": False},
],
),
text_block(
"launcher",
"Целевой пользовательский flow",
"""
Production flow: пользователь открывает nodedc.ru, видит основной промо/маркетинговый сайт, нажимает "Войти на платформу", проходит окно логина/пароля и после успешного доступа попадает в NODE.DC Launcher.
Пользовательский UI платформы не должен светить название identity provider. В текстах и кнопках используется нейтральное "Войти", "Вход на платформу", "Сессия NODE.DC". Authentik остается внутренним identity source и admin-инструментом.
Прямые ссылки на приложения остаются нормальным сценарием. Если пользователь открыл Task Manager напрямую без session, его нужно увести в login flow и вернуть к приложению после успешной авторизации.
""",
),
text_block(
"launcher",
"Реализация этапов 1-3",
"""
В Launcher repo добавлен local BFF server/dev-server.mjs. npm run dev теперь запускает Node/Express BFF, который обслуживает Vite как middleware на том же порту 5173. Старый чистый Vite dev режим сохранен как npm run dev:vite.
BFF автоматически подхватывает OIDC env из NODEDC/platform/infra/.env, не вшивая secrets в frontend или git. Добавлены routes: GET /healthz, GET /auth/login, GET /auth/callback, GET /auth/logout, GET /api/me, GET /api/apps, POST /api/storage/upload и POST /api/storage/data.
OIDC flow реализован как Authorization Code + PKCE: state хранится server-side и в HttpOnly cookie, nonce проверяется по id_token, JWKS загружается из discovery endpoint, JWT валидируется по issuer/audience. Session хранится server-side in-memory для локального dev стенда.
App registry возвращает полный каталог приложений из launcher storage и runtime access state: hasAccess, matchedGroups, accessReason. Frontend больше не скрывает недоступные плитки; он отключает переход и показывает "Нет доступа" через существующую механику карточек.
Frontend Launcher подключен к /api/me и /api/apps. Без session показывается нейтральный экран "Вход на платформу NODE.DC" и кнопка "Войти" без упоминания Authentik. После login пользователь нормализуется из Authentik claims, а плитки получают доступы из runtime app registry.
Проверки 2026-05-04: npm run build проходит; http://launcher.local.nodedc/healthz возвращает oidcConfigured=true; http://launcher.local.nodedc/api/me без session возвращает 401 и loginUrl; http://launcher.local.nodedc/auth/login возвращает 302 на Authentik authorize endpoint; discovery endpoint Authentik для launcher возвращает issuer и authorization_endpoint.
Ручная проверка 2026-05-04 выявила callback error {"error":"JSON Web Key Set malformed"}. Root cause: Authentik OAuth2 providers были созданы без signing_key, поэтому JWKS endpoint отдавал {}. Bootstrap исправлен: providers получают authentik Self-signed Certificate как signing key. После повторного bootstrap JWKS отдает RSA key.
Открытый приемочный пункт: ручной browser login/logout через http://launcher.local.nodedc и проверка, что после callback видна плитка Task Manager.
Граница готовности на текущий момент: Launcher готов как базовый OIDC/BFF portal, но не является финально готовым production control plane. Остаются mock/dev элементы и следующий обязательный блок Plane OIDC. Пока Plane OIDC не реализован, переход из Launcher в task.local.nodedc ожидаемо приводит к старой авторизации Plane, потому что Task Manager еще не доверяет Authentik session.
2026-05-04 добавлен явный logout в профильное меню Launcher: кнопка "Выйти" вызывает /auth/logout и чистит local BFF session без ухода в Authentik UI/admin. Это нужно, чтобы пользователь оставался в NODE.DC UX после выхода.
SSO-session у identity provider может оставаться активной. Поэтому повторное нажатие "Войти" может вернуть пользователя в Launcher без ввода пароля это ожидаемое SSO-поведение, а не ошибка. Для диагностики добавлен prompt=login на /auth/login?prompt=login и отдельный global logout через /auth/logout?global=1, но пользовательский logout по умолчанию остается локальным.
""",
),
text_block(
"launcher",
"Этап 4. Admin API и audit",
"""
Статус: backlog.
Admin API должен управлять пользователями и группами через Authentik API. Launcher не хранит пароли и не заменяет Authentik как identity source.
""",
),
checker(
"launcher4",
"Чекер этапа 4. Admin API и audit",
[
"Добавить список пользователей через Authentik API.",
"Добавить создание пользователя или enrollment flow.",
"Добавить disable пользователя.",
"Добавить reset/recovery flow.",
"Добавить add/remove group.",
"Добавить admin_audit_log.",
"Закрыть admin endpoints группами nodedc:superadmin/nodedc:launcher:admin.",
],
),
],
},
{
"slug": "plane-oidc-user-migration",
"name": "Plane: OIDC и миграция пользователя",
"priority": "urgent",
"state_group": "started",
"assignees": [CODEX_EMAIL],
"description_html": html(
"Интеграция Plane fork с Authentik OIDC без потери текущего пользователя и связанных данных. Это самый рискованный блок, потому что нельзя пересоздать существующего Plane admin и потерять связи задач.",
"Критерий приемки: старый Plane user связан с Authentik sub, после входа через NODE.DC/Authentik видит старые workspace, проекты, задачи, комментарии и назначения; signup закрыт.",
),
"blocks": [
text_block(
"plane",
"Текущая архитектура",
"""
Task Manager работает как Plane CE self-host. Runtime лежит в plane-app, fork исходников в plane-src.
В workspace nodedc уже есть данные, пользователи и проекты. Старые связи в Plane нельзя менять без отдельной проверенной миграции.
""",
),
text_block(
"plane",
"Этап 1. Backup и auth discovery",
"""
Статус: реализовано.
Перед кодом нужен backup и точная карта Plane auth/users: модели пользователя, sessions, signup, login endpoints, env flags и все связи, которые нельзя трогать.
""",
),
checker(
"plane1",
"Чекер этапа 1. Backup и auth discovery",
[
{"text": "Сделать dump plane_db.", "checked": True},
{"text": "Сохранить plane.env.", "checked": True},
{"text": "Сохранить uploads/MinIO volumes.", "checked": True},
{"text": "Найти User/Profile/WorkspaceMember/ProjectMember связи.", "checked": True},
{"text": "Найти текущие login/signup endpoints.", "checked": True},
{"text": "Найти env flags для signup/OAuth/password auth.", "checked": True},
{"text": "Составить список файлов для OIDC integration.", "checked": True},
],
),
text_block(
"plane",
"Реализация этапа 1",
"""
Создан backup перед изменением Plane auth слоя: plane DB dump, plane.env/api env snapshots, docker compose/config copies и архив uploads/MinIO volume. Backup лежит в /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app/backup/nodedc-platform-oidc-20260504-122924.
Discovery подтвердил критичный baseline: пользователь dcctouch@gmail.com уже существует в Plane как admin_nodedc, user id 844d7f18-285d-4671-8371-8ca9ca5ffa39, состоит owner в workspace nodedc и связанных проектах. Миграция не должна пересоздавать этого пользователя и не должна менять старые workspace/project/task связи.
Точки интеграции найдены в plane-src/apps/api/plane/authentication/urls.py, plane-src/apps/api/plane/authentication/utils/login.py и plane-src/apps/api/plane/authentication/utils/redirection_path.py. Env flags Plane подтверждены: ENABLE_SIGNUP, ENABLE_EMAIL_PASSWORD, ENABLE_MAGIC_LINK_LOGIN.
""",
),
text_block(
"plane",
"Этап 2. External identity link",
"""
Статус: реализовано.
Нужно добавить mapping Authentik identity на существующего Plane user. Это отдельный слой связи, а не массовое изменение старых owner/assignee/created_by.
""",
),
checker(
"plane2",
"Чекер этапа 2. External identity link",
[
{"text": "Спроектировать модель external_identity_link.", "checked": True},
{"text": "Добавить миграцию модели.", "checked": True},
{"text": "Обеспечить unique provider+sub.", "checked": True},
{"text": "Обеспечить unique provider+plane_user_id.", "checked": True},
{"text": "Добавить last_login_at/status.", "checked": True},
{"text": "Проверить idempotent поведение.", "checked": True},
],
),
text_block(
"plane",
"Реализация этапа 2",
"""
В Plane source добавлена модель ExternalIdentityLink в plane-src/apps/api/plane/db/models/user.py и экспорт в plane-src/apps/api/plane/db/models/__init__.py.
Применены миграции db.0137_external_identity_link и db.0138_external_identity_link_unique_user. Модель хранит provider, subject, user, email, groups, status и last_login_at. Уникальность закреплена по provider+subject и provider+user, чтобы один Authentik subject не связывался с несколькими Plane users и один Plane user не получал несколько Authentik identities одного provider.
Dry-run команды link_authentik_user проверен на dcctouch@gmail.com без изменения данных.
""",
),
text_block(
"plane",
"Этап 3. OIDC login flow",
"""
Статус: частично реализовано.
Plane должен принимать Authentik OIDC callback, валидировать token/state/nonce и логинить локального пользователя через mapping.
""",
),
checker(
"plane3",
"Чекер этапа 3. OIDC login flow",
[
{"text": "Добавить NODE.DC/Authentik login entrypoint.", "checked": True},
{"text": "Добавить OIDC callback.", "checked": True},
{"text": "Валидировать issuer/audience/exp/sub.", "checked": True},
{"text": "Проверять nodedc:taskmanager:access.", "checked": True},
{"text": "Искать link по authentik_sub.", "checked": True},
{"text": "Логинить существующего plane_user_id.", "checked": True},
"Закрыть путь без mapping или app access.",
],
),
text_block(
"plane",
"Реализация этапа 3",
"""
Добавлены backend routes /auth/oidc/login/ и /auth/oidc/callback/ в Plane API. Login entrypoint генерирует Authorization Code + PKCE, state/nonce хранит в server-side session, callback обменивает code на tokens, проверяет id_token через JWKS и валидирует issuer/audience/nonce.
Access check завязан на группы Authentik: nodedc:superadmin, nodedc:taskmanager:admin, nodedc:taskmanager:user. Для local-dev включен PLANE_OIDC_AUTO_LINK_EMAIL=1, чтобы первый успешный callback мог безопасно связать существующего Plane user по email без пересоздания пользователя.
Проверено: /auth/oidc/login/?next_path=/nodedc стабильно возвращает 302 на Authentik authorize endpoint для client_id nodedc-task-manager.
Ручной browser acceptance от 2026-05-04 закрыт: пользователь зашел в Launcher в incognito, прошел Authentik login, вернулся в Launcher, открыл Operational Core/Task Manager из плитки и получил доступ без Plane password. В Plane создан ExternalIdentityLink для dcctouch@gmail.com, status active, subject присутствует, linked Plane user остался старым user id 844d7f18-285d-4671-8371-8ca9ca5ffa39.
После проверки добавлен route alias /auth/oidc/callback без trailing slash, потому что Authentik сначала обращался к callback без слэша и получал 404 перед успешным запросом на /auth/oidc/callback/. Группы в Plane и Launcher теперь дедуплицируются при нормализации claims.
Открытый продуктовый gap: профильный контекст пока не синхронизируется полностью. Launcher показывает claims из Authentik, а Task Manager показывает локальный Plane profile/avatar. Следующий этап должен сделать единый источник display name/avatar.
""",
),
text_block(
"plane",
"Этап 4. Migration command и signup policy",
"""
Статус: частично реализовано.
После модели и login flow нужна управляемая команда связывания старого пользователя и отключение обходных входов, которые ломают модель доступа.
""",
),
checker(
"plane4",
"Чекер этапа 4. Migration command и signup policy",
[
{"text": "Добавить manage.py command link_authentik_user.", "checked": True},
{"text": "Поддержать dry-run.", "checked": True},
{"text": "Проверять конфликтующий mapping.", "checked": True},
{"text": "Не менять задачи/workspace/memberships.", "checked": True},
{"text": "Отключить публичный signup.", "checked": True},
"Закрыть лишние OAuth/magic-link обходы, если они нарушают invite/manual модель.",
{"text": "Проверить старый admin после OIDC login.", "checked": True},
],
),
text_block(
"plane",
"Реализация этапа 4",
"""
Добавлена management command plane-src/apps/api/plane/db/management/commands/link_authentik_user.py с параметрами --email, --sub и --dry-run. Команда проверяет существующего Plane user, конфликтующий provider+subject и конфликтующий provider+user mapping.
plane-app/plane.env настроен для local runtime: WEB_URL=http://task.local.nodedc, CORS_ALLOWED_ORIGINS включает task.local.nodedc, ENABLE_SIGNUP=0, ENABLE_MAGIC_LINK_LOGIN=0, Plane OIDC env указывает на Authentik provider task-manager. ENABLE_EMAIL_PASSWORD временно оставлен включенным как fallback до ручного подтверждения OIDC входа, чтобы не потерять доступ к текущему Task Manager.
""",
),
],
},
{
"slug": "auth-sdk-application-standards",
"name": "Auth SDK и стандарты приложений",
"priority": "medium",
"state_group": "backlog",
"assignees": [CODEX_EMAIL],
"description_html": html(
"Общий слой правил и SDK для будущих приложений NODE.DC, чтобы Tender, Agents, 1C, DM и новые сервисы не реализовывали auth каждый раз по-разному.",
"Критерий приемки: есть typed AuthUser, JWKS/JWT validation helpers, requireAppAccess, env contract и документация claims для Node.js/Next.js сервисов; для Plane описан Python/Django эквивалент.",
),
"blocks": [
text_block(
"sdk",
"Текущая архитектура",
"""
Единого auth-sdk пока нет. В platform/packages/auth-sdk создан только README и контракт будущего пакета.
Пока не подключен Launcher backend, SDK должен оставаться спецификацией, чтобы не плодить преждевременные абстракции.
""",
),
text_block(
"sdk",
"Этап 1. Claims contract",
"""
Статус: не реализовано.
Сначала нужно стабилизировать claims и naming групп, иначе SDK закрепит неправильный контракт.
""",
),
checker(
"sdk1",
"Чекер этапа 1. Claims contract",
[
"Зафиксировать AuthUser type.",
"Зафиксировать обязательные claims.",
"Зафиксировать app access group naming.",
"Зафиксировать error model для deny/unauthorized.",
"Синхронизировать AUTH_MODEL.md с Authentik bootstrap.",
],
),
text_block(
"sdk",
"Этап 2. TypeScript auth-sdk",
"""
Статус: backlog.
После Launcher backend можно вынести повторяемую JWT/JWKS логику в маленький пакет без привязки к конкретному web framework.
""",
),
checker(
"sdk2",
"Чекер этапа 2. TypeScript auth-sdk",
[
"Добавить JWKS loader/cache.",
"Добавить JWT validation.",
"Добавить normalizeAuthUser.",
"Добавить requireAppAccess.",
"Добавить unit tests.",
"Подключить SDK в Launcher backend.",
],
),
],
},
{
"slug": "security-acceptance-staging",
"name": "Security acceptance и staging path",
"priority": "high",
"state_group": "backlog",
"assignees": [CODEX_EMAIL],
"description_html": html(
"Финальный слой проверки, что платформа не только запускается, но и закрывает реальные security-сценарии: прямые ссылки, отключение пользователей, отсутствие наружных внутренних портов, audit и staging path.",
"Критерий приемки: security checklist закрыт проверками, staging compose или понятный staging plan готов, secrets не попали в frontend/git, Plane admin сохранил данные после OIDC migration.",
),
"blocks": [
text_block(
"security",
"Текущая архитектура",
"""
Security checklist создан в platform/docs/SECURITY_CHECKLIST.md. Реальных acceptance tests по новой auth architecture пока нет, потому что Authentik/proxy/Launcher OIDC/Plane OIDC еще не реализованы.
""",
),
text_block(
"security",
"Этап 1. Security acceptance tests",
"""
Статус: backlog.
Нужно проверить не только happy path, но и отказы: нет логина, нет группы, прямой URL, deactivated user, отсутствие service token во frontend.
""",
),
checker(
"security1",
"Чекер этапа 1. Security acceptance tests",
[
"Проверить redirect Launcher без логина.",
"Проверить скрытие Task Manager без group access.",
"Проверить deny на прямой task.local.nodedc без group access.",
"Проверить успешный вход пользователя с access.",
"Проверить старого Plane admin после OIDC migration.",
"Проверить deactivate user.",
"Проверить audit log admin actions.",
"Проверить отсутствие service tokens во frontend bundle.",
],
),
text_block(
"security",
"Этап 2. Staging deployment path",
"""
Статус: backlog.
После локальной схемы нужен staging plan: HTTPS, secure cookies, реальные domains, secrets management, закрытые внутренние порты.
""",
),
checker(
"security2",
"Чекер этапа 2. Staging deployment path",
[
"Описать staging domains.",
"Подготовить docker-compose.staging.yml или эквивалентный deployment plan.",
"Включить COOKIE_SECURE=true.",
"Включить HTTPS/HSTS.",
"Закрыть Postgres/Redis/MinIO наружу.",
"Описать backup/restore runbook.",
"Описать secrets rotation policy.",
],
),
],
},
]
def ensure_project(workspace, owner, codex_user):
project, _ = Project.objects.get_or_create(
workspace=workspace,
identifier=PROJECT_IDENTIFIER,
defaults={
"name": PROJECT_NAME,
"description": "Платформенный проект NODE.DC: Authentik, Launcher control plane, Plane OIDC и future app auth foundation.",
"network": 0,
"project_lead": owner,
"created_by": codex_user,
"updated_by": codex_user,
},
)
project.name = PROJECT_NAME
project.description = "Платформенный проект NODE.DC: Authentik, Launcher control plane, Plane OIDC и future app auth foundation."
project.network = 0
project.project_lead = owner
project.timezone = "Europe/Moscow"
project.created_by = project.created_by or codex_user
project.updated_by = codex_user
project.save()
for index, state_spec in enumerate(STATE_TEMPLATES):
state = State.all_state_objects.filter(project=project, group=state_spec["group"]).first()
if state is None:
state = State(project=project, workspace=workspace)
state.name = state_spec["name"]
state.group = state_spec["group"]
state.color = state_spec["color"]
state.default = state_spec["default"]
state.is_triage = state_spec["group"] == "triage"
state.sequence = DEFAULT_STATES[index]["sequence"]
state.created_by = state.created_by or codex_user
state.updated_by = codex_user
state.save(disable_auto_set_user=True)
if state_spec["default"]:
project.default_state = state
project.save()
for user, role in [(owner, 20), (codex_user, 20)]:
member, _ = ProjectMember.objects.get_or_create(
project=project,
member=user,
defaults={"workspace": workspace, "role": role, "created_by": codex_user, "updated_by": codex_user},
)
member.workspace = workspace
member.role = role
member.is_active = True
member.updated_by = codex_user
member.save()
return project
def ensure_issue(workspace, project, codex_user, spec):
state = State.all_state_objects.get(project=project, group=spec["state_group"])
issue = Issue.objects.filter(project=project, workspace=workspace, external_source=SOURCE, external_id=spec["slug"]).first()
if issue is None:
issue = Issue.objects.filter(project=project, workspace=workspace, name=spec["name"]).first()
if issue is None:
issue = Issue(project=project, workspace=workspace, created_by=codex_user)
issue.name = spec["name"]
issue.description_html = spec["description_html"]
issue.detail_layout = {"nodedc_structured_blocks": spec["blocks"]}
issue.priority = spec["priority"]
issue.state = state
issue.start_date = date.today() if spec["state_group"] == "started" else None
issue.target_date = None
issue.external_source = SOURCE
issue.external_id = spec["slug"]
issue.updated_by = codex_user
issue.save(disable_auto_set_user=True)
IssueAssignee.objects.filter(issue=issue).delete()
for email in spec["assignees"]:
assignee = User.objects.get(email=email)
IssueAssignee.objects.get_or_create(
issue=issue,
assignee=assignee,
defaults={
"project": project,
"workspace": workspace,
"created_by": codex_user,
"updated_by": codex_user,
},
)
return issue
def ensure_view(workspace, project, codex_user):
view, _ = IssueView.objects.get_or_create(
workspace=workspace,
project=project,
name="Roadmap NODE.DC platform",
defaults={
"description": "Крупные архитектурные блоки платформенной auth/SSO работы",
"filters": {},
"rich_filters": {},
"owned_by": codex_user,
"access": 1,
"logo_props": {},
},
)
view.description = "Крупные архитектурные блоки платформенной auth/SSO работы"
view.filters = {}
view.rich_filters = {}
view.owned_by = codex_user
view.access = 1
view.save(disable_auto_set_user=True)
@transaction.atomic
def main():
workspace = Workspace.objects.get(slug=WORKSPACE_SLUG)
owner = workspace.owner
codex_user = User.objects.get(email=CODEX_EMAIL)
project = ensure_project(workspace, owner, codex_user)
issues = [ensure_issue(workspace, project, codex_user, card) for card in CARDS]
ensure_view(workspace, project, codex_user)
summary = {
"workspace": workspace.slug,
"project": f"{project.identifier} / {project.name}",
"issues": [f"{project.identifier}-{issue.sequence_id}: {issue.name}" for issue in issues],
}
print(summary)
main()