Skip to content

Commit a93ff31

Browse files
committed
fix: migrate auth to persistent IDs
This also tries to consolidate the data.
1 parent 744a4a1 commit a93ff31

9 files changed

Lines changed: 896 additions & 23 deletions

File tree

weblate_web/admin.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
Package,
2727
Post,
2828
Project,
29+
SamlIdentity,
2930
Service,
3031
Subscription,
3132
SubscriptionPastPayment,
@@ -123,6 +124,13 @@ class ImageAdmin(admin.ModelAdmin):
123124
search_fields = ("name",)
124125

125126

127+
@admin.register(SamlIdentity)
128+
class SamlIdentityAdmin(admin.ModelAdmin):
129+
list_display = ("provider", "external_id", "user", "last_seen")
130+
search_fields = ("provider", "external_id", "user__username", "user__email")
131+
autocomplete_fields = ("user",)
132+
133+
126134
@admin.register(Post)
127135
class PostAdmin(admin.ModelAdmin):
128136
list_display = ["title", "timestamp", "topic", "image", "milestone"]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#
2+
# Copyright © Michal Čihař <michal@weblate.org>
3+
#
4+
# This file is part of Weblate <https://weblate.org/>
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
from __future__ import annotations
21+
22+
from django.contrib.auth.models import User
23+
from django.core.management.base import BaseCommand
24+
from django.db.models import Count
25+
from django.db.models.functions import Lower
26+
27+
from weblate_web.crm.models import Interaction
28+
from weblate_web.models import Service
29+
from weblate_web.payments.models import Customer
30+
31+
32+
class Command(BaseCommand):
33+
help = "audits users before switching SAML to persistent hosted IDs"
34+
35+
def handle(self, *args, **options) -> None:
36+
duplicates = (
37+
User.objects.exclude(email="")
38+
.annotate(email_lower=Lower("email"))
39+
.values("email_lower")
40+
.annotate(count=Count("id"))
41+
.filter(count__gt=1)
42+
.order_by("email_lower")
43+
)
44+
45+
for duplicate in duplicates:
46+
email = duplicate["email_lower"]
47+
self.stdout.write(f"Duplicate email: {email}")
48+
users = User.objects.filter(email__iexact=email).order_by("id")
49+
for user in users:
50+
customers = Customer.objects.filter(users=user)
51+
customer_ids = list(customers.values_list("id", flat=True))
52+
service_count = Service.objects.filter(customer__in=customers).count()
53+
interaction_count = Interaction.objects.filter(user=user).count()
54+
identities = [
55+
f"{identity.provider}:{identity.external_id}"
56+
for identity in user.saml_identities.all()
57+
]
58+
self.stdout.write(
59+
" "
60+
f"id={user.id} username={user.username!r} "
61+
f"last_login={user.last_login} "
62+
f"customers={customer_ids} services={service_count} "
63+
f"crm_interactions={interaction_count} "
64+
f"saml_identities={identities}"
65+
)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#
2+
# Copyright © Michal Čihař <michal@weblate.org>
3+
#
4+
# This file is part of Weblate <https://weblate.org/>
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
from __future__ import annotations
21+
22+
import requests
23+
from django.conf import settings
24+
from django.core.management.base import BaseCommand
25+
from django.core.signing import BadSignature, SignatureExpired, dumps, loads
26+
27+
from weblate_web.models import ExternalSyncState
28+
from weblate_web.saml import sync_saml_payload
29+
30+
SYNC_KEY = "hosted-users"
31+
32+
33+
class Command(BaseCommand):
34+
help = "synchronizes hosted.weblate.org users with local SAML identities"
35+
36+
def add_arguments(self, parser) -> None:
37+
parser.add_argument("--since", help="override the stored hosted sync cursor")
38+
39+
def handle(self, *args, **options) -> None:
40+
state, _created = ExternalSyncState.objects.get_or_create(key=SYNC_KEY)
41+
cursor = options["since"] or state.cursor
42+
request_payload = {"since": cursor}
43+
response = requests.get(
44+
settings.HOSTED_USER_SYNC_API,
45+
params={
46+
"payload": dumps(
47+
request_payload,
48+
key=settings.PAYMENT_SECRET,
49+
salt="weblate.user-sync",
50+
)
51+
},
52+
timeout=60,
53+
)
54+
response.raise_for_status()
55+
try:
56+
payload = loads(
57+
response.json()["payload"],
58+
key=settings.PAYMENT_SECRET,
59+
max_age=300,
60+
salt="weblate.user-sync-response",
61+
)
62+
except (BadSignature, KeyError, SignatureExpired) as error:
63+
raise RuntimeError("Invalid hosted user sync response") from error
64+
65+
count = 0
66+
for user_payload in payload.get("users", []):
67+
user, _created = sync_saml_payload(user_payload)
68+
if user is not None:
69+
count += 1
70+
71+
if cursor := payload.get("cursor"):
72+
state.cursor = cursor
73+
state.save(update_fields=("cursor", "updated"))
74+
75+
self.stdout.write(f"Synchronized {count} hosted users")
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#
2+
# Copyright © Michal Čihař <michal@weblate.org>
3+
#
4+
# This file is part of Weblate <https://weblate.org/>
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
from __future__ import annotations
21+
22+
import django.core.serializers.json
23+
import django.db.models.deletion
24+
import django.utils.timezone
25+
from django.conf import settings
26+
from django.db import migrations, models
27+
28+
29+
class Migration(migrations.Migration):
30+
dependencies = [
31+
("weblate_web", "0050_subscription_payment_fk"),
32+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
33+
]
34+
35+
operations = [
36+
migrations.CreateModel(
37+
name="ExternalSyncState",
38+
fields=[
39+
(
40+
"id",
41+
models.AutoField(
42+
auto_created=True,
43+
primary_key=True,
44+
serialize=False,
45+
verbose_name="ID",
46+
),
47+
),
48+
("key", models.CharField(max_length=100, unique=True)),
49+
("cursor", models.CharField(blank=True, max_length=255)),
50+
("updated", models.DateTimeField(auto_now=True)),
51+
],
52+
options={
53+
"verbose_name": "External sync state",
54+
"verbose_name_plural": "External sync states",
55+
},
56+
),
57+
migrations.CreateModel(
58+
name="SamlIdentity",
59+
fields=[
60+
(
61+
"id",
62+
models.AutoField(
63+
auto_created=True,
64+
primary_key=True,
65+
serialize=False,
66+
verbose_name="ID",
67+
),
68+
),
69+
("provider", models.CharField(max_length=255)),
70+
("external_id", models.CharField(max_length=255)),
71+
(
72+
"last_seen",
73+
models.DateTimeField(default=django.utils.timezone.now),
74+
),
75+
(
76+
"raw_attrs",
77+
models.JSONField(
78+
blank=True,
79+
default=dict,
80+
encoder=django.core.serializers.json.DjangoJSONEncoder,
81+
),
82+
),
83+
(
84+
"user",
85+
models.ForeignKey(
86+
on_delete=django.db.models.deletion.CASCADE,
87+
related_name="saml_identities",
88+
to=settings.AUTH_USER_MODEL,
89+
),
90+
),
91+
],
92+
options={
93+
"verbose_name": "SAML identity",
94+
"verbose_name_plural": "SAML identities",
95+
},
96+
),
97+
migrations.AddConstraint(
98+
model_name="samlidentity",
99+
constraint=models.UniqueConstraint(
100+
fields=("provider", "external_id"),
101+
name="weblate_web_saml_identity_unique",
102+
),
103+
),
104+
]

weblate_web/models.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,43 @@
8686
)
8787
MINIMUM_UPGRADE_PAYMENT = Decimal(5)
8888

89+
90+
class SamlIdentity(models.Model):
91+
provider = models.CharField(max_length=255)
92+
external_id = models.CharField(max_length=255)
93+
user = models.ForeignKey(
94+
User, on_delete=models.CASCADE, related_name="saml_identities"
95+
)
96+
last_seen = models.DateTimeField(default=timezone.now)
97+
raw_attrs = models.JSONField(default=dict, blank=True, encoder=DjangoJSONEncoder)
98+
99+
class Meta:
100+
verbose_name = "SAML identity"
101+
verbose_name_plural = "SAML identities"
102+
constraints = [
103+
models.UniqueConstraint(
104+
fields=("provider", "external_id"),
105+
name="weblate_web_saml_identity_unique",
106+
)
107+
]
108+
109+
def __str__(self) -> str:
110+
return f"{self.provider}:{self.external_id}"
111+
112+
113+
class ExternalSyncState(models.Model):
114+
key = models.CharField(max_length=100, unique=True)
115+
cursor = models.CharField(max_length=255, blank=True)
116+
updated = models.DateTimeField(auto_now=True)
117+
118+
class Meta:
119+
verbose_name = "External sync state"
120+
verbose_name_plural = "External sync states"
121+
122+
def __str__(self) -> str:
123+
return self.key
124+
125+
89126
REWARDS = (
90127
(0, gettext_lazy("No reward")),
91128
(1, gettext_lazy("Name in the list of supporters")),

0 commit comments

Comments
 (0)