From fe967bdb2d531ae9689c0ffe77804d512380b682 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sun, 28 Sep 2025 22:14:27 +0200 Subject: [PATCH] wip --- env.d/development/common.dist | 3 ++ src/backend/core/admin.py | 12 ++++- src/backend/core/external_api/viewsets.py | 16 +++++-- .../0015_serviceaccount_and_more.py | 26 ++++++++-- src/backend/core/models.py | 48 +++++++++++++++++++ src/backend/core/utils.py | 5 +- 6 files changed, 101 insertions(+), 9 deletions(-) diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 6ee5a8df..1037b042 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -62,8 +62,11 @@ SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings # Telephony ROOM_TELEPHONY_ENABLED=True +ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR' +ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304' FRONTEND_USE_FRENCH_GOV_FOOTER=False FRONTEND_USE_PROCONNECT_BUTTON=False INTEGRATIONS_JWT_SECRET_KEY=devKey +INTEGRATIONS_APP_BASE_URL: http://localhost:3000 diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 06cf1ebf..26909354 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -155,6 +155,13 @@ class RecordingAdmin(admin.ModelAdmin): return str(owners[0].user) +class ServiceAccountDomainInline(admin.TabularInline): + """Wip.""" + + model = models.ServiceAccountDomain + extra = 0 + + class ServiceAccountAdminForm(forms.ModelForm): """Wip.""" @@ -179,14 +186,15 @@ class ServiceAccountAdmin(admin.ModelAdmin): list_display = ("id", "name", "get_scopes_display") fields = ["name", "id", "created_at", "updated_at", "scopes"] readonly_fields = ["id", "created_at", "updated_at"] + inlines = [ServiceAccountDomainInline] def get_scopes_display(self, obj): """Display scopes in list view.""" if obj.scopes: return ", ".join(obj.scopes) - return "No scopes" + return _("No scopes") - get_scopes_display.short_description = "Scopes" + get_scopes_display.short_description = _("Scopes") @admin.register(models.ServiceAccountAPIKey) diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index b9ed118e..925c2280 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -49,11 +49,21 @@ class IntegrationViewSet(viewsets.GenericViewSet): api_key = models.ServiceAccountAPIKey.objects.get_from_key(key) service_account = api_key.service_account - # todo - extract all this logic in a service - # todo - check if email is allowed by the regex + email = serializer.validated_data["email"] + + if not service_account.can_impersonate_email(email): + logger.warning( + "Service account %s denied impersonation of %s", + service_account.id, + email, + ) + return drf_response.Response( + {"error": "Access denied"}, + status=drf_status.HTTP_403_FORBIDDEN, + ) try: - user = models.User.objects.get(email=serializer.validated_data["email"]) + user = models.User.objects.get(email=email) except models.User.DoesNotExist as e: # todo - create unknown user raise drf_exceptions.NotFound( diff --git a/src/backend/core/migrations/0015_serviceaccount_and_more.py b/src/backend/core/migrations/0015_serviceaccount_and_more.py index 9e8317eb..cd4cb9ce 100644 --- a/src/backend/core/migrations/0015_serviceaccount_and_more.py +++ b/src/backend/core/migrations/0015_serviceaccount_and_more.py @@ -1,7 +1,10 @@ -# Generated by Django 5.2.6 on 2025-09-28 15:41 +# Generated by Django 5.2.6 on 2025-09-28 19:40 +import django.contrib.postgres.fields +import django.core.validators import django.db.models.deletion import uuid +from django.conf import settings from django.db import migrations, models @@ -20,13 +23,13 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), ('name', models.CharField(help_text='Descriptive name for this service account.', max_length=255, verbose_name='Service account name')), ('active', models.BooleanField(default=True)), - ('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms')], max_length=50), default=list, size=None)), + ('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms')], max_length=50), blank=True, default=list, null=True, size=None)), ], options={ 'verbose_name': 'Service account', 'verbose_name_plural': 'Service accounts', 'db_table': 'meet_service_account', - 'abstract': False, + 'ordering': ('-created_at',), }, ), migrations.CreateModel( @@ -48,4 +51,21 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.CreateModel( + name='ServiceAccountDomain', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('domain', models.CharField(help_text='Email domain that can be impersonated.', max_length=253, validators=[django.core.validators.DomainNameValidator(accept_idna=False, message='Enter a valid domain')], verbose_name='Domain')), + ('service_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_domains', to='core.serviceaccount')), + ], + options={ + 'verbose_name': 'Service account domain', + 'verbose_name_plural': 'Service account domains', + 'db_table': 'meet_service_account_domain', + 'ordering': ('domain',), + 'unique_together': {('service_account', 'domain')}, + }, + ), ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index b7357f0d..84ed15aa 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -744,6 +744,8 @@ class ServiceAccount(BaseModel): scopes = ArrayField( models.CharField(max_length=50, choices=ServiceAccountScope.choices), default=list, + blank=True, + null=True, ) class Meta: @@ -755,6 +757,52 @@ class ServiceAccount(BaseModel): def __str__(self): return f"{self.name!s}" + def can_impersonate_email(self, email): + """Check if this service account can impersonate the given email.""" + + if not self.allowed_domains.exists(): + return True # No domain restrictions + + domain = email.split("@")[-1] + return self.allowed_domains.filter(domain__iexact=domain).exists() + + +class ServiceAccountDomain(BaseModel): + """Domain allowed for service account impersonation.""" + + domain = models.CharField( + max_length=253, # Max domain length per RFC + validators=[ + validators.DomainNameValidator( + accept_idna=False, + message=_("Enter a valid domain"), + ) + ], + verbose_name=_("Domain"), + help_text=_("Email domain that can be impersonated."), + ) + + service_account = models.ForeignKey( + "ServiceAccount", + on_delete=models.CASCADE, + related_name="allowed_domains", + ) + + class Meta: + db_table = "meet_service_account_domain" + ordering = ("domain",) + verbose_name = _("Service account domain") + verbose_name_plural = _("Service account domains") + unique_together = [("service_account", "domain")] + + def __str__(self): + return self.domain + + def save(self, *args, **kwargs): + # Normalize domain to lowercase + self.domain = self.domain.lower().strip() + super().save(*args, **kwargs) + class ServiceAccountAPIKey(AbstractAPIKey): """Wip.""" diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 11d8b3b7..dfac8d82 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -33,7 +33,10 @@ def generate_slug(): """Wip.""" sizes = [3, 4, 3] - parts = ["".join(secrets.choices(string.ascii_lowercase, k=size)) for size in sizes] + parts = [ + "".join(secrets.choice(string.ascii_lowercase) for _ in range(size)) + for size in sizes + ] return "-".join(parts)