840 lines
54 KiB
Python
840 lines
54 KiB
Python
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()
|