АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Plane OIDC mapping для существующего пользователя
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user