diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml index 1e30dfc..f0ceef7 100644 --- a/plane-app/docker-compose.yaml +++ b/plane-app/docker-compose.yaml @@ -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: diff --git a/plane-src/apps/api/plane/authentication/urls.py b/plane-src/apps/api/plane/authentication/urls.py index 4bec07d..ec44e24 100644 --- a/plane-src/apps/api/plane/authentication/urls.py +++ b/plane-src/apps/api/plane/authentication/urls.py @@ -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 diff --git a/plane-src/apps/api/plane/authentication/views/__init__.py b/plane-src/apps/api/plane/authentication/views/__init__.py index a9c816a..72a8e87 100644 --- a/plane-src/apps/api/plane/authentication/views/__init__.py +++ b/plane-src/apps/api/plane/authentication/views/__init__.py @@ -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 diff --git a/plane-src/apps/api/plane/authentication/views/app/oidc.py b/plane-src/apps/api/plane/authentication/views/app/oidc.py new file mode 100644 index 0000000..f261b6a --- /dev/null +++ b/plane-src/apps/api/plane/authentication/views/app/oidc.py @@ -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})) diff --git a/plane-src/apps/api/plane/db/management/commands/link_authentik_user.py b/plane-src/apps/api/plane/db/management/commands/link_authentik_user.py new file mode 100644 index 0000000..ce1f46f --- /dev/null +++ b/plane-src/apps/api/plane/db/management/commands/link_authentik_user.py @@ -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}")) diff --git a/plane-src/apps/api/plane/db/migrations/0137_external_identity_link.py b/plane-src/apps/api/plane/db/migrations/0137_external_identity_link.py new file mode 100644 index 0000000..94ec108 --- /dev/null +++ b/plane-src/apps/api/plane/db/migrations/0137_external_identity_link.py @@ -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")}, + }, + ), + ] diff --git a/plane-src/apps/api/plane/db/migrations/0138_external_identity_link_unique_user.py b/plane-src/apps/api/plane/db/migrations/0138_external_identity_link_unique_user.py new file mode 100644 index 0000000..f9037a8 --- /dev/null +++ b/plane-src/apps/api/plane/db/migrations/0138_external_identity_link_unique_user.py @@ -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")}, + ), + ] diff --git a/plane-src/apps/api/plane/db/models/__init__.py b/plane-src/apps/api/plane/db/models/__init__.py index b048cf0..288f0d7 100644 --- a/plane-src/apps/api/plane/db/models/__init__.py +++ b/plane-src/apps/api/plane/db/models/__init__.py @@ -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 diff --git a/plane-src/apps/api/plane/db/models/user.py b/plane-src/apps/api/plane/db/models/user.py index 0785280..667f000 100644 --- a/plane-src/apps/api/plane/db/models/user.py +++ b/plane-src/apps/api/plane/db/models/user.py @@ -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 diff --git a/scripts/bootstrap_nodedc_platform_plan.py b/scripts/bootstrap_nodedc_platform_plan.py new file mode 100644 index 0000000..49fa5d8 --- /dev/null +++ b/scripts/bootstrap_nodedc_platform_plan.py @@ -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"
{paragraph}
" 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()