✨(plugin) add CommuneCreation plugin
Extend plugin mechanism to be able to grant domain admin in Dimail
This commit is contained in:
committed by
Laurent Bossavit
parent
dc938d3159
commit
471f69d4ec
4
Makefile
4
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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"lastName": "Delamairie",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "21580304000017"
|
||||
"siret": "21510339100011"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
|
||||
1
env.d/development/france.dist
Normal file
1
env.d/development/france.dist
Normal file
@@ -0,0 +1 @@
|
||||
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user