diff --git a/Makefile b/Makefile index 97e150c..ef0bbd3 100644 --- a/Makefile +++ b/Makefile @@ -72,6 +72,7 @@ data/static: create-env-files: ## Copy the dist env files to env files create-env-files: \ env.d/development/common \ + env.d/development/france \ env.d/development/crowdin \ env.d/development/postgresql \ env.d/development/kc_postgresql @@ -228,6 +229,9 @@ resetdb: ## flush database and create a superuser "admin" env.d/development/common: cp -n env.d/development/common.dist env.d/development/common +env.d/development/france: + cp -n env.d/development/france.dist env.d/development/france + env.d/development/postgresql: cp -n env.d/development/postgresql.dist env.d/development/postgresql diff --git a/docker-compose.yml b/docker-compose.yml index d00cc52..9053530 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: - DJANGO_CONFIGURATION=Development env_file: - env.d/development/common + - env.d/development/france - env.d/development/postgresql ports: - "8071:8000" diff --git a/docker/auth/realm.json b/docker/auth/realm.json index c92fb9f..c69add7 100644 --- a/docker/auth/realm.json +++ b/docker/auth/realm.json @@ -65,7 +65,7 @@ "lastName": "Delamairie", "enabled": true, "attributes": { - "siret": "21580304000017" + "siret": "21510339100011" }, "credentials": [ { diff --git a/env.d/development/france.dist b/env.d/development/france.dist new file mode 100644 index 0000000..16fccc6 --- /dev/null +++ b/env.d/development/france.dist @@ -0,0 +1 @@ +ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation \ No newline at end of file diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 535777c..b82b72f 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -29,7 +29,10 @@ from timezone_field import TimeZoneField from treebeard.mp_tree import MP_Node, MP_NodeManager from core.enums import WebhookStatusChoices -from core.plugins.loader import organization_plugins_run_after_create +from core.plugins.loader import ( + organization_plugins_run_after_create, + organization_plugins_run_after_grant_access, +) from core.utils.webhooks import scim_synchronizer from core.validators import get_field_validators_from_setting @@ -298,6 +301,22 @@ class OrganizationManager(models.Manager): return instance +class OrganizationAccessManager(models.Manager): + """ + Custom manager for the OrganizationAccess model, to manage complexity/automation. + """ + + def create(self, **kwargs): + """ + Create an organization access with the given kwargs. + + This method is overridden to call the Organization plugins. + """ + instance = super().create(**kwargs) + organization_plugins_run_after_grant_access(instance) + return instance + + class Organization(BaseModel): """ Organization model used to regroup Teams. @@ -618,6 +637,8 @@ class OrganizationAccess(BaseModel): default=OrganizationRoleChoices.ADMIN, ) + objects = OrganizationAccessManager() + class Meta: db_table = "people_organization_access" verbose_name = _("Organization/user relation") @@ -979,3 +1000,7 @@ class Invitation(BaseModel): except smtplib.SMTPException as exception: logger.error("invitation to %s was not sent: %s", self.email, exception) + + +# It's not clear yet how best to split this file. +# pylint: disable=C0302 diff --git a/src/backend/core/plugins/base.py b/src/backend/core/plugins/base.py index 7737eec..17bf46b 100644 --- a/src/backend/core/plugins/base.py +++ b/src/backend/core/plugins/base.py @@ -11,3 +11,9 @@ class BaseOrganizationPlugin: def run_after_create(self, organization) -> None: """Method called after creating an organization.""" raise NotImplementedError("Plugins must implement the run_after_create method") + + def run_after_grant_access(self, organization_access) -> None: + """Method called after creating an organization.""" + raise NotImplementedError( + "Plugins must implement the run_after_grant_access method" + ) diff --git a/src/backend/core/plugins/loader.py b/src/backend/core/plugins/loader.py index 9cc274f..cea4f90 100644 --- a/src/backend/core/plugins/loader.py +++ b/src/backend/core/plugins/loader.py @@ -30,3 +30,15 @@ def organization_plugins_run_after_create(organization): """ for plugin_instance in get_organization_plugins(): plugin_instance.run_after_create(organization) + + +def organization_plugins_run_after_grant_access(organization_access): + """ + Run the after grant access method for all organization plugins. + + Each plugin will be called in the order they are listed in the settings. + Each plugin is responsible to save changes if needed, this is not optimized + but this could be easily improved later if needed. + """ + for plugin_instance in get_organization_plugins(): + plugin_instance.run_after_grant_access(organization_access) diff --git a/src/backend/people/settings.py b/src/backend/people/settings.py index 459d241..3fc8537 100755 --- a/src/backend/people/settings.py +++ b/src/backend/people/settings.py @@ -657,8 +657,6 @@ class Development(Base): OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret" - ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"] - def __init__(self): """In dev, force installs needed for Swagger API.""" # pylint: disable=invalid-name @@ -706,8 +704,6 @@ class Test(Base): OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret" - ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"] - ORGANIZATION_REGISTRATION_ID_VALIDATORS = [ { "NAME": "django.core.validators.RegexValidator", diff --git a/src/backend/plugins/organizations.py b/src/backend/plugins/organizations.py index a8f3550..32ca02d 100644 --- a/src/backend/plugins/organizations.py +++ b/src/backend/plugins/organizations.py @@ -9,6 +9,9 @@ from requests.adapters import HTTPAdapter, Retry from core.plugins.base import BaseOrganizationPlugin +from mailbox_manager.enums import MailDomainRoleChoices +from mailbox_manager.models import MailDomain, MailDomainAccess + logger = logging.getLogger(__name__) @@ -73,8 +76,9 @@ class NameFromSiretOrganizationPlugin(BaseOrganizationPlugin): organization.save(update_fields=["name", "updated_at"]) logger.info("Organization %s name updated to %s", organization, name) - def run_after_grant_access(self, organization): - pass + def run_after_grant_access(self, organization_access): + """After granting an organization access, we don't need to do anything.""" + class ApiCall: """Encapsulates a call to an external API""" @@ -85,18 +89,34 @@ class ApiCall: url: str = "" params: dict = {} headers: dict = {} - response = None + response_data = None def execute(self): """Call the specified API endpoint with supplied parameters and record response""" - if self.method == "POST": - self.response = requests.request( + if self.method in ("POST", "PATCH"): + response = requests.request( method=self.method, url=f"{self.base}/{self.url}", json=self.params, headers=self.headers, timeout=5, ) + else: + response = requests.request( + method=self.method, + url=f"{self.base}/{self.url}", + params=self.params, + headers=self.headers, + timeout=5, + ) + self.response_data = response.json() + logger.info( + "API call: %s %s %s %s", + self.method, + self.url, + self.params, + self.response_data, + ) class CommuneCreation(BaseOrganizationPlugin): @@ -111,25 +131,31 @@ class CommuneCreation(BaseOrganizationPlugin): for result in data["results"]: nature = "nature_juridique" commune = nature in result and result[nature] == "7210" - for organization in result["matching_etablissements"]: - if organization.get("siret") == siret: - if commune: - return organization["libelle_commune"].title() + if commune: + return result["siege"]["libelle_commune"].title() - logger.warning("No organization name found for SIRET %s", siret) + logger.warning("Not a commune: SIRET %s", siret) return None def dns_call(self, spec): """Call to add a DNS record""" records = [ - {"name": item["target"], "type": item["type"], "data": item["value"]} - for item in spec.response + { + "name": item["target"], + "type": item["type"].upper(), + "data": item["value"], + "ttl": 3600, + } + for item in spec.response_data ] result = ApiCall() result.method = "PATCH" result.base = "https://api.scaleway.com" - result.url = f"/domain/v2beta1/dns-zones/{spec.inputs["name"]}.collectivite.fr/records" + result.url = ( + f"/domain/v2beta1/dns-zones/{spec.inputs['name']}.collectivite.fr/records" + ) result.params = {"changes": [{"add": {"records": records}}]} + result.headers = {"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS} return result def complete_commune_creation(self, name: str) -> ApiCall: @@ -160,7 +186,7 @@ class CommuneCreation(BaseOrganizationPlugin): "context_name": f"{inputs['name']}.collectivite.fr", } create_domain.headers = { - "Authorization": f"Basic: {settings.MAIL_PROVISIONING_API_CREDENTIALS}" + "Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}" } spec_domain = ApiCall() @@ -168,7 +194,7 @@ class CommuneCreation(BaseOrganizationPlugin): spec_domain.base = settings.MAIL_PROVISIONING_API_URL spec_domain.url = f"/domains/{inputs['name']}.collectivite.fr/spec" spec_domain.headers = { - "Authorization": f"Basic: {settings.MAIL_PROVISIONING_API_CREDENTIALS}" + "Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}" } return [create_zone, create_domain, spec_domain] @@ -179,12 +205,13 @@ class CommuneCreation(BaseOrganizationPlugin): def run_after_create(self, organization): """After creating an organization, update the organization name.""" + logger.info("In CommuneCreation") if not organization.registration_id_list: # No registration ID to convert... return # In the nominal case, there is only one registration ID because - # the organization as been created from it. + # the organization has been created from it. try: # Retry logic as the API may be rate limited s = requests.Session() @@ -196,6 +223,7 @@ class CommuneCreation(BaseOrganizationPlugin): response.raise_for_status() data = response.json() name = self.get_organization_name_from_results(data, siret) + # Not a commune ? if not name: return except requests.RequestException as exc: @@ -206,5 +234,39 @@ class CommuneCreation(BaseOrganizationPlugin): organization.save(update_fields=["name", "updated_at"]) logger.info("Organization %s name updated to %s", organization, name) - def run_after_grant_access(self, organization): - pass \ No newline at end of file + MailDomain.objects.get_or_create(name=f"{name.lower()}.collectivite.fr") + + # Compute and execute the rest of the process + tasks = self.complete_commune_creation(name) + for task in tasks: + task.execute() + last_task = self.complete_zone_creation(tasks[-1]) + last_task.execute() + + def run_after_grant_access(self, organization_access): + """After granting an organization access, check for needed domain access grant.""" + orga = organization_access.organization + user = organization_access.user + zone_name = orga.name.lower() + ".collectivite.fr" + + try: + domain = MailDomain.objects.get(domain=zone_name) + except MailDomain.DoesNotExist: + domain = None + + if domain: + MailDomainAccess.objects.create( + domain=domain, user=user, role=MailDomainRoleChoices.OWNER + ) + grant_access = ApiCall() + grant_access.method = "POST" + grant_access.base = settings.MAIL_PROVISIONING_API_URL + grant_access.url = "/allows" + grant_access.params = { + "user": user.sub, + "domain": zone_name, + } + grant_access.headers = { + "Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}" + } + grant_access.execute() diff --git a/src/backend/plugins/tests/organizations/test_commune_creation.py b/src/backend/plugins/tests/organizations/test_commune_creation.py index ace82b7..6d899cc 100644 --- a/src/backend/plugins/tests/organizations/test_commune_creation.py +++ b/src/backend/plugins/tests/organizations/test_commune_creation.py @@ -96,7 +96,7 @@ def test_tasks_on_commune_creation_include_dimail_domain_creation(): } assert ( tasks[1].headers["Authorization"] - == f"Basic: {settings.MAIL_PROVISIONING_API_CREDENTIALS}" + == f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}" ) @@ -112,7 +112,7 @@ def test_tasks_on_commune_creation_include_fetching_spec(): assert tasks[2].method == "GET" assert ( tasks[2].headers["Authorization"] - == f"Basic: {settings.MAIL_PROVISIONING_API_CREDENTIALS}" + == f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}" ) @@ -143,7 +143,7 @@ def test_tasks_on_commune_creation_include_dns_records(): ] tasks = plugin.complete_commune_creation(name) - tasks[2].response = spec_response + tasks[2].response_data = spec_response expected = { "changes": [ @@ -152,8 +152,9 @@ def test_tasks_on_commune_creation_include_dns_records(): "records": [ { "name": item["target"], - "type": item["type"], + "type": item["type"].upper(), "data": item["value"], + "ttl": 3600, } for item in spec_response ] @@ -165,3 +166,6 @@ def test_tasks_on_commune_creation_include_dns_records(): zone_call = plugin.complete_zone_creation(tasks[2]) assert zone_call.params == expected assert zone_call.url == "/domain/v2beta1/dns-zones/abidos.collectivite.fr/records" + assert ( + zone_call.headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS + ) diff --git a/src/frontend/apps/e2e/__tests__/app-desk/siret.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/siret.spec.ts index 1e9515b..e6d8fe3 100644 --- a/src/frontend/apps/e2e/__tests__/app-desk/siret.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-desk/siret.spec.ts @@ -14,7 +14,7 @@ test.describe('OIDC interop with SIRET', () => { ); expect(response.ok()).toBeTruthy(); expect(await response.json()).toMatchObject({ - organization: { registration_id_list: ['21580304000017'] }, + organization: { registration_id_list: ['21510339100011'] }, }); }); }); @@ -28,6 +28,6 @@ test.describe('When a commune, display commune name below user name', () => { name: 'Marie Delamairie', }); - await expect(logout.getByText('Varzy')).toBeVisible(); + await expect(logout.getByText('Merlaut')).toBeVisible(); }); }); diff --git a/src/helm/env.d/dev-keycloak/values.desk.yaml.gotmpl b/src/helm/env.d/dev-keycloak/values.desk.yaml.gotmpl index 413ca06..68e078f 100644 --- a/src/helm/env.d/dev-keycloak/values.desk.yaml.gotmpl +++ b/src/helm/env.d/dev-keycloak/values.desk.yaml.gotmpl @@ -32,7 +32,6 @@ backend: OIDC_RP_SCOPES: "openid email siret" OIDC_REDIRECT_ALLOWED_HOSTS: https://desk.127.0.0.1.nip.io OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" - ORGANIZATION_PLUGINS: "plugins.organizations.NameFromSiretOrganizationPlugin" ORGANIZATION_REGISTRATION_ID_VALIDATORS: '[{"NAME": "django.core.validators.RegexValidator", "OPTIONS": {"regex": "^[0-9]{14}$"}}]' LOGIN_REDIRECT_URL: https://desk.127.0.0.1.nip.io LOGIN_REDIRECT_URL_FAILURE: https://desk.127.0.0.1.nip.io diff --git a/src/helm/env.d/dev/values.desk.yaml.gotmpl b/src/helm/env.d/dev/values.desk.yaml.gotmpl index b13a310..5717c33 100644 --- a/src/helm/env.d/dev/values.desk.yaml.gotmpl +++ b/src/helm/env.d/dev/values.desk.yaml.gotmpl @@ -51,7 +51,6 @@ backend: USER_OIDC_FIELDS_TO_NAME: "given_name,usual_name" OIDC_REDIRECT_ALLOWED_HOSTS: https://desk.127.0.0.1.nip.io OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" - ORGANIZATION_PLUGINS: "plugins.organizations.NameFromSiretOrganizationPlugin" ORGANIZATION_REGISTRATION_ID_VALIDATORS: '[{"NAME": "django.core.validators.RegexValidator", "OPTIONS": {"regex": "^[0-9]{14}$"}}]' LOGIN_REDIRECT_URL: https://desk.127.0.0.1.nip.io LOGIN_REDIRECT_URL_FAILURE: https://desk.127.0.0.1.nip.io