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

This commit is contained in:
DCCONSTRUCTIONS
2026-05-04 13:01:21 +03:00
parent 119d503d96
commit 561d1eeef5
10 changed files with 1244 additions and 1 deletions
@@ -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