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