(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: \
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

View File

@@ -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"

View File

@@ -65,7 +65,7 @@
"lastName": "Delamairie",
"enabled": true,
"attributes": {
"siret": "21580304000017"
"siret": "21510339100011"
},
"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 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

View File

@@ -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"
)

View File

@@ -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)

View File

@@ -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",

View File

@@ -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
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()

View File

@@ -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
)

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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