(plugin) add CommuneCreation plugin

Extend plugin mechanism to be able to grant domain admin in Dimail
This commit is contained in:
Laurent Bossavit
2025-01-22 15:32:15 +01:00
committed by Laurent Bossavit
parent dc938d3159
commit 471f69d4ec
13 changed files with 141 additions and 32 deletions

View File

@@ -72,6 +72,7 @@ data/static:
create-env-files: ## Copy the dist env files to env files create-env-files: ## Copy the dist env files to env files
create-env-files: \ create-env-files: \
env.d/development/common \ env.d/development/common \
env.d/development/france \
env.d/development/crowdin \ env.d/development/crowdin \
env.d/development/postgresql \ env.d/development/postgresql \
env.d/development/kc_postgresql env.d/development/kc_postgresql
@@ -228,6 +229,9 @@ resetdb: ## flush database and create a superuser "admin"
env.d/development/common: env.d/development/common:
cp -n env.d/development/common.dist 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: env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql cp -n env.d/development/postgresql.dist env.d/development/postgresql

View File

@@ -27,6 +27,7 @@ services:
- DJANGO_CONFIGURATION=Development - DJANGO_CONFIGURATION=Development
env_file: env_file:
- env.d/development/common - env.d/development/common
- env.d/development/france
- env.d/development/postgresql - env.d/development/postgresql
ports: ports:
- "8071:8000" - "8071:8000"

View File

@@ -65,7 +65,7 @@
"lastName": "Delamairie", "lastName": "Delamairie",
"enabled": true, "enabled": true,
"attributes": { "attributes": {
"siret": "21580304000017" "siret": "21510339100011"
}, },
"credentials": [ "credentials": [
{ {

View File

@@ -0,0 +1 @@
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation

View File

@@ -29,7 +29,10 @@ from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager from treebeard.mp_tree import MP_Node, MP_NodeManager
from core.enums import WebhookStatusChoices 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.utils.webhooks import scim_synchronizer
from core.validators import get_field_validators_from_setting from core.validators import get_field_validators_from_setting
@@ -298,6 +301,22 @@ class OrganizationManager(models.Manager):
return instance 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): class Organization(BaseModel):
""" """
Organization model used to regroup Teams. Organization model used to regroup Teams.
@@ -618,6 +637,8 @@ class OrganizationAccess(BaseModel):
default=OrganizationRoleChoices.ADMIN, default=OrganizationRoleChoices.ADMIN,
) )
objects = OrganizationAccessManager()
class Meta: class Meta:
db_table = "people_organization_access" db_table = "people_organization_access"
verbose_name = _("Organization/user relation") verbose_name = _("Organization/user relation")
@@ -979,3 +1000,7 @@ class Invitation(BaseModel):
except smtplib.SMTPException as exception: except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, 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

View File

@@ -11,3 +11,9 @@ class BaseOrganizationPlugin:
def run_after_create(self, organization) -> None: def run_after_create(self, organization) -> None:
"""Method called after creating an organization.""" """Method called after creating an organization."""
raise NotImplementedError("Plugins must implement the run_after_create method") 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"
)

View File

@@ -30,3 +30,15 @@ def organization_plugins_run_after_create(organization):
""" """
for plugin_instance in get_organization_plugins(): for plugin_instance in get_organization_plugins():
plugin_instance.run_after_create(organization) 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)

View File

@@ -657,8 +657,6 @@ class Development(Base):
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret" OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret"
ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"]
def __init__(self): def __init__(self):
"""In dev, force installs needed for Swagger API.""" """In dev, force installs needed for Swagger API."""
# pylint: disable=invalid-name # pylint: disable=invalid-name
@@ -706,8 +704,6 @@ class Test(Base):
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret" OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret"
ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"]
ORGANIZATION_REGISTRATION_ID_VALIDATORS = [ ORGANIZATION_REGISTRATION_ID_VALIDATORS = [
{ {
"NAME": "django.core.validators.RegexValidator", "NAME": "django.core.validators.RegexValidator",

View File

@@ -9,6 +9,9 @@ from requests.adapters import HTTPAdapter, Retry
from core.plugins.base import BaseOrganizationPlugin from core.plugins.base import BaseOrganizationPlugin
from mailbox_manager.enums import MailDomainRoleChoices
from mailbox_manager.models import MailDomain, MailDomainAccess
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,8 +76,9 @@ class NameFromSiretOrganizationPlugin(BaseOrganizationPlugin):
organization.save(update_fields=["name", "updated_at"]) organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name) logger.info("Organization %s name updated to %s", organization, name)
def run_after_grant_access(self, organization): def run_after_grant_access(self, organization_access):
pass """After granting an organization access, we don't need to do anything."""
class ApiCall: class ApiCall:
"""Encapsulates a call to an external API""" """Encapsulates a call to an external API"""
@@ -85,18 +89,34 @@ class ApiCall:
url: str = "" url: str = ""
params: dict = {} params: dict = {}
headers: dict = {} headers: dict = {}
response = None response_data = None
def execute(self): def execute(self):
"""Call the specified API endpoint with supplied parameters and record response""" """Call the specified API endpoint with supplied parameters and record response"""
if self.method == "POST": if self.method in ("POST", "PATCH"):
self.response = requests.request( response = requests.request(
method=self.method, method=self.method,
url=f"{self.base}/{self.url}", url=f"{self.base}/{self.url}",
json=self.params, json=self.params,
headers=self.headers, headers=self.headers,
timeout=5, 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): class CommuneCreation(BaseOrganizationPlugin):
@@ -111,25 +131,31 @@ class CommuneCreation(BaseOrganizationPlugin):
for result in data["results"]: for result in data["results"]:
nature = "nature_juridique" nature = "nature_juridique"
commune = nature in result and result[nature] == "7210" commune = nature in result and result[nature] == "7210"
for organization in result["matching_etablissements"]: if commune:
if organization.get("siret") == siret: return result["siege"]["libelle_commune"].title()
if commune:
return organization["libelle_commune"].title()
logger.warning("No organization name found for SIRET %s", siret) logger.warning("Not a commune: SIRET %s", siret)
return None return None
def dns_call(self, spec): def dns_call(self, spec):
"""Call to add a DNS record""" """Call to add a DNS record"""
records = [ 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 = ApiCall()
result.method = "PATCH" result.method = "PATCH"
result.base = "https://api.scaleway.com" 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.params = {"changes": [{"add": {"records": records}}]}
result.headers = {"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS}
return result return result
def complete_commune_creation(self, name: str) -> ApiCall: def complete_commune_creation(self, name: str) -> ApiCall:
@@ -160,7 +186,7 @@ class CommuneCreation(BaseOrganizationPlugin):
"context_name": f"{inputs['name']}.collectivite.fr", "context_name": f"{inputs['name']}.collectivite.fr",
} }
create_domain.headers = { create_domain.headers = {
"Authorization": f"Basic: {settings.MAIL_PROVISIONING_API_CREDENTIALS}" "Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
} }
spec_domain = ApiCall() spec_domain = ApiCall()
@@ -168,7 +194,7 @@ class CommuneCreation(BaseOrganizationPlugin):
spec_domain.base = settings.MAIL_PROVISIONING_API_URL spec_domain.base = settings.MAIL_PROVISIONING_API_URL
spec_domain.url = f"/domains/{inputs['name']}.collectivite.fr/spec" spec_domain.url = f"/domains/{inputs['name']}.collectivite.fr/spec"
spec_domain.headers = { 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] return [create_zone, create_domain, spec_domain]
@@ -179,12 +205,13 @@ class CommuneCreation(BaseOrganizationPlugin):
def run_after_create(self, organization): def run_after_create(self, organization):
"""After creating an organization, update the organization name.""" """After creating an organization, update the organization name."""
logger.info("In CommuneCreation")
if not organization.registration_id_list: if not organization.registration_id_list:
# No registration ID to convert... # No registration ID to convert...
return return
# In the nominal case, there is only one registration ID because # 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: try:
# Retry logic as the API may be rate limited # Retry logic as the API may be rate limited
s = requests.Session() s = requests.Session()
@@ -196,6 +223,7 @@ class CommuneCreation(BaseOrganizationPlugin):
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
name = self.get_organization_name_from_results(data, siret) name = self.get_organization_name_from_results(data, siret)
# Not a commune ?
if not name: if not name:
return return
except requests.RequestException as exc: except requests.RequestException as exc:
@@ -206,5 +234,39 @@ class CommuneCreation(BaseOrganizationPlugin):
organization.save(update_fields=["name", "updated_at"]) organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name) logger.info("Organization %s name updated to %s", organization, name)
def run_after_grant_access(self, organization): MailDomain.objects.get_or_create(name=f"{name.lower()}.collectivite.fr")
pass
# 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()

View File

@@ -96,7 +96,7 @@ def test_tasks_on_commune_creation_include_dimail_domain_creation():
} }
assert ( assert (
tasks[1].headers["Authorization"] 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].method == "GET"
assert ( assert (
tasks[2].headers["Authorization"] 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 = plugin.complete_commune_creation(name)
tasks[2].response = spec_response tasks[2].response_data = spec_response
expected = { expected = {
"changes": [ "changes": [
@@ -152,8 +152,9 @@ def test_tasks_on_commune_creation_include_dns_records():
"records": [ "records": [
{ {
"name": item["target"], "name": item["target"],
"type": item["type"], "type": item["type"].upper(),
"data": item["value"], "data": item["value"],
"ttl": 3600,
} }
for item in spec_response 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]) zone_call = plugin.complete_zone_creation(tasks[2])
assert zone_call.params == expected assert zone_call.params == expected
assert zone_call.url == "/domain/v2beta1/dns-zones/abidos.collectivite.fr/records" assert zone_call.url == "/domain/v2beta1/dns-zones/abidos.collectivite.fr/records"
assert (
zone_call.headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
)

View File

@@ -14,7 +14,7 @@ test.describe('OIDC interop with SIRET', () => {
); );
expect(response.ok()).toBeTruthy(); expect(response.ok()).toBeTruthy();
expect(await response.json()).toMatchObject({ 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', name: 'Marie Delamairie',
}); });
await expect(logout.getByText('Varzy')).toBeVisible(); await expect(logout.getByText('Merlaut')).toBeVisible();
}); });
}); });

View File

@@ -32,7 +32,6 @@ backend:
OIDC_RP_SCOPES: "openid email siret" OIDC_RP_SCOPES: "openid email siret"
OIDC_REDIRECT_ALLOWED_HOSTS: https://desk.127.0.0.1.nip.io OIDC_REDIRECT_ALLOWED_HOSTS: https://desk.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" 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}$"}}]' 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: https://desk.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://desk.127.0.0.1.nip.io LOGIN_REDIRECT_URL_FAILURE: https://desk.127.0.0.1.nip.io

View File

@@ -51,7 +51,6 @@ backend:
USER_OIDC_FIELDS_TO_NAME: "given_name,usual_name" USER_OIDC_FIELDS_TO_NAME: "given_name,usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://desk.127.0.0.1.nip.io OIDC_REDIRECT_ALLOWED_HOSTS: https://desk.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" 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}$"}}]' 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: https://desk.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://desk.127.0.0.1.nip.io LOGIN_REDIRECT_URL_FAILURE: https://desk.127.0.0.1.nip.io