Skip to content

Commit 68e5c23

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

9 files changed

Lines changed: 1240 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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 typing import NoReturn
23+
24+
import requests
25+
from django.conf import settings
26+
from django.core.management.base import BaseCommand
27+
from django.core.signing import BadSignature, SignatureExpired, dumps, loads
28+
from django.db import DataError, IntegrityError
29+
30+
from weblate_web.models import ExternalSyncState
31+
from weblate_web.saml import AmbiguousSamlIdentityError, sync_saml_payload
32+
33+
SYNC_KEY = "hosted-users"
34+
INVALID_SYNC_RESPONSE = "Invalid hosted user sync response"
35+
36+
37+
def raise_invalid_sync_response() -> NoReturn:
38+
raise RuntimeError(INVALID_SYNC_RESPONSE)
39+
40+
41+
class Command(BaseCommand):
42+
help = "synchronizes hosted.weblate.org users with local SAML identities"
43+
44+
def add_arguments(self, parser) -> None:
45+
parser.add_argument("--since", help="override the stored hosted sync cursor")
46+
47+
def handle(self, *args, **options) -> None:
48+
state, _created = ExternalSyncState.objects.get_or_create(key=SYNC_KEY)
49+
cursor = options["since"] or state.cursor
50+
request_payload = {"since": cursor}
51+
response = requests.post(
52+
settings.HOSTED_USER_SYNC_API,
53+
data={
54+
"payload": dumps(
55+
request_payload,
56+
key=settings.PAYMENT_SECRET,
57+
salt="weblate.user-sync",
58+
)
59+
},
60+
timeout=60,
61+
)
62+
response.raise_for_status()
63+
try:
64+
response_payload = response.json()
65+
except ValueError as error:
66+
raise RuntimeError(INVALID_SYNC_RESPONSE) from error
67+
if not isinstance(response_payload, dict):
68+
raise_invalid_sync_response()
69+
signed_payload = response_payload.get("payload")
70+
if not isinstance(signed_payload, str):
71+
raise_invalid_sync_response()
72+
try:
73+
payload = loads(
74+
signed_payload,
75+
key=settings.PAYMENT_SECRET,
76+
max_age=300,
77+
salt="weblate.user-sync-response",
78+
)
79+
except (BadSignature, SignatureExpired) as error:
80+
raise RuntimeError(INVALID_SYNC_RESPONSE) from error
81+
if not isinstance(payload, dict):
82+
raise_invalid_sync_response()
83+
84+
count = 0
85+
skipped = False
86+
for user_payload in payload.get("users", []):
87+
try:
88+
user, _created = sync_saml_payload(user_payload)
89+
except (
90+
AmbiguousSamlIdentityError,
91+
AttributeError,
92+
DataError,
93+
IntegrityError,
94+
KeyError,
95+
TypeError,
96+
ValueError,
97+
) as error:
98+
skipped = True
99+
self.stderr.write(f"Skipping hosted user payload: {error}")
100+
continue
101+
if user is None:
102+
skipped = True
103+
self.stderr.write("Skipping hosted user payload: user not synchronized")
104+
continue
105+
count += 1
106+
107+
if skipped:
108+
self.stderr.write("Not advancing hosted user sync cursor")
109+
elif cursor := payload.get("cursor"):
110+
state.cursor = cursor
111+
state.save(update_fields=("cursor", "updated"))
112+
113+
self.stdout.write(f"Synchronized {count} hosted users")
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
migrations.AddConstraint(
105+
model_name="samlidentity",
106+
constraint=models.UniqueConstraint(
107+
fields=("provider", "user"),
108+
name="weblate_web_saml_identity_user_unique",
109+
),
110+
),
111+
]

weblate_web/models.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,47 @@
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+
models.UniqueConstraint(
108+
fields=("provider", "user"),
109+
name="weblate_web_saml_identity_user_unique",
110+
),
111+
]
112+
113+
def __str__(self) -> str:
114+
return f"{self.provider}:{self.external_id}"
115+
116+
117+
class ExternalSyncState(models.Model):
118+
key = models.CharField(max_length=100, unique=True)
119+
cursor = models.CharField(max_length=255, blank=True)
120+
updated = models.DateTimeField(auto_now=True)
121+
122+
class Meta:
123+
verbose_name = "External sync state"
124+
verbose_name_plural = "External sync states"
125+
126+
def __str__(self) -> str:
127+
return self.key
128+
129+
89130
REWARDS = (
90131
(0, gettext_lazy("No reward")),
91132
(1, gettext_lazy("Name in the list of supporters")),

0 commit comments

Comments
 (0)