From f60bfc267650449e5e58878db64a380cc3191299 Mon Sep 17 00:00:00 2001 From: Sabrina Demagny Date: Mon, 31 Mar 2025 14:14:28 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(core)=20create=20AccountService=20mod?= =?UTF-8?q?el?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create new model to allow access of some API endpoints with API Key authentification. Scopes will allow to define permission access on those endpoints. --- CHANGELOG.md | 1 + src/backend/core/admin.py | 18 ++++++++ src/backend/core/factories.py | 10 ++++ .../core/migrations/0014_accountservice.py | 32 +++++++++++++ src/backend/core/models.py | 31 +++++++++++++ .../tests/test_models_account_services.py | 46 +++++++++++++++++++ src/backend/people/settings.py | 6 +++ 7 files changed, 144 insertions(+) create mode 100644 src/backend/core/migrations/0014_accountservice.py create mode 100644 src/backend/core/tests/test_models_account_services.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fd90c6f..a94f6b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(core) create AccountService model #771 - 🧱(helm) disable createsuperuser job by setting #863 - 🔒️(passwords) add validators for production #850 - ✨(domains) allow to re-run check on domain if status is failed diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index b565984..349326d 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -1,5 +1,6 @@ """Admin classes and registrations for People's core app.""" +from django.conf import settings from django.contrib import admin, messages from django.contrib.auth import admin as auth_admin from django.utils.translation import gettext_lazy as _ @@ -284,3 +285,20 @@ class ServiceProviderAdmin(admin.ModelAdmin): ) search_fields = ("name", "audience_id") readonly_fields = ("created_at", "updated_at") + + +@admin.register(models.AccountService) +class AccountServiceAdmin(admin.ModelAdmin): + """Admin interface for account services.""" + + list_display = ("name", "created_at", "updated_at") + readonly_fields = ("api_key", "created_at", "updated_at") + + def get_form(self, request, obj=None, change=False, **kwargs): + """Add help text to the scopes field to provide list of available scopes.""" + form = super().get_form(request, obj, change, **kwargs) + form.base_fields[ + "scopes" + ].help_text = f"Scopes define what the service can access. \ + Available scopes: {', '.join(settings.ACCOUNT_SERVICE_SCOPES)}" + return form diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 90fa14c..df9285e 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -277,3 +277,13 @@ class ServiceProviderFactory(factory.django.DjangoModelFactory): if not create or not extracted: return self.organizations.set(extracted) + + +class AccountServiceFactory(factory.django.DjangoModelFactory): + """A factory to create account services for testing purposes.""" + + class Meta: + model = models.AccountService + + name = factory.Sequence(lambda n: f"Account Service {n!s}") + api_key = factory.Faker("uuid4") diff --git a/src/backend/core/migrations/0014_accountservice.py b/src/backend/core/migrations/0014_accountservice.py new file mode 100644 index 0000000..3105fb9 --- /dev/null +++ b/src/backend/core/migrations/0014_accountservice.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.8 on 2025-04-03 20:37 + +import core.models +import django.contrib.postgres.fields +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_organization_is_active'), + ] + + operations = [ + migrations.CreateModel( + name='AccountService', + 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 at')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('api_key', models.CharField(default='Y9-IThtCrKASOvRAKO2MUBd_XvZrnSTGNMMlv-Z1_o8', max_length=255, verbose_name='api key')), + ('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, validators=[core.models.validate_account_service_scope]), help_text='Allowed scopes for this service', size=None, verbose_name='allowed scopes')), + ], + options={ + 'verbose_name': 'Account service', + 'verbose_name_plural': 'Account services', + 'db_table': 'people_account_service', + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f8dcebe..3284dcc 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -5,6 +5,7 @@ Declare and configure the models for the People core application import json import os +import secrets import smtplib import uuid from contextlib import suppress @@ -1091,3 +1092,33 @@ class Invitation(BaseInvitation): "patch": False, "put": False, } + + +def validate_account_service_scope(scope): + """Validate the scope of the account service.""" + if scope not in settings.ACCOUNT_SERVICE_SCOPES: + raise ValidationError(f"Invalid scope: {scope}") + + +class AccountService(BaseModel): + """Account service model.""" + + name = models.CharField(_("name"), max_length=255) + api_key = models.CharField( + _("api key"), + max_length=255, + default=secrets.token_urlsafe(32), + ) + scopes = ArrayField( + models.CharField(max_length=255, validators=[validate_account_service_scope]), + verbose_name=_("allowed scopes"), + help_text=_("Allowed scopes for this service"), + ) + + class Meta: + db_table = "people_account_service" + verbose_name = _("Account service") + verbose_name_plural = _("Account services") + + def __str__(self): + return self.name diff --git a/src/backend/core/tests/test_models_account_services.py b/src/backend/core/tests/test_models_account_services.py new file mode 100644 index 0000000..07f92e1 --- /dev/null +++ b/src/backend/core/tests/test_models_account_services.py @@ -0,0 +1,46 @@ +""" +Unit tests for the Team model +""" + +from django.core.exceptions import ValidationError +from django.test import override_settings + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"]) +def test_models_account_services_validate_scope(): + """Test that account service scopes are validated.""" + # Valid scope from settings.ACCOUNT_SERVICE_SCOPES + account_service = factories.AccountServiceFactory( + scopes=["la-suite-list-organizations-siret"] + ) + assert account_service.scopes == ["la-suite-list-organizations-siret"] + + # Invalid scope + with pytest.raises(ValidationError, match="Invalid scope: invalid-scope"): + factories.AccountServiceFactory(scopes=["invalid-scope"]) + + # Multiple scopes with one invalid + with pytest.raises(ValidationError, match="Invalid scope: invalid-scope"): + factories.AccountServiceFactory( + scopes=["la-suite-list-organizations-siret", "invalid-scope"] + ) + + +@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"]) +def test_models_account_services_name_null(): + """The "name" field should not be null.""" + with pytest.raises(ValidationError, match="This field cannot be null."): + models.AccountService.objects.create(name=None) + + +@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"]) +def test_models_account_services_name_empty(): + """The "name" field should not be empty.""" + with pytest.raises(ValidationError, match="This field cannot be blank."): + models.AccountService.objects.create(name="") diff --git a/src/backend/people/settings.py b/src/backend/people/settings.py index 36183c9..a3affcb 100755 --- a/src/backend/people/settings.py +++ b/src/backend/people/settings.py @@ -526,6 +526,12 @@ class Base(Configuration): environ_prefix=None, ) + ACCOUNT_SERVICE_SCOPES = values.ListValue( + default=[], + environ_name="ACCOUNT_SERVICE_SCOPES", + environ_prefix=None, + ) + # MAILBOX-PROVISIONING API WEBMAIL_URL = values.Value( default=None,