Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion cypress/e2e/join_with_free_email_domain/fixtures.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@ VALUES
INSERT INTO organizations
(id, siret, created_at, updated_at)
VALUES
(1, '19750663700010', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
(1, '19750663700010', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(2, '11000201100044', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

INSERT INTO users_organizations
(user_id, organization_id, is_external, verification_type, has_been_greeted)
VALUES
(1, 1, false, 'domain', true);

INSERT INTO email_domains
(organization_id, domain, verification_type, verified_at)
VALUES
(2, 'finances.gouv.fr', 'verified', CURRENT_TIMESTAMP),
(2, 'ext.finances.gouv.fr', 'external', CURRENT_TIMESTAMP)
;
15 changes: 15 additions & 0 deletions cypress/e2e/join_with_free_email_domain/index.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,19 @@ describe("restrict access for", () => {
"L’accès à ce site est limité aux agentes et agents possédant une adresse email d’une administration publique.",
);
});

it("ChorusPro", function () {
cy.focused().clear().type("11000201100044");

cy.contains("Enregistrer").click();

cy.title().should("include", "Domains restreintes dans l'organisation -");
cy.contains("Accès restreint");
cy.contains(
"Seules les adresses finances.gouv.fr peuvent rejoindre l’organisation « Services de l'etat pour la facturation electronique - Destination etat via chorus pro ».",
);
cy.contains(
"Soyez sûrs d’utiliser le SIRET de l’organisation pour laquelle vous travaillez.",
);
});
});
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions packages/identite/src/data/organization/domains-whitelist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//

export const DOMAINS_WHITELIST = new Map<string, string[]>([
[
"11000201100044", // ChorusPro siret
["finances.gouv.fr"], // domains to whitelist
],
]);
3 changes: 3 additions & 0 deletions packages/identite/src/data/organization/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//

export * from "./domains-whitelist.js";
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function markDomainAsVerifiedFactory({

return match(domain_verification_type)
.with(
...EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES,
...EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES.options,
async (approved_verification_type) => {
await assignUserVerificationTypeToDomain(organization_id, domain);
return markDomainAsApproved({
Expand All @@ -69,7 +69,7 @@ export function markDomainAsVerifiedFactory({
},
)
.with(
...EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES,
...EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES.options,
(rejected_verification_type) =>
markDomainAsRejected({
organization_id,
Expand All @@ -93,7 +93,7 @@ export function markDomainAsVerifiedFactory({
organization_id,
domain,
domain_verification_types: [
...EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES,
...EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES.options,
null,
],
});
Expand All @@ -118,7 +118,7 @@ export function markDomainAsVerifiedFactory({
domain,
domain_verification_types: [
null,
...EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES,
...EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES.options,
],
});
return addDomain({
Expand Down
3 changes: 3 additions & 0 deletions packages/identite/src/services/organization/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
//

export * from "./is-domain-allowed-for-organization.js";
export * from "./is-entreprise-unipersonnelle.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { DOMAINS_WHITELIST } from "@gouvfr-lasuite/proconnect.identite/data/organization";

export function isDomainAllowedForOrganization(siret: string, domain: string) {
const whitelist = DOMAINS_WHITELIST.get(siret);

// Allow unknown siret to ignore whitelisting
if (!whitelist) return true;

return whitelist.includes(domain);
}
28 changes: 20 additions & 8 deletions packages/identite/src/types/email-domain.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
export const EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES = [
//

import { z } from "zod";

//

export const EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES = z.enum([
"official_contact",
"trackdechets_postal_mail",
"verified",
] as const;
]);

export type EmailDomainApprovedVerificationType = z.output<
typeof EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES
>;

//

export const EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES = [
export const EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES = z.enum([
"blacklisted", // unused
"external", // domain used by external employees (eg. ext.numerique.gouv.fr)
"refused",
] as const;
]);

export type EmailDomainApprovedVerificationType =
(typeof EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES)[number];
export type EmailDomainRejectedVerificationType = z.output<
typeof EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES
>;

export type EmailDomainRejectedVerificationType =
(typeof EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES)[number];
//

export type EmailDomainVerificationType =
| EmailDomainApprovedVerificationType
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
{
"data": {
"siret": "11000201100044",
"siege_social": true,
"etat_administratif": "A",
"date_fermeture": null,
"enseigne": "DESTINATION ETAT VIA CHORUS PRO",
"activite_principale": {
"code": "84.11Z",
"nomenclature": "NAFRev2",
"libelle": "Administration publique générale"
},
"tranche_effectif_salarie": {
"de": 50,
"a": 99,
"code": "21",
"date_reference": "2022",
"intitule": "50 à 99 salariés"
},
"diffusable_commercialement": true,
"status_diffusion": "diffusible",
"date_creation": 815785200,
"unite_legale": {
"siren": "110002011",
"rna": null,
"siret_siege_social": "11000201100044",
"type": "personne_morale",
"personne_morale_attributs": {
"raison_sociale": "SERVICES DE L'ETAT POUR LA FACTURATION ELECTRONIQUE",
"sigle": null
},
"personne_physique_attributs": {
"pseudonyme": null,
"prenom_usuel": null,
"prenom_1": null,
"prenom_2": null,
"prenom_3": null,
"prenom_4": null,
"nom_usage": null,
"nom_naissance": null,
"sexe": null
},
"categorie_entreprise": "PME",
"status_diffusion": "diffusible",
"diffusable_commercialement": true,
"forme_juridique": {
"code": "7120",
"libelle": "Service central d'un ministère"
},
"activite_principale": {
"code": "84.11Z",
"nomenclature": "NAFRev2",
"libelle": "Administration publique générale"
},
"tranche_effectif_salarie": {
"de": 50,
"a": 99,
"code": "21",
"date_reference": "2022",
"intitule": "50 à 99 salariés"
},
"economie_sociale_et_solidaire": false,
"date_creation": 362095200,
"etat_administratif": "A"
},
"adresse": {
"status_diffusion": "diffusible",
"complement_adresse": null,
"numero_voie": "139",
"indice_repetition_voie": null,
"type_voie": "RUE",
"libelle_voie": "DE BERCY",
"code_postal": "75012",
"libelle_commune": "PARIS",
"libelle_commune_etranger": null,
"distribution_speciale": null,
"code_commune": "75112",
"code_cedex": null,
"libelle_cedex": null,
"code_pays_etranger": null,
"libelle_pays_etranger": null,
"acheminement_postal": {
"l1": "SERVICES DE L'ETAT POUR LA FACTURATION ELECTRONIQUE",
"l2": "",
"l3": "",
"l4": "139 RUE DE BERCY",
"l5": "",
"l6": "75012 PARIS",
"l7": "FRANCE"
}
}
},
"links": {
"unite_legale": "https://entreprise.api.gouv.fr/v3/insee/sirene/unites_legales/110002011"
},
"meta": {
"date_derniere_mise_a_jour": 1737241200,
"redirect_from_siret": null
}
}
10 changes: 10 additions & 0 deletions src/config/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export class AccessRestrictedToPublicServiceEmailError extends Error {
}
}

export class DomainRestrictedError extends Error {
constructor(
public organizationId: number,
options?: ErrorOptions,
) {
super("", options);
this.name = "DomainRestrictedError";
}
}

export class InvalidCredentialsError extends Error {}

export class EmailUnavailableError extends Error {}
Expand Down
40 changes: 40 additions & 0 deletions src/controllers/organization.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getEmailDomain } from "@gouvfr-lasuite/proconnect.core/services/email";
import { EntrepriseApiError } from "@gouvfr-lasuite/proconnect.entreprise/types";
import { DOMAINS_WHITELIST } from "@gouvfr-lasuite/proconnect.identite/data/organization";
import {
InvalidCertificationError,
InvalidSiretError,
Expand All @@ -12,6 +13,7 @@ import { isEmpty } from "lodash-es";
import { z, ZodError } from "zod";
import {
AccessRestrictedToPublicServiceEmailError,
DomainRestrictedError,
UnableToAutoJoinOrganizationError,
UserAlreadyAskedToJoinOrganizationError,
UserInOrganizationAlreadyError,
Expand Down Expand Up @@ -144,6 +146,12 @@ export const postJoinOrganizationMiddleware = async (
return res.redirect(`/users/access-restricted-to-public-sector-email`);
}

if (error instanceof DomainRestrictedError) {
return res.redirect(
`/users/domains-restricted-in-organization?organization_id=${error.organizationId}`,
);
}

if (
error instanceof InvalidSiretError ||
error instanceof OrganizationNotActiveError ||
Expand Down Expand Up @@ -176,6 +184,38 @@ export const postJoinOrganizationMiddleware = async (
}
};

export const getDomainsRestrictedInOrganizationController = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const schema = z.object({
organization_id: idSchema(),
});

const { organization_id } = await schema.parseAsync(req.query);

const organization = await getOrganizationById(organization_id);
if (isEmpty(organization)) {
return next(new HttpErrors.NotFound());
}
const whitelist = DOMAINS_WHITELIST.get(organization.siret);
if (!whitelist) {
return next(new HttpErrors.NotFound());
}

return res.render("user/access-restricted-to-domains", {
pageTitle: "Domains restreintes dans l'organisation",
csrfToken: csrfToken(req),
organization_label: organization.cached_libelle,
organization_domains: whitelist.join(", "),
});
} catch (error) {
next(error);
}
};

export const getJoinOrganizationConfirmController = async (
req: Request,
res: Response,
Expand Down
10 changes: 9 additions & 1 deletion src/managers/organization/join.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
OrganizationNotFoundError,
} from "@gouvfr-lasuite/proconnect.identite/errors";
import { forceJoinOrganizationFactory } from "@gouvfr-lasuite/proconnect.identite/managers/organization";
import { isEntrepriseUnipersonnelle } from "@gouvfr-lasuite/proconnect.identite/services/organization";
import {
isDomainAllowedForOrganization,
isEntrepriseUnipersonnelle,
} from "@gouvfr-lasuite/proconnect.identite/services/organization";
import type {
Organization,
OrganizationInfo,
Expand All @@ -26,6 +29,7 @@ import {
} from "../../config/env";
import {
AccessRestrictedToPublicServiceEmailError,
DomainRestrictedError,
UnableToAutoJoinOrganizationError,
UserAlreadyAskedToJoinOrganizationError,
UserInOrganizationAlreadyError,
Expand Down Expand Up @@ -176,6 +180,10 @@ export const joinOrganization = async ({
const organizationEmailDomains =
await findEmailDomainsByOrganizationId(organization_id);

if (!isDomainAllowedForOrganization(siret, domain)) {
throw new DomainRestrictedError(organization_id);
}

if (certificationRequested) {
const isDirigeant = await isOrganizationDirigeant(siret, user_id);

Expand Down
8 changes: 8 additions & 0 deletions src/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import nocache from "nocache";
import { HOST } from "../config/env";
import {
getAccessRestrictedToPublicSectorEmailController,
getDomainsRestrictedInOrganizationController,
getJoinOrganizationConfirmController,
getJoinOrganizationController,
getOrganizationSuggestionsController,
Expand Down Expand Up @@ -402,6 +403,13 @@ export const userRouter = () => {
getJoinOrganizationConfirmController,
);

userRouter.get(
"/domains-restricted-in-organization",
checkUserHasPersonalInformationsMiddleware,
csrfProtectionMiddleware,
getDomainsRestrictedInOrganizationController,
);

userRouter.get(
"/unable-to-auto-join-organization",
checkUserHasPersonalInformationsMiddleware,
Expand Down
Loading