Compare commits

...

121 Commits

Author SHA1 Message Date
Eléonore Voisin a073e491b1 🐛(front) fix missing pagination mail domains
fix position div ref
2025-07-02 14:16:31 +02:00
Eléonore Voisin efd137e328 🐛(front) fix missing pagination mail domains
fix missing pagination mail domains + mailboxes list
2025-07-02 00:03:06 +02:00
Marie PUPO JEAMMET 10dcdfc8c2 🐛(webhook) fix error raised when no secret
Our Tchap webhook contains no secret (as a dedicated access token is provided)
This lead to an error upon trying to join rooms.
2025-06-23 17:28:41 +02:00
elvoisin f30398fbc7 🐛(front) fix button add mail domain (#932)
fix button rules + fix bad wording
2025-06-23 16:27:52 +02:00
Marie PUPO JEAMMET cc39ed5298 (teams) add matrix webhook for teams
A webhook to invite/kick team members to a matrix room.
2025-06-21 00:15:16 +02:00
Marie PUPO JEAMMET 7bebf13d88 🐛(domains) reduce logs around domain invitations
reduce logs and add tests around domain invitations
2025-06-21 00:15:16 +02:00
Quentin BEY e64a34f167 🧑‍💻(keycloak) command to add new client
This introduce a command to create a new client into the "people" realm.
This could be use to create a specific client to test the resource
server mode on a local deployment:
 - run the people stack
 - add the new client, let say, for docs
 - configure the people backend for token introspection
 - make calls from docs backend to people's backend

The new client is not mandatory because the same client could be used
everywhere but this would not demonstrate the fact the introspection
works in a real world configuration.
2025-06-21 00:15:16 +02:00
Quentin BEY 3379d6d499 🔧(git) set LF line endings for all text files
Windows users are by default using CRLF line endings,
which can cause issues with some tools and
environments. This commit sets the `.gitattributes`
file to enforce LF line endings for all text
files in the repository.

Based on the same commit on docs
2025-06-21 00:15:16 +02:00
Quentin BEY 213656fc2e 🧑‍💻(docker) split frontend to another file
This commit aims at improving the user experience:
- Use a dedicated `Dockerfile` for the frontend
- Run the backend and frontend in "watch"/dev mode in Docker
- Do not start all Docker instances for small tasks
2025-06-21 00:15:16 +02:00
Quentin BEY 4dfd682cb6 (resource-server) add SCIM /Me endpoint
This provide a "self-care" SCIM endpoint, authenticated with OIDC token
introspection. This endpoint will be use by services to fetch the user's
team list.

We chose to use the SCIM format (even if this is not a SCIM context) to
make it easier to understand/maintain/plug.
2025-06-21 00:15:16 +02:00
mjeammet 160c45bf35 🌐(i18n) update translated strings
Update translated files with new translations
2025-06-11 11:40:56 +02:00
Marie PUPO JEAMMET 3fdd8a230c 🔖(minor) release version 1.17.0
Update all version files and changelog for minor release.
2025-06-11 11:40:56 +02:00
renovate[bot] b47246826e ⬆️(dependencies) manually update python dependencies
Many of our dependencies still aren't compatible with Redis 6.
This results in a partial upgrade of our dependencies.
2025-06-10 21:59:41 +02:00
Marie PUPO JEAMMET 5429354261 🐛(config) whitelist kube pod
Whitelist our pod's IP address. Based on Visio's PR
https://github.com/suitenumerique/meet/pull/95
2025-06-10 18:12:02 +02:00
Marie PUPO JEAMMET 86c98cc426 🧑‍💻(dimail) modify makefile to setup dimail container upon running demo
Setup dimail container upon running demo so that it's always in sync.
2025-06-10 16:52:04 +02:00
Marie PUPO JEAMMET 0bbce9ffc8 🔥(dimail) remove obsolete user and allow creation in dimail setup
Remove obsolete duplication to dimail database.
See PR https://github.com/suitenumerique/people/pull/886 for more context.
2025-06-10 16:52:04 +02:00
Marie PUPO JEAMMET ce66645294 🐛(demo) fix domains names
Mailbox creation was broken in dev because of wrong format of domains names
2025-06-10 16:52:04 +02:00
Eléonore Voisin 485eb88dd1 (frontend) add crisp script
add crisp chatbox to global layout
2025-06-10 16:13:24 +02:00
renovate[bot] a8b08c4b6d ⬆️(dependencies) update requests to v2.32.4 [SECURITY] 2025-06-10 15:47:00 +02:00
renovate[bot] 23f5a13ccc ⬆️(dependencies) update django to v5.2.2 [SECURITY] 2025-06-10 12:20:27 +00:00
elvoisin 1245c54c61 ️(fix) add error when mailbox create failed (#915)
fix toast error when mailbox create failed
2025-06-10 12:19:31 +00:00
Quentin BEY 95f63fa56d 🔒️(frontend) hide Nginx server version in error responses
Remove version disclosure in /assets/ error pages identified by security
auditor to prevent information leakage vulnerability.
2025-06-05 19:27:24 +02:00
Marie PUPO JEAMMET 4c3891047b (mailboxes) add a button to reset password on enabled mailboxes
Domain admins can now send requests to reset password for any mailbox
on their domains.
2025-05-20 16:45:14 +02:00
Marie PUPO JEAMMET fce9b1e490 🐛(dimail) fix broken auth while resetting passwords
Dimail client's "reset password" method was using basic auth while
dimail expects a token for this endpoint. Fixed it.
2025-05-20 16:45:14 +02:00
Marie PUPO JEAMMET 83bec33bdb ✏️(typo) fix typos
Did you know ? "Successful" actually takes two esses.
2025-05-20 16:45:14 +02:00
Quentin BEY 8fed2606d6 🧑‍💻(frontend) add makefile lint --fix
Add a Makefile command to easily run the automatic fixup for frontend
files.
2025-05-20 15:00:14 +02:00
Marie PUPO JEAMMET c7eb86eaa9 ⬆️(dependencies) manually upgrade python dependencies
Renovate seems to be in a weird place dealing with conflicting versions.
This PR handles the non-problematic upgrades
2025-05-16 19:32:15 +02:00
qbey be1513e106 🌐(i18n) update translated strings
Update translated files with new translations
2025-05-16 11:22:18 +00:00
Quentin BEY 8e85f303ec 🌐(frontend) update some translated messages
Slight cleanup of translations.
2025-05-16 11:55:32 +02:00
Quentin BEY ee564ff6ba 🎨(front) fix mail domain list display
The first implementation was using `div` instead of the proper
components.
2025-05-16 11:55:32 +02:00
renovate[bot] b0355059b7 ⬆️(dependencies) update js dependencies 2025-05-16 11:41:03 +02:00
Marie PUPO JEAMMET 6e792986be (admin) send pending mailboxes from admin
Provides an admin action to send all pending mailboxes for an active domain.
This allows quick fixes when mailboxes fell out of sync.
2025-05-16 11:30:46 +02:00
Quentin BEY fe9fb67fed 🔒️(docker) patch libxml to address CVE
Trivy scan detects some issue:
┌─────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬───────────────────────────────────────────────────────────┐
│ Library │ Vulnerability  │ Severity │ Status │ Installed Version │ Fixed Version │                           Title                           │
├─────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼───────────────────────────────────────────────────────────┤
│ libxml2 │ CVE-2025-32414 │ HIGH     │ fixed  │ 2.13.4-r5         │ 2.13.4-r6     │ libxml2: Out-of-Bounds Read in libxml2                    │
│         │                │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2025-32414                │
│         ├────────────────┤          │        │                   │               ├───────────────────────────────────────────────────────────┤
│         │ CVE-2025-32415 │          │        │                   │               │ libxml2: Out-of-bounds Read in xmlSchemaIDCFillNodeTables │
│         │                │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2025-32415                │
└─────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┴───────────────────────────────────────────────────────────┘
2025-05-16 10:03:52 +02:00
Quentin BEY 91fbef9066 🎨(front) rewrite the team main page
The first rewrite I made was using `div` instead of the proper
components.
2025-05-14 18:46:49 +02:00
Quentin BEY 4f3c9abe62 🐛(front) improve domain "return" button
The button to return to domain list was reloading the whole page which
was quite long (and somehow failing on staging).
2025-05-14 17:57:29 +02:00
Quentin BEY bd43e4620d 💄(teams) update team list page to match new UI
This is an attempt to quick fix the team page to match the new UI.
2025-05-14 17:57:29 +02:00
Quentin BEY 8c67d4a004 (e2e) add mailboxe (dis/en)able check
This provides a new test to check the action on the mailbox item.
For now we can only enable or disable a mailbox.

We need to create the mailbox in the test, so it exists on Dimail side.
2025-05-14 17:46:26 +02:00
Quentin BEY 78cb3e693c 💚(e2e) remove useless test on "tabs"
This is supposed to be a validation test on accessibility but in fact
it's just a flaky test which does not provide any information. We need
to replace this with something smarter.
2025-05-14 17:46:26 +02:00
Quentin BEY cec8e87edd 🐛(front-mail) dynamically display org name
The organization is not always DINUM.
2025-05-14 17:46:26 +02:00
Quentin BEY 35a700d522 💄(cunningham) fix style for display
Some CSS were not found (like color gold-500).
2025-05-14 17:46:26 +02:00
Quentin BEY c786ddbb82 🐛(frontend) re-enable mailbox actions
This code was commented but seems to work properly.
2025-05-14 17:46:26 +02:00
Quentin BEY cb198a9d04 💩(frontend) restore user name / org header
This was added previously and while the organization is not displayed
elsewhere it's better to keep the information displayed somewhere.
2025-05-14 17:43:37 +02:00
Quentin BEY 3d9645b561 ⚰️(i18n) utils module is not used anymore
The use of this module has been removed during the UI refacto.
2025-05-14 17:43:37 +02:00
Quentin BEY 21cbeded18 (frontend) fix end-to-end tests after refacto
Some test were broken other were flaky: fix them.
All tests are not worthy but since it was easy to fix, we keep them
until we write better tests...
2025-05-14 17:43:37 +02:00
Quentin BEY f0c609ef0b 🐛(frontend) fix create team button
The button to create a new team was not displayed properly anymore.
2025-05-14 17:43:37 +02:00
Quentin BEY aa3d90b686 🐛(frontend) fix browser language detection
The `Default language` E2E test detected the browser language was not
automatically detected when user does not have any cookie, it was always
falling back on the defaut language (en).
2025-05-14 17:43:37 +02:00
Quentin BEY 4a08a9ec92 ⚰️(front) remove unused code after UI v2
The linter was unhappy, so I removed the unused variables or code.
2025-05-14 17:43:37 +02:00
Quentin BEY 5544a40f5f 🎨(front) fix linter issues with --fix
Simply run the lint command with "autofix" to format files.
2025-05-14 17:43:37 +02:00
Eléonore Voisin e274c309cd 🎨(frontend) global UI rewrite to match new design
This commit is the result of several squashed commits which were
complicated to disjoin.
This rewrites the base UI, and the mail management interfaces.
2025-05-14 17:41:30 +02:00
renovate[bot] 560998083d ⬆️(dependencies) update django to v5.2.1 [SECURITY] 2025-05-08 18:32:32 +02:00
Marie PUPO JEAMMET 2d56c57102 (dimail) add reset password method
allow domain owner and admins to reset password for a mailbox
they manage. The request is sent to dimail, which responds with
a new randomly generated password. This new password is sent to
secondary email.
2025-05-05 17:28:00 +02:00
Benoit Serrano b5d86967ff 🐛(docker) fix dockerize no matching manifest for linux/arm64/v8 error
fixing docker config for linux/arm64/v8 error
2025-05-05 17:28:00 +02:00
Marie PUPO JEAMMET 08559d856d ⬆️(dimail) update dimail version to 2.11
update dimail version to last production-ready version.
2025-05-05 17:28:00 +02:00
Marie PUPO JEAMMET 8b17a5470d 🔖(changelog) fix changelog
Fixing changelog after having forgotten to update it in last release (1.16.0)
2025-05-05 12:39:34 +02:00
mjeammet 141c4e7f61 🌐(i18n) update translated strings
Update translated files with new translations
2025-05-05 11:32:27 +02:00
Marie PUPO JEAMMET a5473f62b7 🔖(minor) release version 1.16.0
Update all version files and changelog for minor release.
2025-05-05 11:32:27 +02:00
Quentin BEY 889291c7f3 🔒️(drf) disable browsable HTML API renderer
The `BrowsableAPIRenderer` generates a form to test POST/PUT/... actions
and fill the FK fields with unfiltered data. This issue has been spoted
on visio and fixed https://github.com/suitenumerique/meet/pull/508
2025-04-30 15:58:21 +02:00
Quentin BEY a8d20bacb0 ️(back) use redis as session backend in dev
We want to persist the session during development. Otherwise the session
is reset everytime the server is restart. This behavior make developing
bot a front and back feature a nigthmare, we spend our time login again
and again.

Shamelessly copy/pasted from @lunika 's work
suitenumerique/docs@007854a
2025-04-30 15:11:40 +02:00
renovate[bot] c4a81cf76a ⬆️(dependencies) update python dependencies 2025-04-30 10:49:16 +02:00
Quentin BEY 0a241f0e03 🔧(sentry) add Celery beat task integration
This should provide "cron" monitoring in Sentry.
2025-04-28 15:51:34 +02:00
Laurent Bossavit b389927653 📝(security) add a basic security disclosure policy
This is copied from Docs with only minor changes.
2025-04-24 14:05:22 +02:00
Marie PUPO JEAMMET 056a4bd7ac 🛂(dimail) simplify interop with dimail
In this commit, we stop creating /users and /allows in dimail
for our dbs to be in sync. People with stop impersonating users
in dimail and will create mailboxes using its own credentials.
2025-04-23 16:24:53 +02:00
Quentin BEY 6721328b2d ⬆️(django-lasuite) bump version to v0.0.7
This fixes the userinfo OIDC endpoint format autodetection.
2025-04-23 10:23:09 +02:00
Quentin BEY ab5d8c74d8 (e2e) fix keycloak user email address
Django >= 5.2 add a verification on email address
2025-04-22 17:59:55 +02:00
Quentin BEY 4c14f967b6 (backend) fix test after dependencies update
The queries needs update to manage save/release in database, this should
be improved, but considered ok for now.
2025-04-22 17:59:55 +02:00
renovate[bot] b42cd483c6 ⬆️(dependencies) update python dependencies 2025-04-22 17:59:55 +02:00
Marie PUPO JEAMMET 3735b699cc 🔧(renovate) add dimail-api to renovate scope
Dimail-api is currently outside of renovate' scope, which resulted in us having
to check and update dimail's image manually or, if failing to, discovering new
behaviors by chance or by errors in production. This should fix it.
2025-04-22 14:31:50 +02:00
Quentin BEY 0220875c70 ⬆️(django-lasuite) bump to version 0.0.5
Bump the lib to the latest version:
- update the post_get_or_create_user method signature
- allow silent login for OIDC (will require frontend implementation)
2025-04-10 15:00:03 +02:00
Sabrina Demagny 7a1fc6b626 (mailbox) remove secondary email as required field
The secondary email address is no longer required for all creation
processes and we should not force the user to provide and store an
insecure email address.
2025-04-10 12:59:16 +02:00
Sabrina Demagny 99d7b23dc9 🐛(core) fix AccountService api_key field declaration
Override save is a better way to auto generate api_key if
it is not set.
Default with random secret generate a new migration each time we
run `make makemigrations`.
2025-04-10 10:24:08 +02:00
renovate[bot] 1a52cd63bf ⬆️(dependencies) update js dependencies 2025-04-09 12:12:01 +02:00
Sabrina Demagny 8691f1846d 📝(scripts) enhance release scripts instructions
Add information about settings and helm chart to configure
Add more details about translations PR autogenerated
2025-04-08 22:00:41 +02:00
Sabrina Demagny edbf77c525 💄(domain) enhance admin action label to import mailboxes
So far, "Synchronise from dimail" only import missing mailboxes
from dimail, so this label needs to be more explicit.
2025-04-08 21:39:16 +02:00
Quentin BEY 140d099fce ⬆️(backend) bump django-lasuite to v0.0.2
This will allow the introspected token to not contain the `iss` claim.
2025-04-07 13:55:19 +02:00
sdemagny 133688324b 🌐(i18n) update translated strings
Update translated files with new translations
2025-04-04 17:46:21 +02:00
Sabrina Demagny a7b3cd42bc 🔖(minor) release version 1.15.0
Update all version files and changelog for minor release.
2025-04-04 17:46:21 +02:00
Jacques ROUSSEL ceebf8f7aa 🐛(ci) remove path to trigger relaese helm chart
We had an issue with the automatic helm chart releaser so we decide to
trigger the job on every merge.
2025-04-04 17:18:20 +02:00
Sabrina Demagny 8ef2cc9a37 🧱(helm) add la-suite ingress path
The route was added but not declared in the ingress.
2025-04-04 15:02:20 +02:00
Quentin BEY e2d362bc77 (backend) add django-lasuite dependency
Use the OIDC backends from the new library.
2025-04-04 09:57:12 +02:00
Sabrina Demagny 594d3af0d0 (plugins) add endpoint to list SIRET of active organizations
Allow access to AccountService with right scope to list
SIRET of active communes
2025-04-04 08:47:24 +02:00
Sabrina Demagny 855e20d407 (core) create AccountServiceAuthentication backend
Backend authentication with API Key to AccountService
2025-04-04 08:47:24 +02:00
Sabrina Demagny f60bfc2676 (core) create AccountService model
Create new model to allow access of some API
endpoints with API Key authentification.
Scopes will allow to define permission access on those
endpoints.
2025-04-04 08:47:24 +02:00
Marie PUPO JEAMMET b4de7fda92 🔒️(users) restrict listable users to same organization
This is a quick fix to a security issue. Previously, any user could
list all users. Now /users/ endpoint only lists users from same
organization.
2025-04-03 16:18:25 +02:00
Quentin BEY a009f3ccb7 🐛(plugin) allow simple application name
This allows to use the application name, instead of the full path to the
application configuration in the INSTALLED_PLUGINS setting.
2025-04-03 15:17:53 +02:00
Marie PUPO JEAMMET 2f1843e0e8 🐛(stats) rename stat for clarity
Public statistics on domains was modified to count only enabled
domains. Modify stat name to reflect change.
2025-04-03 14:58:07 +02:00
Quentin BEY 3a044e6b02 📝(helm) update missing documentation
Seems like the readme was not updated after adding the celery beat
worker configuration.
2025-04-03 10:33:47 +02:00
Quentin BEY 7c569a3ca3 🧱(helm) disable createsuperuser job by setting
This provides the way to disable the admin user creation at each
deployment. In production we don't want to persist a generic admin user:
it should be created once, at first deployment then replaced by
nominative accounts.
2025-04-03 10:33:23 +02:00
Quentin BEY e23d236614 (pytest) fail on tests external calls
The backend tests must not try to call the real world.
2025-04-03 09:39:15 +02:00
renovate[bot] 61c3b6ac6b ⬆️(dependencies) update django to v5.1.8 [SECURITY] 2025-04-03 07:47:33 +02:00
Quentin BEY 1eb9dffa48 🐛(contacts) add missing select_related
The new DRF version (3.16.0) adds a check on unique together and needs
more fields to be loaded. To prevent an extra query, we select the owner
value in the DB query.
2025-04-01 10:58:49 +02:00
renovate[bot] d0854851a2 ⬆️(dependencies) update python dependencies 2025-04-01 10:58:49 +02:00
renovate[bot] 6eae92d9e5 ⬆️(dependencies) update js dependencies 2025-03-31 09:39:23 +02:00
Jacques ROUSSEL b02146e4eb 🐛(ci) use github action for argocd webhook notification
In order to refactor this notification between alls projetcs, we
chooseto use a custom github action
2025-03-28 16:32:10 +01:00
Quentin BEY dd43483ce6 🔒️(passwords) add validators for production
This enabled various password validators to enforce password complexity.
2025-03-28 15:43:45 +01:00
Sabrina Demagny 838d1267b2 (domains) allow to re-run check on failed domain
In use we realize that it is also necessary to be able
to re-run dimail check on domain failed
2025-03-28 15:03:15 +01:00
Quentin BEY fbe3aa54d0 🐛(ci) use sha256 to sign argocd webhook call
The argocd webhook call needs now to use sha256 digest now to sign

Copy from docs project commit by @lunika
2025-03-28 11:09:04 +01:00
Sabrina Demagny e4e9a121a4 (organization) add is_active field
Add flag to indicate whether the organization is active.
Prepare organizations provisioning. The organization
will be created with this flag set to False and will
become active when the first user is associated with it.
2025-03-27 18:34:09 +01:00
Sabrina Demagny 3173e096d9 🐛(dimail) enhance sentry log for dimail error
Remove duplicate sentry log and fix failure if response content
has an unexpected format
2025-03-27 18:25:24 +01:00
Marie PUPO JEAMMET 4420bab073 🐛(demo) fix missing support_email field
"support_email" field was missing for all domains created in demo.
this lead to "make demo" and "setup_dimail_db" commands to fail.
2025-03-27 18:06:36 +01:00
Marie PUPO JEAMMET 8cbedeb76e ♻️(dimail) refacto setup_dimail_db to call dimail client
Management command "setup_dimail_db" called dimail directly, thus
creating duplicated code. It now calls "create_domain" and "create_allow"
methods from DimailAPIClient (create_user is left unchanged to create
special users such as dimail admin or people)
2025-03-27 18:06:36 +01:00
Quentin BEY 28fdee868d ♻️(plugins) rewrite plugin system as django app
This allow more flexibility around the installed plugins, this will
allow to add models in plugins if needed.
2025-03-26 19:56:23 +01:00
Quentin BEY 4ced342062 ♻️(core) move app ready code to functions
For readability, we move the code block from the `ready` method to a
dedicated function.

This will allow to add more things to do in the `ready` with more focus.
2025-03-26 19:56:23 +01:00
Laurent Bossavit 2502ff0c99 🔧(dns) make target zone for communes domains configurable
Add a configuration setting tied to an env var, so we can have
a separate zone for staging/preprod.
2025-03-25 19:48:43 +01:00
Laurent Bossavit dc33493739 Revert "(dimail) remove ci-time dependency on dimail to improve CI times"
This reverts commit 81030004e9.
2025-03-25 18:36:17 +01:00
Laurent Bossavit 81030004e9 (dimail) remove ci-time dependency on dimail to improve CI times
The Gitlab instance hosting dimail images has become unstable
resulting in excessive CI failures, and we don't actually depend
on dimail for CI.
2025-03-25 18:10:34 +01:00
Sabrina Demagny 339831f090 🌐(i18n) update translations
Run i18n-download-and-compile to download translations from
Crowdin and compile them
2025-03-25 13:45:24 +01:00
Sabrina Demagny 5178e460c4 (domains) notify support when domain status changes
During the scheduled task to check domains,
send an email notification to domain support if a
status has changed.
2025-03-25 08:44:35 +01:00
Sabrina Demagny feb5d7154b (domains) define domain check interval as a settings
For now, to avoid overloading dimail, we have defined a
time interval between each check request to dimail.
This interval should be configurable for testing and
different environments.
2025-03-25 08:44:35 +01:00
renovate[bot] 7dac39034a ⬆️(dependencies) update js dependencies 2025-03-24 10:55:35 +01:00
renovate[bot] 660fc7c291 ⬆️(dependencies) update python dependencies 2025-03-24 09:34:53 +01:00
renovate[bot] 27fd43b164 ⬆️(dependencies) update next to v15.2.3 [SECURITY] 2025-03-22 12:44:17 +01:00
Laurent Bossavit e63c31f960 🐛(front) disable retries in useQuery and useInfiniteQuery
The default options in TanStack Query don't make sense for these purposes
and were inducing a need for long timeouts in Playwright tests. (Personal
aside: I consider timeouts in Playwright as a testing smell.)
2025-03-20 14:56:15 +01:00
Quentin BEY 6b2ca88ff2 (oidc) add simple introspection backend
This provides a configurable OIDC introspection backend to be able to
call introspection endpoints which returns JSON data instead of an
encrypted JWT.

Two backends are currently defined:

 - ResourceServerBackend` which expect a JSON response
 - JWTResourceServerBackend which implements RFC 9701 and expects
   JWE reponse.

There might be other cases (eg: ResourceServerBackend with JWT, JWS or
JWE, etc. but for now we don't use it, so we follow YAGNI).

This also allow to configure the claim to determine the "audience":

 - client_id: for our Keycloak implementation
 - aud: used by ProConnect
2025-03-20 09:30:18 +01:00
Quentin BEY b771f614e2 🧑‍💻(tilt) setup resource server with kc
This configures the settings to be able to call people as a resource
server when using Keycloak deployment.
2025-03-20 09:30:18 +01:00
Marie PUPO JEAMMET 889a495ea3 🧐(stats) restrict domains count to active domains
Stats are currently counting all domains, including users tests.
Counting enabled domains is more relevant to reflect actual use.
2025-03-19 16:52:11 +01:00
Sabrina Demagny f21716dc68 📝(release) add information about crowdin download
Now a github action downloads last translations from crowdin
when a release branch is created.
We have to merge crowdin translations into release
branch before submit the release PR ;)
2025-03-18 18:29:02 +01:00
Sabrina Demagny 7e1f0b31f9 (scripts) enhance release script input
Remove leading and trailing spaces inserted by mistake in the input.
For the version number, a space can corrupt the version control files.
2025-03-18 18:29:02 +01:00
Sabrina Demagny 666cafe220 📝(dimail) add some info about data required to create mailbox
Prepare generic mailbox implementation
2025-03-18 18:29:02 +01:00
Quentin BEY 1ec98f0948 🧑‍💻(tasks) run management commands
This allows to run management commands from a celery task.
2025-03-18 18:02:53 +01:00
renovate[bot] f0258bbde7 ⬆️(dependencies) update python dependencies 2025-03-17 12:02:44 +01:00
renovate[bot] 773f8cfb4b ⬆️(dependencies) update js dependencies 2025-03-17 11:41:57 +01:00
314 changed files with 14597 additions and 11372 deletions
+24
View File
@@ -0,0 +1,24 @@
# Set the default behavior for all files
* text=auto eol=lf
# Binary files (should not be modified)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.flv binary
*.fla binary
*.swf binary
*.gz binary
*.zip binary
*.7z binary
*.ttf binary
*.woff binary
*.woff2 binary
*.eot binary
*.pdf binary
+8 -10
View File
@@ -40,7 +40,7 @@ jobs:
name: Run trivy scan (frontend)
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target frontend-production -f Dockerfile'
docker-build-args: '--target frontend-production -f src/frontend/Dockerfile'
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
build-and-push-backend:
@@ -105,6 +105,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
@@ -118,12 +119,9 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify
with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
+2 -4
View File
@@ -167,6 +167,8 @@ jobs:
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make update-keycloak-realm-app
make add-dev-rsa-private-key-to-env
make run
- name: Apply DRF migrations
@@ -177,10 +179,6 @@ jobs:
run: |
make demo FLUSH_ARGS='--no-input'
- name: Setup Dimail DB
run: |
make dimail-setup-db
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
-2
View File
@@ -5,8 +5,6 @@ on:
push:
branches:
- 'main'
paths:
- ./src/helm/desk/**
jobs:
release:
+69 -1
View File
@@ -8,6 +8,71 @@ and this project adheres to
## [Unreleased]
### Added
- 🐛(front) fix missing pagination mail domains
- 🐛(front) fix button add mail domain
- ✨(teams) add matrix webhook for teams #904
- ✨(resource-server) add SCIM /Me endpoint #895
- 🔧(git) set LF line endings for all text files #928
### Changed
- 🧑‍💻(docker) split frontend to another file #924
## [1.17.0] - 2025-06-11
### Added
- ✨(frontend) add crisp script #914
- ⚡️(fix) add error when mailbox create failed
- ✨(mailbox) allow to reset password on mailboxes #834
## [1.16.0] - 2025-05-05
### Added
- 🔧(sentry) add Celery beat task integration #892
### Changed
- ✨(uiv2) change mail domains
- 🛂(dimail) simplify interop with dimail
- ✨(mailbox) remove secondary email as required field
### Fixed
- 🔒️(drf) disable browsable HTML API renderer #897
## [1.15.0] - 2025-04-04
### Added
- 🧱(helm) add la-suite ingress path
- (backend) add django-lasuite dependency #858
- ✨(plugins) add endpoint to list siret of active organizations #771
- ✨(core) create AccountServiceAuthentication backend #771
- ✨(core) create AccountService model #771
- 🧱(helm) disable createsuperuser job by setting #863
- 🔒️(passwords) add validators for production #850
- ✨(domains) allow to re-run check on domain if status is failed
- ✨(organization) add `is_active` field
- ✨(domains) notify support when domain status changes #668
- ✨(domains) define domain check interval as a settings
- ✨(oidc) add simple introspection backend #832
- 🧑‍💻(tasks) run management commands #814
### Changed
- ♻️(plugins) rewrite plugin system as django app #844
- 🔒️(users) restrict listable users to same organization #846
### Fixed
- 🐛(dimail) enhance sentry log
- 🐛(oauth2) force JWT signed for /userinfo #804
- 🐛(front) disable retries in useQuery and useInfiniteQuery #818
## [1.14.1] - 2025-03-17
## [1.14.0] - 2025-03-17
@@ -341,7 +406,10 @@ and this project adheres to
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/suitenumerique/people/compare/v1.14.1...main
[unreleased]: https://github.com/suitenumerique/people/compare/v1.17.0...main
[1.17.0]: https://github.com/suitenumerique/people/releases/v1.17.0
[1.16.0]: https://github.com/suitenumerique/people/releases/v1.16.0
[1.15.0]: https://github.com/suitenumerique/people/releases/v1.15.0
[1.14.1]: https://github.com/suitenumerique/people/releases/v1.14.1
[1.14.0]: https://github.com/suitenumerique/people/releases/v1.14.0
[1.13.1]: https://github.com/suitenumerique/people/releases/v1.13.1
-55
View File
@@ -10,61 +10,6 @@ RUN python -m pip install --upgrade pip setuptools
RUN apk update && \
apk upgrade
### ---- Front-end dependencies image ----
FROM node:20 AS frontend-deps
WORKDIR /deps
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
RUN yarn --frozen-lockfile
### ---- Front-end builder dev image ----
FROM node:20 AS frontend-builder-dev
WORKDIR /builder
COPY --from=frontend-deps /deps/node_modules ./node_modules
COPY ./src/frontend .
WORKDIR ./apps/desk
### ---- Front-end builder image ----
FROM frontend-builder-dev AS frontend-builder
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.27-alpine AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=frontend-builder \
/builder/apps/desk/out \
/usr/share/nginx/html
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
# ---- Back-end builder image ----
FROM base AS back-builder
+42 -30
View File
@@ -41,11 +41,9 @@ DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN = $(COMPOSE) run --rm --no-deps
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_KC_DB = $(COMPOSE_RUN) dockerize -wait tcp://kc_postgresql:5432 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -78,19 +76,33 @@ create-env-files: \
env.d/development/kc_postgresql
.PHONY: create-env-files
add-dev-rsa-private-key-to-env: ## Add a generated RSA private key to the env file
@echo "Generating RSA private key PEM for development..."
@mkdir -p env.d/development/rsa
@openssl genrsa -out env.d/development/rsa/private.pem 2048
@echo -n "\nOAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY=\"" >> env.d/development/common
@openssl rsa -in env.d/development/rsa/private.pem -outform PEM >> env.d/development/common
@echo "\"" >> env.d/development/common
@rm -rf env.d/development/rsa
.PHONY: add-dev-rsa-private-key-to-env
update-keycloak-realm-app: ## Create the Keycloak realm for the project
@echo "$(BOLD)Creating Keycloak realm for 'app'$(RESET)"
@sed -i 's|http://app-dev:8000|http://app:8000|g' ./docker/auth/realm.json
.PHONY: update-keycloak-realm-app
bootstrap: ## Prepare Docker images for the project and install frontend dependencies
bootstrap: \
data/media \
data/static \
create-env-files \
build \
run \
run-dev \
migrate \
back-i18n-compile \
mails-install \
mails-build \
dimail-setup-db \
install-front-desk
dimail-setup-db
.PHONY: bootstrap
# -- Docker/compose
@@ -106,19 +118,14 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d celery-beat-dev
@$(COMPOSE) up --force-recreate -d flower-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
run: ## start the wsgi (production) and servers with production Docker images
@$(COMPOSE) up --force-recreate --detach app frontend celery celery-beat nginx maildev
.PHONY: run
run-dev: ## start the servers in development mode (watch) Docker images
@$(COMPOSE) up --force-recreate --detach app-dev frontend-dev celery-dev celery-beat-dev nginx maildev
.PHONY: run-dev
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -132,6 +139,7 @@ stop: ## stop the development server using Docker
demo: ## flush db then create a demo for load testing purpose
@$(MAKE) resetdb
@$(MANAGE) create_demo
@$(MAKE) dimail-setup-db
.PHONY: demo
@@ -158,10 +166,14 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
bin/pylint --diff-only=origin/main
.PHONY: lint-pylint
lint-front:
lint-front: ## lint front-end sources with eslint
cd $(PATH_FRONT) && yarn lint
.PHONY: lint-front
lint-front-fix: ## fix front-end sources with eslint
cd $(PATH_FRONT) && yarn lint-fix
.PHONY: lint-front-fix
test: ## run project tests
@$(MAKE) test-back-parallel
.PHONY: test
@@ -182,22 +194,16 @@ test-coverage: ## compute, display and save test coverage
makemigrations: ## run django makemigrations for the people project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations $(ARGS)
.PHONY: makemigrations
migrate: ## run django migrations for the people project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate $(ARGS)
.PHONY: migrate
showmigrations: ## run django showmigrations for the people project.
@echo "$(BOLD)Running showmigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) showmigrations $(ARGS)
.PHONY: showmigrations
@@ -289,14 +295,10 @@ i18n-generate-and-upload: \
# -- INTEROPERABILTY
# -- Dimail configuration
recreate-dimail-container:
@$(COMPOSE) up --force-recreate -d dimail
.PHONY: recreate-dimail-container
dimail-setup-db:
@$(COMPOSE) up --force-recreate -d dimail
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
@$(MANAGE) setup_dimail_db
@$(MANAGE) setup_dimail_db --populate-from-people
.PHONY: dimail-setup-db
# -- Mail generator
@@ -425,3 +427,13 @@ install-secret: ## install the kubernetes secrets from Vaultwarden
fetch-domain-status:
@$(MANAGE) fetch_domain_status
.PHONY: fetch-domain-status
# -- Keycloak related
create-new-client: ## run the add-keycloak-client.sh script for keycloak.
@echo "$(BOLD)Running add-keycloak-client.sh$(RESET)"
@$(COMPOSE_RUN) \
-v ./scripts/keycloak/add-keycloak-client.sh:/tmp/add-keycloak-client.sh \
--entrypoint="/tmp/add-keycloak-client.sh" \
keycloak \
$(filter-out $@,$(MAKECMDGOALS))
.PHONY: create-new-client
+7 -1
View File
@@ -33,7 +33,7 @@ The easiest way to start working on the project is to use GNU Make:
$ make bootstrap
```
This command builds the `app` container, installs dependencies, performs
This command builds the `app-dev` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-related or migration-related issues.
@@ -46,6 +46,12 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
$ make run
```
or if you want to run them in development mode (with live reloading):
```bash
$ make run-dev
```
You can check all available Make rules using:
```bash
+23
View File
@@ -0,0 +1,23 @@
# Security Policy
## Reporting a Vulnerability
Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at support-regie@numerique.gouv.fr
We appreciate your effort to make People more secure.
## Vulnerability disclosure policy
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
1. The Maintainers team will handle the fix as usual (Pull Request,
release).
2. In the release notes, we will include the identification numbers from the
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
and Exposures (CVE) identifier for the vulnerability.
3. Once this grace period has passed, we will publish the vulnerability.
By adhering to this security policy, we aim to address security concerns
effectively and responsibly in our open source software project.
+17
View File
@@ -64,6 +64,23 @@ function _dc_run() {
_docker_compose run --rm $user_args "$@"
}
# _dc_run_no_deps: wrap docker compose run command without dependencies
#
# usage: _dc_run_no_deps [options] [ARGS...]
#
# options: docker compose run command options
# ARGS : docker compose run command arguments
function _dc_run_no_deps() {
_set_user
user_args="--user=$USER_ID"
if [ -z $USER_ID ]; then
user_args=""
fi
_docker_compose run --no-deps --rm $user_args "$@"
}
# _dc_exec: wrap docker compose exec command
#
# usage: _dc_exec [options] [ARGS...]
+1 -1
View File
@@ -35,4 +35,4 @@ fi
# Fix docker vs local path when project sources are mounted as a volume
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
_dc_run_no_deps app-dev pylint "${paths[@]}" "${args[@]}"
+125 -39
View File
@@ -1,10 +1,16 @@
services:
postgresql:
image: postgres:16
platform: linux/amd64
env_file:
- env.d/development/postgresql
ports:
- "15432:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
interval: 1s
timeout: 2s
retries: 300
redis:
image: redis:5
@@ -22,6 +28,7 @@ services:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-development
pull_policy: never
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
@@ -36,11 +43,69 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- dimail
- postgresql
- maildev
- redis
postgresql:
condition: service_healthy
restart: true
dimail:
condition: service_started
maildev:
condition: service_started
redis:
condition: service_started
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-dev
image: people:frontend-development
pull_policy: never
volumes:
- ./src/frontend:/home/frontend
- /home/frontend/node_modules
- /home/frontend/apps/desk/node_modules
ports:
- "3000:3000"
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-production
environment:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/france
- env.d/development/postgresql
ports:
- "8071:8000"
volumes:
- ./data/media:/data/media
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
frontend:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
image: people:frontend-production
pull_policy: build
ports:
- "3000:3000"
celery-dev:
user: ${DOCKER_USER:-1000}
image: people:backend-development
@@ -55,7 +120,11 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev
postgresql:
condition: service_healthy
restart: true
app-dev:
condition: service_started
celery-beat-dev:
user: ${DOCKER_USER:-1000}
@@ -71,26 +140,9 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-production
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./data/media:/data/media
depends_on:
- postgresql
- redis
postgresql:
condition: service_healthy
restart: true
celery:
user: ${DOCKER_USER:-1000}
@@ -102,7 +154,29 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
- app
postgresql:
condition: service_healthy
restart: true
app:
condition: service_started
celery-beat:
user: ${DOCKER_USER:-1000}
image: people:backend-production
command: ["celery", "-A", "people.celery_app", "beat", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
nginx:
image: nginx:1.25
@@ -111,11 +185,9 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- app
- keycloak
dockerize:
image: jwilder/dockerize
keycloak:
condition: service_healthy
restart: true
crowdin:
image: crowdin/cli:4.6.1
@@ -135,11 +207,16 @@ services:
- ".:/app"
kc_postgresql:
image: postgres:14.3
ports:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
image: postgres:14.3
ports:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
interval: 1s
timeout: 2s
retries: 300
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
@@ -156,6 +233,8 @@ services:
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
@@ -166,14 +245,21 @@ services:
KC_DB_USERNAME: people
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
healthcheck:
test: [ "CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready" ]
interval: 1s
timeout: 2s
retries: 300
ports:
- "8080:8080"
depends_on:
- kc_postgresql
kc_postgresql:
condition: service_healthy
restart: true
dimail:
entrypoint: /opt/dimail-api/start-dev.sh
image: registry.mim-libre.fr/dimail/dimail-api:v0.1.1
image: registry.mim-libre.fr/dimail/dimail-api:v0.2.11
pull_policy: always
environment:
DIMAIL_MODE: FAKE
+61 -4
View File
@@ -26,7 +26,7 @@
"oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"sslRequired": "none",
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"rememberMe": true,
@@ -50,6 +50,9 @@
"firstName": "John",
"lastName": "Doe",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -77,10 +80,13 @@
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
"email": "user-e2e-chromium@example.org",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -91,10 +97,13 @@
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
"email": "user-e2e-webkit@example.org",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -105,10 +114,13 @@
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
"email": "user-e2e-firefox@example.org",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -123,6 +135,9 @@
"firstName": "E2E",
"lastName": "Group Member",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -137,6 +152,9 @@
"firstName": "E2E",
"lastName": "Group Administrator",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -151,6 +169,9 @@
"firstName": "E2E",
"lastName": "Group Owner",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -165,6 +186,9 @@
"firstName": "E2E",
"lastName": "Mailbox Member",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -179,6 +203,9 @@
"firstName": "E2E",
"lastName": "Mailbox Administrator",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -193,6 +220,9 @@
"firstName": "E2E",
"lastName": "Mailbox Owner",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -207,6 +237,9 @@
"firstName": "E2E",
"lastName": "Group Member Mailbox Member",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -221,6 +254,9 @@
"firstName": "E2E",
"lastName": "Group Member Mailbox Administrator",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -235,6 +271,9 @@
"firstName": "E2E",
"lastName": "Group Member Mailbox Owner",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -249,6 +288,9 @@
"firstName": "E2E",
"lastName": "Group Administrator Mailbox Member",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -263,6 +305,9 @@
"firstName": "E2E",
"lastName": "Group Administrator Mailbox Administrator",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -277,6 +322,9 @@
"firstName": "E2E",
"lastName": "Group Administrator Mailbox Owner",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -291,6 +339,9 @@
"firstName": "E2E",
"lastName": "Group Owner Mailbox Member",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -305,6 +356,9 @@
"firstName": "E2E",
"lastName": "Group Owner Mailbox Administrator",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
@@ -319,6 +373,9 @@
"firstName": "E2E",
"lastName": "Mailbox Owner",
"enabled": true,
"attributes": {
"siret": "13002526500013"
},
"credentials": [
{
"type": "password",
+30 -25
View File
@@ -2,44 +2,49 @@
## What is dimail ?
The mailing solution provided in La Suite is [Open-XChange](https://www.open-xchange.com/) (OX).
OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
The mailing solution provided in La Suite is [La Messagerie](https://webmail.numerique.gouv.fr/), using [Open-XChange](https://www.open-xchange.com/) (OX). OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.fr/docs#/).
## Architectural links of dimail
## Use of dimail container
As dimail's primary goal is to act as an interface between People and OX, its architecture is similar to that of People. A series of requests are sent from People to dimail upon creating domains, users, permissions and mailboxes.
To ease local development, dimail provides a container that we embark in our docker-compose. In "FAKE" mode, it simulates all responses from Open Exchange.
Bootstraping with command `make bootstrap` creates a container and initializes its database.
Additional commands :
- Reset and populate the database with all the content of your People database with `dimail-setup-db`
## Architecture
### Domains
Upon creating a domain on People, the same domain is created on dimail and will undergo a series of checks. When all checks have passed, the domain is considered enabled.
Domains in OX have a field called "context". Context is a shared space between domains, allowing users to discover users not only on their domain but on their entire context.
Domains in OX have a field called "context". "Contexts" are shared spaces between domains, allowing users to discover users not only on their domain but on their entire context.
> [!NOTE]
> Contexts are only implemented in La Messagerie and are not currently in use in People. Domains created via People are in their own context.
People users can have 3 levels of permissions on a domain:
- Viewers can
- see the domain's information
- list its mailboxes and managers
- Administrators can
- create mailboxes
- invite collaborators to manage domain
- change role of a viewer to administrators
- all of viewers permissions
- Owners can
- promote administrators owners and demote owners
- all of viewers and administrators' permissions
> [!NOTE]
> Contexts are only implemented in La Messagerie and are not currently in use in People. Domains created via People are in their own context.
### Mailboxes
Mailboxes can be created by a domain owners or administrators in People's domain tab.
On enabled domains, mailboxes are created at the same time on dimail (and a confirmation email is sent to the secondary email).
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's activation.
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's (re)activation.
On disabled domains, mailboxes creation is not allowed.
### Users
The ones issuing requests. Dimail users will reflect domains owners and administrators on People, for logging purposes and to allow direct use of dimail, if desired. User reconciliation is made on user uuid provided by ProConnect.
### Permissions
As for People, an access - a permissions (or an "allow" in dimail) - grants an user permission to create objects on a domain.
Permissions requests are sent automatically upon :
- dimail database initialisation:
+ permission for dimail user People to create users and domains
- domain creation :
+ permission for dimail user People to manage domain
+ permission for new owner on new domain
- user creation:
+ permission for People to manage user
- access creation, if owner or admin:
+ permission for this user to manage domain
+4
View File
@@ -3,6 +3,7 @@ DJANGO_ALLOWED_HOSTS=*
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=people.settings
DJANGO_SUPERUSER_PASSWORD=admin
DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000
# Python
PYTHONPATH=/app
@@ -65,3 +66,6 @@ IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
MbyqKyC6DAzv4jEEhHaN7oY=
-----END PRIVATE KEY-----
"
# INTEROP
MAIL_PROVISIONING_API_CREDENTIALS="bGFfcmVnaWU6cGFzc3dvcmQ=" # Dev and test key for dimail interop
+6 -2
View File
@@ -2,5 +2,9 @@
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
OAUTH2_PROVIDER_OIDC_ENABLED = True
OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD="siret"
OAUTH2_PROVIDER_OIDC_ENABLED=True
OAUTH2_PROVIDER_VALIDATOR_CLASS="mailbox_oauth2.validators.ProConnectValidator"
INSTALLED_PLUGINS=plugins.la_suite
+2 -1
View File
@@ -1 +1,2 @@
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation
INSTALLED_PLUGINS=plugins.la_suite
DNS_PROVISIONING_TARGET_ZONE=test.collectivite.fr
+5
View File
@@ -14,6 +14,11 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint", "@hookform/resolvers"]
},
{
"groupName": "docker-compose dependencies",
"matchManagers": ["docker-compose"],
"matchPackageNames": ["dimail-api"]
}
]
}
+149
View File
@@ -0,0 +1,149 @@
#!/bin/bash
# Script to add a new client to Keycloak using the kcadm.sh CLI
# Usage: ./add-keycloak-client.sh [client_id] [client_secret]
# Default values
CLIENT_ID=${1:-"some-client-id"}
CLIENT_SECRET=${2:-"ThisIsAnExampleKeyForDevPurposeOnly"}
KEYCLOAK_URL=${KEYCLOAK_URL:-"http://keycloak:8080"}
KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-"admin"}
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-"admin"}
REALM=${REALM:-"people"}
# Check for kcadm.sh in common locations
KCADM_LOCATIONS=(
"/opt/keycloak/bin/kcadm.sh"
"/opt/jboss/keycloak/bin/kcadm.sh"
"/usr/local/bin/kcadm.sh"
"./bin/kcadm.sh"
"$(which kcadm.sh 2>/dev/null)"
)
KCADM=""
for loc in "${KCADM_LOCATIONS[@]}"; do
if [ -x "$loc" ]; then
KCADM="$loc"
break
fi
done
if [ -z "$KCADM" ]; then
echo "Error: kcadm.sh not found. Please specify its location manually."
echo "You can set the KCADM environment variable to the path of kcadm.sh"
exit 1
fi
echo "Using Keycloak Admin CLI at: $KCADM"
echo "Logging in to Keycloak at $KEYCLOAK_URL..."
# Login to Keycloak
$KCADM config credentials \
--server $KEYCLOAK_URL \
--realm master \
--user $KEYCLOAK_ADMIN \
--password $KEYCLOAK_ADMIN_PASSWORD
if [ $? -ne 0 ]; then
echo "Failed to login to Keycloak. Please check your credentials and try again."
exit 1
fi
echo "Successfully logged in to Keycloak."
echo "Creating new client '$CLIENT_ID' in realm '$REALM'..."
# Create a temporary JSON file with client configuration
CLIENT_JSON=$(mktemp)
cat > "$CLIENT_JSON" << EOF
{
"clientId": "$CLIENT_ID",
"name": "",
"description": "",
"rootUrl": "",
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "$CLIENT_SECRET",
"redirectUris": [
"http://localhost:8070/*",
"http://localhost:8071/*",
"http://localhost:3200/*",
"http://localhost:8088/*",
"http://localhost:3000/*"
],
"webOrigins": [
"http://localhost:3200",
"http://localhost:8088",
"http://localhost:8070",
"http://localhost:3000"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"access.token.lifespan": "-1",
"client.secret.creation.time": "$(date +%s)",
"user.info.response.signature.alg": "RS256",
"post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*",
"oauth2.device.authorization.grant.enabled": "false",
"use.jwks.url": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"use.refresh.tokens": "true",
"tls-client-certificate-bound-access-tokens": "false",
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"client_credentials.use_refresh_token": "false",
"acr.loa.map": "{}",
"require.pushed.authorization.requests": "false",
"display.on.consent.screen": "false",
"client.session.idle.timeout": "-1",
"token.response.type.bearer.lower-case": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
EOF
# Create the client using kcadm.sh
$KCADM create clients -r "$REALM" -f "$CLIENT_JSON"
if [ $? -ne 0 ]; then
echo "Failed to create client. Check the error message above."
rm "$CLIENT_JSON"
exit 1
fi
echo "✅ Client '$CLIENT_ID' created successfully!"
echo " Client ID: $CLIENT_ID"
echo " Client Secret: $CLIENT_SECRET"
# Clean up temporary file
rm "$CLIENT_JSON"
# Display the created client
echo "Client details:"
$KCADM get clients -r "$REALM" --query "clientId=$CLIENT_ID"
+10 -5
View File
@@ -102,11 +102,14 @@ def deployment_part(version):
NEXT COMMAND on lasuite-deploiement repository:
>> git push origin {deployment_branch}
Continue ? (y,n)
\x1b[0m""")
\x1b[0m""").strip()
if confirm == 'y':
run_command(f"git push origin {deployment_branch}", shell=True)
sys.stdout.write(f"""\033[1;34m
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Check if you need to bump chart version in the manifests/regie/helmfile.yaml file
and settings in the manifests/regie/env.d/preprod/values.desk.yaml.gotmpl file
add commits into {deployment_branch} branch to do it if needed, then...
--> Please submit PR {deployment_branch} and merge code to main [https://github.com/numerique-gouv/lasuite-deploiement]
\x1b[0m""")
@@ -132,12 +135,14 @@ Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
NEXT COMMAND on current repository:
>> git push origin {branch_to_release}
Continue ? (y,n)
\x1b[0m""")
\x1b[0m""").strip()
if confirm == 'y':
run_command(f"git push origin {branch_to_release}", shell=True)
sys.stdout.write(f"""
\033[1;34mPLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR {branch_to_release} and merge code to main [https://github.com/suitenumerique/people/]
--> A github action will download last translations from crowdin and create a PR (i18n/update-translations) to merge into the release branch.
Please wait for this, approve and merge this translations PR into the release branch {branch_to_release}.
--> Then please submit PR {branch_to_release} and merge code to main [https://github.com/suitenumerique/people/]
--> Then do:
>> git checkout main
>> git pull
@@ -153,9 +158,9 @@ Continue ? (y,n)
if __name__ == "__main__":
version, kind = None, None
while not version:
version = input("Enter your release version:")
version = input("Enter your release version:").strip()
while kind not in RELEASE_KINDS:
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
kind = input("Enter kind of release (p:patch, m:minor, mj:major):").strip()
if "--only-deployment-part" not in sys.argv:
project_part(version, kind)
deployment_part(version)
+30
View File
@@ -0,0 +1,30 @@
"""Global fixtures for the backend tests."""
import pytest
from urllib3.connectionpool import HTTPConnectionPool
@pytest.fixture(autouse=True)
def no_http_requests(monkeypatch):
"""
Prevents HTTP requests from being made during tests.
This is useful for tests that do not require actual HTTP requests
and helps to avoid network-related issues.
Credits: https://blog.jerrycodes.com/no-http-requests/
"""
allowed_hosts = {"localhost"}
original_urlopen = HTTPConnectionPool.urlopen
def urlopen_mock(self, method, url, *args, **kwargs):
if self.host in allowed_hosts:
return original_urlopen(self, method, url, *args, **kwargs)
raise RuntimeError(
f"The test was about to {method} {self.scheme}://{self.host}{url}"
)
monkeypatch.setattr(
"urllib3.connectionpool.HTTPConnectionPool.urlopen", urlopen_mock
)
+23 -6
View File
@@ -1,5 +1,6 @@
"""Admin classes and registrations for People's core app."""
from django.conf import settings
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
@@ -10,10 +11,7 @@ from treebeard.forms import movenodeform_factory
from mailbox_manager.admin import MailDomainAccessInline
from . import models
from .plugins.loader import (
get_organization_plugins,
organization_plugins_run_after_create,
)
from .plugins.registry import registry as plugin_hooks_registry
class TeamAccessInline(admin.TabularInline):
@@ -229,7 +227,7 @@ class OrganizationServiceProviderInline(admin.TabularInline):
def run_post_creation_plugins(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Run the post creation plugins for the selected organizations."""
for organization in queryset:
organization_plugins_run_after_create(organization)
plugin_hooks_registry.execute_hook("organization_created", organization)
messages.success(
request,
@@ -244,9 +242,11 @@ class OrganizationAdmin(admin.ModelAdmin):
actions = [run_post_creation_plugins]
list_display = (
"name",
"is_active",
"created_at",
"updated_at",
)
list_filter = ("is_active",)
search_fields = ("name",)
inlines = (OrganizationAccessInline, OrganizationServiceProviderInline)
exclude = ("service_providers",) # Handled by the inline
@@ -254,7 +254,7 @@ class OrganizationAdmin(admin.ModelAdmin):
def get_actions(self, request):
"""Adapt actions list to the context."""
actions = super().get_actions(request)
if not get_organization_plugins():
if not plugin_hooks_registry.get_callbacks("organization_created"):
actions.pop("run_post_creation_plugins", None)
return actions
@@ -285,3 +285,20 @@ class ServiceProviderAdmin(admin.ModelAdmin):
)
search_fields = ("name", "audience_id")
readonly_fields = ("created_at", "updated_at")
@admin.register(models.AccountService)
class AccountServiceAdmin(admin.ModelAdmin):
"""Admin interface for account services."""
list_display = ("name", "created_at", "updated_at")
readonly_fields = ("api_key", "created_at", "updated_at")
def get_form(self, request, obj=None, change=False, **kwargs):
"""Add help text to the scopes field to provide list of available scopes."""
form = super().get_form(request, obj, change, **kwargs)
form.base_fields[
"scopes"
].help_text = f"Scopes define what the service can access. \
Available scopes: {', '.join(settings.ACCOUNT_SERVICE_SCOPES)}"
return form
+15 -5
View File
@@ -29,6 +29,7 @@ from core.api import permissions
from core.api.client import serializers
from core.utils.raw_sql import gen_sql_filter_json_array
from mailbox_manager import enums
from mailbox_manager import models as domains_models
@@ -145,7 +146,7 @@ class ContactViewSet(
"""Contact ViewSet"""
permission_classes = [permissions.AccessPermission]
queryset = models.Contact.objects.select_related("user").all()
queryset = models.Contact.objects.select_related("user", "owner").all()
serializer_class = serializers.ContactSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
ordering_fields = ["full_name", "short_name", "created_at"]
@@ -262,9 +263,10 @@ class UserViewSet(
queryset = self.queryset
if self.action == "list":
# Exclude inactive contacts
# Filter active users
# and users from same organization
queryset = queryset.filter(
is_active=True,
is_active=True, organization_id=self.request.user.organization_id
)
# Exclude all users already in the given team
@@ -584,7 +586,13 @@ class ConfigView(views.APIView):
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = ["LANGUAGES", "FEATURES", "RELEASE", "COMMIT"]
array_settings = [
"LANGUAGES",
"FEATURES",
"RELEASE",
"COMMIT",
"CRISP_WEBSITE_ID",
]
dict_settings = {}
for setting in array_settings:
dict_settings[setting] = getattr(settings, setting)
@@ -609,7 +617,9 @@ class StatView(views.APIView):
last_login__gte=timezone.now() - datetime.timedelta(30)
).count(),
"teams": models.Team.objects.count(),
"domains": domains_models.MailDomain.objects.count(),
"active_domains": domains_models.MailDomain.objects.filter(
status=enums.MailDomainStatusChoices.ENABLED
).count(),
"mailboxes": domains_models.Mailbox.objects.count(),
}
return response.Response(context)
@@ -0,0 +1 @@
"""SCIM compliant API for resource server"""
@@ -0,0 +1,16 @@
"""Exceptions for SCIM API."""
from django.conf import settings
from core.api.resource_server.scim.response import ScimJsonResponse
def scim_exception_handler(exc, _context):
"""Handle SCIM exceptions and return them in the correct format."""
data = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": str(exc.status_code),
"detail": str(exc.detail) if settings.DEBUG else "",
}
return ScimJsonResponse(data, status=exc.status_code)
@@ -0,0 +1,16 @@
"""SCIM API response classes."""
from rest_framework.response import Response
class ScimJsonResponse(Response):
"""
Custom JSON response class for SCIM API.
This class sets the content type to "application/json+scim" for SCIM
responses.
"""
def __init__(self, *args, **kwargs):
"""JSON response with enforced SCIM content type."""
super().__init__(*args, content_type="application/json+scim", **kwargs)
@@ -0,0 +1,90 @@
"""SCIM serializers for resource server API."""
from django.urls import reverse
from rest_framework import serializers
from core import models
class SCIMUserSerializer(serializers.ModelSerializer):
"""Serialize users in SCIM format."""
schemas = serializers.SerializerMethodField()
userName = serializers.CharField(source="sub")
displayName = serializers.CharField(source="name")
emails = serializers.SerializerMethodField()
meta = serializers.SerializerMethodField()
groups = serializers.SerializerMethodField()
active = serializers.BooleanField(source="is_active")
class Meta:
model = models.User
fields = [
"id",
"schemas",
"active",
"userName",
"displayName",
"emails",
"groups",
"meta",
]
read_only_fields = [
"id",
"schemas",
"active",
"userName",
"displayName",
"emails",
"groups",
"meta",
]
def get_schemas(self, _obj):
"""Return the SCIM schemas for the user."""
return ["urn:ietf:params:scim:schemas:core:2.0:User"]
def get_emails(self, obj):
"""Return the user's email as a list of email objects."""
if obj.email:
return [
{
"value": obj.email,
"primary": True,
"type": "work",
}
]
return []
def get_groups(self, obj):
"""
Return the groups the user belongs to.
WARNING: you need to prefetch the team accesses in the
viewset to avoid N+1 queries.
"""
return [
{
"value": str(team_access.team.external_id),
"display": team_access.team.name,
"type": "direct",
}
for team_access in obj.accesses.all()
]
def get_meta(self, obj):
"""Return metadata about the user."""
request = self.context.get("request")
location = (
f"{request.build_absolute_uri('/').rstrip('/')}{reverse('scim-me-list')}"
if request
else None
)
return {
"resourceType": "User",
"created": obj.created_at.isoformat(),
"lastModified": obj.updated_at.isoformat(),
"location": location,
}
@@ -0,0 +1,62 @@
"""Resource server SCIM API endpoints"""
from django.contrib.auth import get_user_model
from django.db.models import Prefetch, Q
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
from rest_framework import (
viewsets,
)
from core.api import permissions
from core.models import TeamAccess
from . import serializers
from .exceptions import scim_exception_handler
from .response import ScimJsonResponse
User = get_user_model()
class MeViewSet(ResourceServerMixin, viewsets.ViewSet):
"""
SCIM-compliant ViewSet for the /Me endpoint.
This endpoint provides information about the currently authenticated user
in SCIM (System for Cross-domain Identity Management) format.
Features:
- Returns user details in SCIM format.
- Includes the user's teams, restricted to the audience.
Limitations:
- Does not currently support managing Team hierarchies.
Endpoint:
GET /resource-server/v1.0/scim/Me/
Retrieves the authenticated user's details and associated teams.
"""
permission_classes = [permissions.IsAuthenticated]
serializer_class = serializers.SCIMUserSerializer
def get_exception_handler(self):
"""Override the default exception handler to use SCIM-specific handling."""
return scim_exception_handler
def list(self, request, *args, **kwargs):
"""Return the current user's details in SCIM format."""
service_provider_audience = self._get_service_provider_audience()
user = User.objects.prefetch_related(
Prefetch(
"accesses",
queryset=TeamAccess.objects.select_related("team").filter(
Q(team__service_providers__audience_id=service_provider_audience)
| Q(team__is_visible_all_services=True)
),
)
).get(pk=request.user.pk)
serializer = self.serializer_class(user, context={"request": request})
return ScimJsonResponse(serializer.data)
@@ -6,6 +6,7 @@ from functools import reduce
from django.db.models import OuterRef, Prefetch, Q, Subquery, Value
from django.db.models.functions import Coalesce
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
from rest_framework import (
filters,
mixins,
@@ -15,7 +16,6 @@ from rest_framework import (
from core import models
from core.api import permissions
from core.api.client.viewsets import Pagination
from core.resource_server.mixins import ResourceServerMixin
from . import serializers
+65 -7
View File
@@ -1,11 +1,69 @@
"""People Core application"""
# from django.apps import AppConfig
# from django.utils.translation import gettext_lazy as _
import logging
from django.apps import AppConfig
from django.conf import settings
from django.core.management import call_command, get_commands
from django.utils.translation import gettext_lazy as _
from core.utils.io import TeeStringIO
from people.celery_app import app as celery_app
logger = logging.getLogger(__name__)
# class CoreConfig(AppConfig):
# """Configuration class for the People core app."""
def _register_management_commands_as_task():
"""
Register management command which are enabled via MANAGEMENT_COMMAND_AS_TASK setting.
"""
for command_name in settings.MANAGEMENT_COMMAND_AS_TASK:
# Check if the command is a valid management command
try:
app_name = get_commands()[command_name]
except KeyError:
logging.error("Command %s is not a valid management command.", command_name)
continue
# name = "core"
# app_label = "core"
# verbose_name = _("People core application")
command_full_name = ".".join([app_name, command_name])
# Create a closure to capture the current value of command_full_name and command_name
def create_task(cmd_name, cmd_full_name):
@celery_app.task(name=cmd_full_name)
def task_wrapper(*command_args, **command_options):
stdout = TeeStringIO(logging.getLogger(cmd_full_name).info)
stderr = TeeStringIO(logging.getLogger(cmd_full_name).error)
call_command(
cmd_name,
*command_args,
no_color=True,
stdout=stdout,
stderr=stderr,
**command_options,
)
stdout.seek(0)
stderr.seek(0)
return {
"stdout": str(stdout.read()),
"stderr": str(stderr.read()),
}
return task_wrapper
# Create the task with the current values
create_task(command_name, command_full_name)
class CoreConfig(AppConfig):
"""Configuration class for the People core app."""
name = "core"
app_label = "core"
verbose_name = _("People core application")
def ready(self):
"""Run when the application is ready."""
_register_management_commands_as_task()
+73 -123
View File
@@ -1,20 +1,20 @@
"""Authentication Backends for the People core app."""
import logging
from email.headerregistry import Address
from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
import requests
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from lasuite.tools.email import get_domain_from_email
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from core.models import (
AccountService,
Contact,
Organization,
OrganizationAccess,
@@ -23,100 +23,45 @@ from core.models import (
logger = logging.getLogger(__name__)
User = get_user_model()
def get_domain_from_email(email: Optional[str]) -> Optional[str]:
"""Extract domain from email."""
try:
return Address(addr_spec=email).domain
except (ValueError, AttributeError):
return None
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User model, and handles signed and/or encrypted UserInfo response.
"""
def get_userinfo(self, access_token, id_token, payload):
"""Return user details dictionary.
def get_extra_claims(self, user_info):
"""
Return extra claims from user_info.
Parameters:
- access_token (str): The access token.
- id_token (str): The id token (unused).
- payload (dict): The token payload (unused).
Note: The id_token and payload parameters are unused in this implementation,
but were kept to preserve base method signature.
Note: It handles signed and/or encrypted UserInfo Response. It is required by
Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response.
Args:
user_info (dict): The user information dictionary.
Returns:
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
dict: A dictionary of extra claims.
"""
user_response = requests.get(
self.OIDC_OP_USER_ENDPOINT,
headers={"Authorization": f"Bearer {access_token}"},
verify=self.get_settings("OIDC_VERIFY_SSL", True),
timeout=self.get_settings("OIDC_TIMEOUT", None),
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
userinfo = self.verify_token(user_response.text)
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found.
Parameters:
- access_token (str): The access token.
- id_token (str): The ID token.
- payload (dict): The user payload.
Returns:
- User: An existing or newly created User instance.
Raises:
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
# Get user's full name from OIDC fields defined in settings
email = user_info.get("email")
claims = {
"sub": sub,
"email": email,
"name": self.compute_full_name(user_info),
}
extra_claims = super().get_extra_claims(user_info)
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
extra_claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = (
user_info.get(settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD)
)
return extra_claims
# if sub is absent, try matching on email
user = self.get_existing_user(sub, email)
def post_get_or_create_user(self, user, claims, is_new_user):
"""
Post-processing after user creation or retrieval.
if user:
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(claims)
Args:
user (User): The user instance.
claims (dict): The claims dictionary.
is_new_user (bool): Indicates if the user was newly created.
Returns:
- None
"""
# Data cleaning, to be removed when user organization is null=False
# or all users have an organization.
# See https://github.com/suitenumerique/people/issues/504
@@ -124,7 +69,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
organization_registration_id = claims.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
)
domain = get_domain_from_email(email)
domain = get_domain_from_email(claims["email"])
try:
organization, organization_created = (
Organization.objects.get_or_create_from_user_claims(
@@ -151,8 +96,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"User %s updated with organization %s", user.pk, organization
)
return user
def create_user(self, claims):
"""Return a newly created User instance."""
sub = claims.get("sub")
@@ -164,8 +107,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
name = claims.get("name")
# Extract or create the organization from the data
organization_registration_id = claims.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
organization_registration_id = claims.pop(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD,
None,
)
domain = get_domain_from_email(email)
try:
@@ -185,13 +129,8 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
logger.info("Creating user %s / %s", sub, email)
user = self.UserModel.objects.create(
organization=organization,
password="!", # noqa: S106
sub=sub,
email=email,
name=name,
)
user = super().create_user(claims | {"organization": organization})
if organization_created:
# Warning: we may remove this behavior in the near future when we
# add a feature to claim the organization ownership.
@@ -215,33 +154,44 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return user
def compute_full_name(self, user_info):
"""Compute user's full name based on OIDC fields in settings."""
name_fields = settings.USER_OIDC_FIELDS_TO_NAME
full_name = " ".join(
user_info[field] for field in name_fields if user_info.get(field)
)
return full_name or None
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
class AccountServiceAuthentication(BaseAuthentication):
"""Authentication backend for account services using Authorization header.
The Authorization header is used to authenticate the request.
The api key is stored in the AccountService model.
Header format:
Authorization: ApiKey <api_key>
"""
def authenticate(self, request):
"""Authenticate the request. Find the account service and check the api key.
Should return either:
- a tuple of (account_service, api_key) if allowed,
- None to pass on other authentication backends
- raise an AuthenticationFailed exception to stop propagation.
"""
auth_header = request.headers.get("Authorization", "")
if not auth_header:
return None
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email)
except User.DoesNotExist:
pass
return None
auth_mode, api_key = auth_header.split(" ")
except (IndexError, ValueError) as err:
raise AuthenticationFailed(_("Invalid authorization header.")) from err
if auth_mode.lower() != "apikey" or not api_key:
raise AuthenticationFailed(_("Invalid authorization header."))
try:
account_service = AccountService.objects.get(api_key=api_key)
except AccountService.DoesNotExist as err:
logger.warning("Invalid api_key: %s", api_key)
raise AuthenticationFailed(_("Invalid api key.")) from err
return (account_service, account_service.api_key)
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
updated_claims = {}
for key in ["email", "name"]:
claim_value = claims.get(key)
if claim_value and claim_value != getattr(user, key):
updated_claims[key] = claim_value
if updated_claims:
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
def authenticate_header(self, request):
"""
Return a string to be used as the value of the `WWW-Authenticate`
header in a `401 Unauthenticated` response, or `None` if the
authentication scheme should return `403 Permission Denied` responses.
"""
return "apikey"
-18
View File
@@ -1,18 +0,0 @@
"""Authentication URLs for the People core app."""
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
urlpatterns = [
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
path(
"logout-callback/",
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozilla_oidc_urls,
]
-137
View File
@@ -1,137 +0,0 @@
"""Authentication Views for the People core app."""
from urllib.parse import urlencode
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
Adds support for handling logout callbacks from the identity provider (OP)
by initiating the logout flow if the user has an active session.
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
This parameter is crucial for maintaining the integrity of the logout flow between this call
and the subsequent callback.
"""
@staticmethod
def persist_state(request, state):
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
This method is used to store the OIDC state parameter in the session, according to the
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
utility function.
"""
if "oidc_states" not in request.session or not isinstance(
request.session["oidc_states"], dict
):
request.session["oidc_states"] = {}
request.session["oidc_states"][state] = {}
request.session.save()
def construct_oidc_logout_url(self, request):
"""Create the redirect URL for interfacing with the OIDC provider.
Retrieves the necessary parameters from the session and constructs the URL
required to initiate logout with the OpenID Connect provider.
If no ID token is found in the session, the logout flow will not be initiated,
and the method will return the default redirect URL.
The 'state' parameter is generated randomly and persisted in the session to ensure
its integrity during the subsequent callback.
"""
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
if not oidc_logout_endpoint:
return self.redirect_url
reverse_url = reverse("oidc_logout_callback")
id_token = request.session.get("oidc_id_token", None)
if not id_token:
return self.redirect_url
query = {
"id_token_hint": id_token,
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
"post_logout_redirect_uri": absolutify(request, reverse_url),
}
self.persist_state(request, query["state"])
return f"{oidc_logout_endpoint}?{urlencode(query)}"
def post(self, request):
"""Handle user logout.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, constructs the OIDC logout URL and redirects the user to start
the logout process.
If the user is redirected to the default logout URL, ensure her Django session
is terminated.
"""
logout_url = self.redirect_url
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
# If the user is not redirected to the OIDC provider, ensure logout
if logout_url == self.redirect_url:
auth.logout(request)
return HttpResponseRedirect(logout_url)
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
Handles the callback after logout from the identity provider (OP).
Verifies the state parameter and performs necessary logout actions.
The Django session is maintained during the logout process to ensure the integrity
of the logout flow initiated in the previous step.
"""
http_method_names = ["get"]
def get(self, request):
"""Handle the logout callback.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, verifies the state parameter and performs necessary logout actions.
"""
if not request.user.is_authenticated:
return HttpResponseRedirect(self.redirect_url)
state = request.GET.get("state")
if state not in request.session.get("oidc_states", {}):
msg = "OIDC callback state not found in session `oidc_states`!"
raise SuspiciousOperation(msg)
del request.session["oidc_states"][state]
request.session.save()
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)
+7
View File
@@ -24,3 +24,10 @@ class WebhookStatusChoices(models.TextChoices):
FAILURE = "failure", _("Failure")
PENDING = "pending", _("Pending")
SUCCESS = "success", _("Success")
class WebhookProtocolChoices(models.TextChoices):
"""Defines the possible protocols of webhook."""
SCIM = "scim"
MATRIX = "matrix"
+10
View File
@@ -277,3 +277,13 @@ class ServiceProviderFactory(factory.django.DjangoModelFactory):
if not create or not extracted:
return
self.organizations.set(extracted)
class AccountServiceFactory(factory.django.DjangoModelFactory):
"""A factory to create account services for testing purposes."""
class Meta:
model = models.AccountService
name = factory.Sequence(lambda n: f"Account Service {n!s}")
api_key = factory.Faker("uuid4")
@@ -0,0 +1,22 @@
"""Management command to list all plugins and their hooks."""
from django.conf import settings
from django.core.management.base import BaseCommand
from core.plugins.registry import registry
class Command(BaseCommand):
"""Management command to list all plugins and their hooks."""
help = "List all plugins and their hooks"
def handle(self, *args, **options):
"""Print plugin information."""
self.stdout.write(self.style.NOTICE("# Listing plugins\n"))
for plugin_app in settings.INSTALLED_PLUGINS:
self.stdout.write(self.style.NOTICE(f" - {plugin_app}\n"))
self.stdout.write(self.style.NOTICE("# Listing loaded hooks\n"))
for hook_name, callbacks in registry.get_registered_hooks():
self.stdout.write(self.style.NOTICE(f" - {hook_name}: {callbacks}\n"))
@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-03-27 10:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_organization_metadata'),
]
operations = [
migrations.AddField(
model_name='organization',
name='is_active',
field=models.BooleanField(default=True, verbose_name='active'),
),
]
@@ -0,0 +1,32 @@
# Generated by Django 5.1.8 on 2025-04-03 20:37
import core.models
import django.contrib.postgres.fields
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0013_organization_is_active'),
]
operations = [
migrations.CreateModel(
name='AccountService',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('name', models.CharField(max_length=255, verbose_name='name')),
('api_key', models.CharField(default='Y9-IThtCrKASOvRAKO2MUBd_XvZrnSTGNMMlv-Z1_o8', max_length=255, verbose_name='api key')),
('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, validators=[core.models.validate_account_service_scope]), help_text='Allowed scopes for this service', size=None, verbose_name='allowed scopes')),
],
options={
'verbose_name': 'Account service',
'verbose_name_plural': 'Account services',
'db_table': 'people_account_service',
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.8 on 2025-04-10 07:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_accountservice'),
]
operations = [
migrations.AlterField(
model_name='accountservice',
name='api_key',
field=models.CharField(max_length=255, verbose_name='api key'),
),
]
@@ -0,0 +1,40 @@
# Generated by Django 5.2 on 2025-06-12 09:58
import uuid
from django.conf import settings
from django.db import migrations, models
def gen_team_uuid(apps, schema_editor):
Team = apps.get_model("core", "Team")
for row in Team.objects.all():
row.external_id = uuid.uuid4()
# Don't need to batch update here, as the table is likely small or empty
row.save(update_fields=["external_id"])
class Migration(migrations.Migration):
dependencies = [
('core', '0015_alter_accountservice_api_key'),
]
operations = [
migrations.AddField(
model_name='team',
name='external_id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=False, verbose_name='external_id'),
),
migrations.RunPython(gen_team_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='team',
name='external_id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=True, verbose_name='external_id'),
),
#
migrations.AlterField(
model_name='team',
name='users',
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', through_fields=('team', 'user'), to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-06-17 14:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_team_external_id_alter_team_users'),
]
operations = [
migrations.AddField(
model_name='teamwebhook',
name='protocol',
field=models.CharField(choices=[('scim', 'Scim'), ('matrix', 'Matrix')], default='scim'),
),
]
+78 -22
View File
@@ -5,6 +5,7 @@ Declare and configure the models for the People core application
import json
import os
import secrets
import smtplib
import uuid
from contextlib import suppress
@@ -20,7 +21,7 @@ from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db import models
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext, override
@@ -30,12 +31,9 @@ import jsonschema
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,
organization_plugins_run_after_grant_access,
)
from core.utils.webhooks import scim_synchronizer
from core.enums import WebhookProtocolChoices, WebhookStatusChoices
from core.plugins.registry import registry as plugin_hooks_registry
from core.utils.webhooks import webhooks_synchronizer
from core.validators import get_field_validators_from_setting
logger = getLogger(__name__)
@@ -325,7 +323,7 @@ class OrganizationManager(models.Manager):
This method is overridden to call the Organization plugins.
"""
instance = super().create(**kwargs)
organization_plugins_run_after_create(instance)
plugin_hooks_registry.execute_hook("organization_created", instance)
return instance
@@ -341,7 +339,7 @@ class OrganizationAccessManager(models.Manager):
This method is overridden to call the Organization plugins.
"""
instance = super().create(**kwargs)
organization_plugins_run_after_grant_access(instance)
plugin_hooks_registry.execute_hook("organization_access_granted", instance)
return instance
@@ -396,6 +394,7 @@ class Organization(BaseModel):
related_name="organizations",
blank=True,
)
is_active = models.BooleanField(verbose_name=_("active"), default=True)
objects = OrganizationManager()
@@ -743,6 +742,11 @@ class Team(MP_Node, BaseModel):
can see it.
When a team is created from a Service Provider this one is automatically set in the
Team `service_providers`.
The team `external_id` is used to synchronize the team with external systems, this
is the equivalent of the User `sub` field but for teams (note: `sub` is highly related
to the OIDC standard, while `external_id` is not). The `external_id` is NOT the same as
the `externalId` field in the SCIM standard when importing teams from SCIM.
"""
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
@@ -753,6 +757,14 @@ class Team(MP_Node, BaseModel):
name = models.CharField(max_length=100)
external_id = models.UUIDField(
verbose_name=_("external_id"),
help_text=_("Team external UUID for synchronization with external systems"),
unique=True,
default=uuid.uuid4,
editable=False,
)
users = models.ManyToManyField(
User,
through="TeamAccess",
@@ -852,16 +864,12 @@ class TeamAccess(BaseModel):
Override save function to fire webhooks on any addition or update
to a team access.
"""
if self._state.adding:
if self._state.adding and self.team.webhooks.exists():
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
with transaction.atomic():
instance = super().save(*args, **kwargs)
scim_synchronizer.add_user_to_group(self.team, self.user)
else:
instance = super().save(*args, **kwargs)
# try to synchronize all webhooks
webhooks_synchronizer.add_user_to_group(self.team, self.user)
return instance
return super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""
@@ -869,11 +877,12 @@ class TeamAccess(BaseModel):
Don't allow deleting a team access until it is successfully synchronized with all
its webhooks.
"""
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
with transaction.atomic():
arguments = self.team, self.user
super().delete(*args, **kwargs)
scim_synchronizer.remove_user_from_group(*arguments)
if webhooks := self.team.webhooks.all():
webhooks.update(status=WebhookStatusChoices.PENDING)
# try to synchronize all webhooks
webhooks_synchronizer.remove_user_from_group(self.team, self.user)
super().delete(*args, **kwargs)
def get_abilities(self, user):
"""
@@ -931,6 +940,11 @@ class TeamWebhook(BaseModel):
team = models.ForeignKey(Team, related_name="webhooks", on_delete=models.CASCADE)
url = models.URLField(_("url"))
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
protocol = models.CharField(
max_length=None,
default=WebhookProtocolChoices.SCIM,
choices=WebhookProtocolChoices.choices,
)
status = models.CharField(
max_length=10,
default=WebhookStatusChoices.PENDING,
@@ -1093,3 +1107,45 @@ class Invitation(BaseInvitation):
"patch": False,
"put": False,
}
def validate_account_service_scope(scope):
"""Validate the scope of the account service."""
if scope not in settings.ACCOUNT_SERVICE_SCOPES:
raise ValidationError(f"Invalid scope: {scope}")
class AccountService(BaseModel):
"""Account service model."""
name = models.CharField(_("name"), max_length=255)
api_key = models.CharField(
_("api key"),
max_length=255,
)
scopes = ArrayField(
models.CharField(max_length=255, validators=[validate_account_service_scope]),
verbose_name=_("allowed scopes"),
help_text=_("Allowed scopes for this service"),
)
class Meta:
db_table = "people_account_service"
verbose_name = _("Account service")
verbose_name_plural = _("Account services")
def __str__(self):
return self.name
def save(self, *args, **kwargs):
"""
Override save method to generate a new api_key if it is not set.
"""
if not self.api_key:
self.api_key = secrets.token_urlsafe(32)
return super().save(*args, **kwargs)
@property
def is_authenticated(self):
"""Indicate if the account service is authenticated."""
return True
+16 -12
View File
@@ -1,19 +1,23 @@
"""Base plugin class for organization plugins."""
"""Base Django Application Configuration for plugins."""
class BaseOrganizationPlugin:
class BasePluginAppConfigMixIn:
"""
Base class for organization plugins.
Configuration for the La Suite plugin application.
Plugins must implement all methods of this class even if it is only to "pass".
We cannot use the `AppConfig` class directly because it is not compatible with
the Django way to discover default AppConfig (see `AppConfig.create`).
We use a mixin then, to be able to list plugins using `plugins.la_suite` instead
of `plugins.la_suite.apps.LaSuitePluginConfig`.
Another way would be to force `default` attribute on plugin AppConfig.
"""
def run_after_create(self, organization) -> None:
"""Method called after creating an organization."""
raise NotImplementedError("Plugins must implement the run_after_create method")
def ready(self):
"""
Initialize the hooks registry when the application is ready.
This is called by Django when the application is loaded.
"""
from .registry import registry # pylint: disable=import-outside-toplevel
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"
)
registry.register_app(self.name) # pylint: disable=no-member
-44
View File
@@ -1,44 +0,0 @@
"""Helper functions to load and run organization plugins."""
from functools import lru_cache
from typing import List
from django.conf import settings
from django.utils.module_loading import import_string
from core.plugins.base import BaseOrganizationPlugin
@lru_cache(maxsize=None)
def get_organization_plugins() -> List[BaseOrganizationPlugin]:
"""
Return a list of all organization plugins.
While the plugins initialization does not depend on the request, we can cache the result.
"""
return [
import_string(plugin_path)() for plugin_path in settings.ORGANIZATION_PLUGINS
]
def organization_plugins_run_after_create(organization):
"""
Run the after create 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_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)
+129
View File
@@ -0,0 +1,129 @@
"""Registry for hooks."""
import logging
from typing import Callable, Dict, List, Set
logger = logging.getLogger(__name__)
class HooksRegistry:
"""Registry for hooks."""
_available_hooks = {
"organization_created",
"organization_access_granted",
}
def __init__(self):
"""Initialize the registry."""
self._hooks: Dict[str, List[Callable]] = {
hook_name: [] for hook_name in self._available_hooks
}
self._registered_apps: Set[str] = set()
def register_hook(self, hook_name: str, callback: Callable) -> None:
"""
Register a hook callback.
Args:
hook_name: The name of the hook.
callback: The callback function to register.
"""
try:
self._hooks[hook_name].append(callback)
except KeyError as exc:
logger.exception(
"Failed to register hook '%s' is not a valid hook: %s", hook_name, exc
)
logger.info("Registered hook %s: %s", hook_name, callback)
def get_registered_hooks(self):
"""Get all registered hooks."""
return self._hooks.items()
def register_app(self, app_name: str) -> None:
"""
Register an app as having hooks.
Args:
app_name: The name of the app.
"""
if app_name in self._registered_apps:
return
self._registered_apps.add(app_name)
try:
# Try to import the hooks module from the app
__import__(f"{app_name}.hooks")
logger.info("Registered hooks from app: %s", app_name)
except ImportError:
# It's okay if the app doesn't have a hooks module
logger.info("App %s has no hooks module", app_name)
def get_callbacks(self, hook_name: str) -> List[Callable]:
"""
Get all callbacks for a hook.
Args:
hook_name: The name of the hook.
Returns:
A list of callback functions.
"""
try:
return self._hooks[hook_name]
except KeyError as exc:
logger.exception(
"Failed to get callbacks for hook '%s' is not a valid hook: %s",
hook_name,
exc,
)
return []
def execute_hook(self, hook_name: str, *args, **kwargs):
"""
Execute all callbacks for a hook.
Args:
hook_name: The name of the hook.
*args: Positional arguments to pass to the callbacks.
**kwargs: Keyword arguments to pass to the callbacks.
Returns:
A list of results from the callbacks.
"""
results = []
for callback in self.get_callbacks(hook_name):
try:
result = callback(*args, **kwargs)
results.append(result)
except Exception as e: # pylint: disable=broad-except
logger.exception("Error executing hook %s: %s", hook_name, e)
return results
def reset(self):
"""Function to reset the registry, to be used in test only."""
self._hooks = {hook_name: [] for hook_name in self._available_hooks}
self._registered_apps = set()
# Create a singleton instance of the registry
registry = HooksRegistry()
def register_hook(hook_name: str):
"""
Decorator to register a function as a hook callback.
Args:
hook_name: The name of the hook.
Returns:
A decorator function.
"""
def decorator(func):
registry.register_hook(hook_name, func)
return func
return decorator
+16
View File
@@ -0,0 +1,16 @@
"""URLs for plugins"""
from django.conf import settings
from django.urls import include, path
plugins_urlpatterns = []
# Try to import and include URLs from each installed plugin
for app in settings.INSTALLED_PLUGINS:
try:
plugins_urlpatterns.append(path("", include(f"{app}.urls")))
except (ImportError, AttributeError):
# Skip if app doesn't have urls.py
continue
urlpatterns = plugins_urlpatterns
@@ -1 +0,0 @@
"""Backend resource server module."""
@@ -1,72 +0,0 @@
"""Resource Server Authentication"""
import base64
import binascii
import logging
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from mozilla_django_oidc.contrib.drf import OIDCAuthentication
from .backend import ResourceServerBackend, ResourceServerImproperlyConfiguredBackend
from .clients import AuthorizationServerClient
logger = logging.getLogger(__name__)
class ResourceServerAuthentication(OIDCAuthentication):
"""Authenticate clients using the token received from the authorization server."""
def __init__(self):
"""Require authentication to be configured in order to instantiate."""
super().__init__()
try:
authorization_server_client = AuthorizationServerClient(
url=settings.OIDC_OP_URL,
verify_ssl=settings.OIDC_VERIFY_SSL,
timeout=settings.OIDC_TIMEOUT,
proxy=settings.OIDC_PROXY,
url_jwks=settings.OIDC_OP_JWKS_ENDPOINT,
url_introspection=settings.OIDC_OP_INTROSPECTION_ENDPOINT,
)
self.backend = ResourceServerBackend(authorization_server_client)
except ImproperlyConfigured as err:
message = "Resource Server authentication is disabled"
logger.debug("%s. Exception: %s", message, err)
self.backend = ResourceServerImproperlyConfiguredBackend()
def get_access_token(self, request):
"""Retrieve and decode the access token from the request.
This method overrides the 'get_access_token' method from the parent class,
to support service providers that would base64 encode the bearer token.
"""
access_token = super().get_access_token(request)
try:
access_token = base64.b64decode(access_token).decode("utf-8")
except (binascii.Error, TypeError):
pass
return access_token
def authenticate(self, request):
"""
Authenticate the request and return a tuple of (user, token) or None.
We override the 'authenticate' method from the parent class to store
the introspected token audience inside the request.
"""
result = super().authenticate(request) # Might raise AuthenticationFailed
if result is None: # Case when there is no access token
return None
# Note: at this stage, the request is a "drf_request" object
request.resource_server_token_audience = self.backend.token_origin_audience
return result
-240
View File
@@ -1,240 +0,0 @@
"""Resource Server Backend"""
import logging
from django.conf import settings
from django.contrib import auth
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from joserfc import jwe as jose_jwe
from joserfc import jwt as jose_jwt
from joserfc.errors import InvalidClaimError, InvalidTokenError
from requests.exceptions import HTTPError
from rest_framework.exceptions import AuthenticationFailed
from . import utils
logger = logging.getLogger(__name__)
class ResourceServerBackend:
"""Backend of an OAuth 2.0 resource server.
This backend is designed to authenticate resource owners to our API using the access token
they received from the authorization server.
In the context of OAuth 2.0, a resource server is a server that hosts protected resources and
is capable of accepting and responding to protected resource requests using access tokens.
The resource server verifies the validity of the access tokens issued by the authorization
server to ensure secure access to the resources.
For more information, visit: https://www.oauth.com/oauth2-servers/the-resource-server/
"""
# pylint: disable=too-many-instance-attributes
def __init__(self, authorization_server_client):
"""Require client_id, client_secret set and authorization_server_client provided."""
# pylint: disable=invalid-name
self.UserModel = auth.get_user_model()
self._client_id = settings.OIDC_RS_CLIENT_ID
self._client_secret = settings.OIDC_RS_CLIENT_SECRET
self._encryption_encoding = settings.OIDC_RS_ENCRYPTION_ENCODING
self._encryption_algorithm = settings.OIDC_RS_ENCRYPTION_ALGO
self._signing_algorithm = settings.OIDC_RS_SIGNING_ALGO
self._scopes = settings.OIDC_RS_SCOPES
if (
not self._client_id
or not self._client_secret
or not authorization_server_client
):
raise ImproperlyConfigured(
"Could not instantiate ResourceServerBackend, some parameters are missing."
)
self._authorization_server_client = authorization_server_client
self._claims_registry = jose_jwt.JWTClaimsRegistry(
iss={"essential": True, "value": self._authorization_server_client.url},
aud={"essential": True, "value": self._client_id},
token_introspection={"essential": True},
)
# Declare the token origin audience: to know where the token comes from
# and store it for further use in the application
self.token_origin_audience = None
# pylint: disable=unused-argument
def get_or_create_user(self, access_token, id_token, payload):
"""Maintain API compatibility with OIDCAuthentication class from mozilla-django-oidc
Params 'id_token', 'payload' won't be used, and our implementation will only
support 'get_user', not 'get_or_create_user'.
"""
return self.get_user(access_token)
def get_user(self, access_token):
"""Get user from an access token emitted by the authorization server.
This method will submit the access token to the authorization server for
introspection, to ensure its validity and obtain the associated metadata.
It follows the specifications outlined in RFC7662 https://www.rfc-editor.org/info/rfc7662,
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
In our eGovernment applications, the standard RFC 7662 doesn't provide sufficient security.
Its introspection response is a plain JSON object. Therefore, we use the draft RFC
that extends RFC 7662 by returning a signed and encrypted JWT for stronger assurance that
the authorization server issued the token introspection response.
"""
self.token_origin_audience = None # Reset the token origin audience
jwt = self._introspect(access_token)
claims = self._verify_claims(jwt)
user_info = self._verify_user_info(claims["token_introspection"])
sub = user_info.get("sub")
if sub is None:
message = "User info contained no recognizable user identification"
logger.debug(message)
raise SuspiciousOperation(message)
try:
user = self.UserModel.objects.get(sub=sub)
except self.UserModel.DoesNotExist:
logger.debug("Login failed: No user with %s found", sub)
return None
self.token_origin_audience = str(user_info["aud"])
return user
def _verify_user_info(self, introspection_response):
"""Verify the 'introspection_response' to get valid and relevant user info.
The 'introspection_response' should be still active, and while authenticating
the resource owner should have requested relevant scope to access her data in
our resource server.
Scope should be configured to match between the AS and the RS. The AS will filter
all the scopes the resource owner requested to expose only the relevant ones to
our resource server.
"""
active = introspection_response.get("active", None)
if not active:
message = "Introspection response is not active."
logger.debug(message)
raise SuspiciousOperation(message)
requested_scopes = introspection_response.get("scope", None).split(" ")
if set(self._scopes).isdisjoint(set(requested_scopes)):
message = "Introspection response contains any required scopes."
logger.debug(message)
raise SuspiciousOperation(message)
audience = introspection_response.get("aud", None)
if not audience:
raise SuspiciousOperation(
"Introspection response does not provide source audience."
)
return introspection_response
def _introspect(self, token):
"""Introspect an access token to the authorization server."""
try:
jwe = self._authorization_server_client.get_introspection(
self._client_id,
self._client_secret,
token,
)
except HTTPError as err:
message = "Could not fetch introspection"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
private_key = utils.import_private_key_from_settings()
jws = self._decrypt(jwe, private_key=private_key)
try:
public_key_set = self._authorization_server_client.import_public_keys()
except (TypeError, ValueError, AttributeError, HTTPError) as err:
message = "Could get authorization server JWKS"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
jwt = self._decode(jws, public_key_set)
return jwt
def _decrypt(self, encrypted_token, private_key):
"""Decrypt the token encrypted by the Authorization Server (AS).
Resource Server (RS)'s public key is used for encryption, and its private
key is used for decryption. The RS's public key is exposed to the AS via a JWKS endpoint.
Encryption Algorithm and Encoding should be configured to match between the AS
and the RS.
"""
try:
decrypted_token = jose_jwe.decrypt_compact(
encrypted_token,
private_key,
algorithms=[self._encryption_algorithm, self._encryption_encoding],
)
except Exception as err:
message = "Token decryption failed"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return decrypted_token
def _decode(self, encoded_token, public_key_set):
"""Decode the token signed by the Authorization Server (AS).
AS's private key is used for signing, and its public key is used for decoding.
The AS public key is exposed via a JWK endpoint.
Signing Algorithm should be configured to match between the AS and the RS.
"""
try:
token = jose_jwt.decode(
encoded_token.plaintext,
public_key_set,
algorithms=[self._signing_algorithm],
)
except ValueError as err:
message = "Token decoding failed"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return token
def _verify_claims(self, token):
"""Verify the claims of the token to ensure authentication security.
By verifying these claims, we ensure that the token was issued by a
trusted authorization server and is intended for this specific
resource server. This prevents various types of attacks, such as
token substitution or misuse of tokens issued for different clients.
"""
try:
self._claims_registry.validate(token.claims)
except (InvalidClaimError, InvalidTokenError) as err:
message = "Failed to validate token's claims"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return token.claims
class ResourceServerImproperlyConfiguredBackend:
"""Fallback backend for improperly configured Resource Servers."""
token_origin_audience = None
def get_or_create_user(self, access_token, id_token, payload):
"""Indicate that the Resource Server is improperly configured."""
raise AuthenticationFailed("Resource Server is improperly configured")
@@ -1,97 +0,0 @@
"""Resource Server Clients classes"""
from django.core.exceptions import ImproperlyConfigured
import requests
from joserfc.jwk import KeySet
class AuthorizationServerClient:
"""Client for interacting with an OAuth 2.0 authorization server.
An authorization server issues access tokens to client applications after authenticating
and obtaining authorization from the resource owner. It also provides endpoints for token
introspection and JSON Web Key Sets (JWKS) to validate and decode tokens.
This client facilitates communication with the authorization server, including:
- Fetching token introspection responses.
- Fetching JSON Web Key Sets (JWKS) for token validation.
- Setting appropriate headers for secure communication as recommended by RFC drafts.
"""
# ruff: noqa: PLR0913 PLR0917
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-arguments
def __init__(
self,
url,
url_jwks,
url_introspection,
verify_ssl,
timeout,
proxy,
):
"""Require at a minimum url, url_jwks and url_introspection."""
if not url or not url_jwks or not url_introspection:
raise ImproperlyConfigured(
"Could not instantiate AuthorizationServerClient, some parameters are missing."
)
self.url = url
self._url_introspection = url_introspection
self._url_jwks = url_jwks
self._verify_ssl = verify_ssl
self._timeout = timeout
self._proxy = proxy
@property
def _introspection_headers(self):
"""Get HTTP header for the introspection request.
Notify the authorization server that we expect a signed and encrypted response
by setting the appropriate 'Accept' header.
This follows the recommendation from the draft RFC:
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
"""
return {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
}
def get_introspection(self, client_id, client_secret, token):
"""Retrieve introspection response about a token."""
response = requests.post(
self._url_introspection,
data={
"client_id": client_id,
"client_secret": client_secret,
"token": token,
},
headers=self._introspection_headers,
verify=self._verify_ssl,
timeout=self._timeout,
proxies=self._proxy,
)
response.raise_for_status()
return response.text
def get_jwks(self):
"""Retrieve Authorization Server JWKS."""
response = requests.get(
self._url_jwks,
verify=self._verify_ssl,
timeout=self._timeout,
proxies=self._proxy,
)
response.raise_for_status()
return response.json()
def import_public_keys(self):
"""Retrieve and import Authorization Server JWKS."""
jwks = self.get_jwks()
public_keys = KeySet.import_key_set(jwks)
return public_keys
@@ -1,53 +0,0 @@
"""
Mixins for resource server views.
"""
from rest_framework import exceptions as drf_exceptions
from .authentication import ResourceServerAuthentication
class ResourceServerMixin:
"""
Mixin for resource server views:
- Restrict the authentication class to ResourceServerAuthentication.
- Adds the Service Provider ID to the serializer context.
- Fetch the Service Provider ID from the OIDC introspected token stored
in the request.
This Mixin *must* be used in every view that should act as a resource server.
"""
authentication_classes = [ResourceServerAuthentication]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
# When used as a resource server, we need to know the audience to automatically:
# - add the Service Provider to the team "scope" on creation
context["from_service_provider_audience"] = (
self._get_service_provider_audience()
)
return context
def _get_service_provider_audience(self):
"""Return the audience of the Service Provider from the OIDC introspected token."""
if not isinstance(
self.request.successful_authenticator, ResourceServerAuthentication
):
# We could check request.resource_server_token_audience here, but it's
# more explicit to check the authenticator type and assert the attribute
# existence.
return None
# When used as a resource server, the request has a token audience
service_provider_audience = self.request.resource_server_token_audience
if not service_provider_audience: # should not happen
raise drf_exceptions.AuthenticationFailed(
"Resource server token audience not found in request"
)
return service_provider_audience
-9
View File
@@ -1,9 +0,0 @@
"""Resource Server URL Configuration"""
from django.urls import path
from .views import JWKSView
urlpatterns = [
path("jwks", JWKSView.as_view(), name="resource_server_jwks"),
]
-48
View File
@@ -1,48 +0,0 @@
"""Resource Server utils functions"""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from joserfc.jwk import JWKRegistry
def import_private_key_from_settings():
"""Import the private key used by the resource server when interacting with the OIDC provider.
This private key is crucial; its public components are exposed in the JWK endpoints,
while its private component is used for decrypting the introspection token retrieved
from the OIDC provider.
By default, we recommend using RSAKey for asymmetric encryption,
known for its strong security features.
Note:
- The function requires the 'OIDC_RS_PRIVATE_KEY_STR' setting to be configured.
- The 'OIDC_RS_ENCRYPTION_KEY_TYPE' and 'OIDC_RS_ENCRYPTION_ALGO' settings can be customized
based on the chosen key type.
Raises:
ImproperlyConfigured: If the private key setting is missing, empty, or incorrect.
Returns:
joserfc.jwk.JWK: The imported private key as a JWK object.
"""
private_key_str = getattr(settings, "OIDC_RS_PRIVATE_KEY_STR", None)
if not private_key_str:
raise ImproperlyConfigured(
"OIDC_RS_PRIVATE_KEY_STR setting is missing or empty."
)
private_key_pem = private_key_str.encode()
try:
private_key = JWKRegistry.import_key(
private_key_pem,
key_type=settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
parameters={"alg": settings.OIDC_RS_ENCRYPTION_ALGO, "use": "enc"},
)
except ValueError as err:
raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is wrong.") from err
return private_key
-40
View File
@@ -1,40 +0,0 @@
"""Resource Server views"""
from django.core.exceptions import ImproperlyConfigured
from joserfc.jwk import KeySet
from rest_framework.response import Response
from rest_framework.views import APIView
from . import utils
class JWKSView(APIView):
"""
API endpoint for retrieving a JSON Web Keys Set (JWKS).
Returns:
Response: JSON response containing the JWKS data.
"""
authentication_classes = [] # disable authentication
permission_classes = [] # disable permission
def get(self, request):
"""Handle GET requests to retrieve JSON Web Keys Set (JWKS).
Returns:
Response: JSON response containing the JWKS data.
"""
try:
private_key = utils.import_private_key_from_settings()
except (ImproperlyConfigured, ValueError) as err:
return Response({"error": str(err)}, status=500)
try:
jwk = KeySet([private_key]).as_dict(private=False)
except (TypeError, ValueError, AttributeError):
return Response({"error": "Could not load key"}, status=500)
return Response(jwk)
@@ -2,11 +2,16 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory, override_settings
import pytest
from rest_framework.exceptions import AuthenticationFailed
from core import factories, models
from core.authentication.backends import OIDCAuthenticationBackend
from core.authentication.backends import (
AccountServiceAuthentication,
OIDCAuthenticationBackend,
)
pytestmark = pytest.mark.django_db
User = get_user_model()
@@ -155,7 +160,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
with django_assert_num_queries(3): # user by email + user by sub + update sub
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -233,7 +238,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.name == "John Doe"
assert user.password == "!"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -252,7 +257,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
match="Claims verification failed",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
@@ -406,7 +411,7 @@ def test_authentication_getter_existing_user_via_email_update_organization(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(9):
with django_assert_num_queries(11):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -453,3 +458,60 @@ def test_authentication_getter_existing_user_with_registration_id(
assert user.organization is not None
assert user.organization.registration_id_list == ["12345678901234"]
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
def test_account_service_authenticate_valid_api_key():
"""Test the authenticate method with a valid API key."""
request = RequestFactory().get("/")
account_service = factories.AccountServiceFactory(
name="test_service",
api_key="test_api_key_123",
scopes=["la-suite-list-organizations-siret"],
)
request.headers = {"Authorization": f"ApiKey {account_service.api_key}"}
result = AccountServiceAuthentication().authenticate(request)
assert result is not None
assert result[0] == account_service
assert result[1] == account_service.api_key
def test_account_service_authenticate_missing_api_key():
"""Test the authenticate method with a missing API key."""
request = RequestFactory().get("/")
request.headers = {}
result = AccountServiceAuthentication().authenticate(request)
assert result is None
def test_account_service_authenticate_invalid_api_key():
"""Test the authenticate method with an invalid API key."""
request = RequestFactory().get("/")
request.headers = {"Authorization": "ApiKey invalid_key"}
with pytest.raises(AuthenticationFailed):
AccountServiceAuthentication().authenticate(request)
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
def test_account_service_authenticate_invalid_header():
"""Test the authenticate method with an invalid header."""
request = RequestFactory().get("/")
account_service = factories.AccountServiceFactory(
name="test_service",
api_key="test_api_key_123",
scopes=["la-suite-list-organizations-siret"],
)
request.headers = {"Authorization": f"Bearer {account_service.api_key}"}
with pytest.raises(AuthenticationFailed):
AccountServiceAuthentication().authenticate(request)
request.headers = {"Authorization": account_service.api_key}
with pytest.raises(AuthenticationFailed):
AccountServiceAuthentication().authenticate(request)
@@ -1,10 +0,0 @@
"""Unit tests for the Authentication URLs."""
from core.authentication.urls import urlpatterns
def test_urls_override_default_mozilla_django_oidc():
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
url_names = [u.name for u in urlpatterns]
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
@@ -1,231 +0,0 @@
"""Unit tests for the Authentication Views."""
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import crypto
import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
pytestmark = pytest.mark.django_db
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_anonymous():
"""Anonymous users calling the logout url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_custom")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
)
def test_view_logout(mocked_oidc_logout_url):
"""Authenticated users should be redirected to OIDC provider for logout."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
)
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
"""Authenticated users should be logged out when no OIDC provider is available."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/default-redirect-logout"
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback_anonymous():
"""Anonymous users calling the logout callback url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_callback")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize(
"initial_oidc_states",
[{}, {"other_state": "foo"}],
)
def test_view_logout_persist_state(initial_oidc_states):
"""State value should be persisted in session's data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_oidc_states:
request.session["oidc_states"] = initial_oidc_states
request.session.save()
mocked_state = "mock_state"
OIDCLogoutView().persist_state(request, mocked_state)
assert "oidc_states" in request.session
assert request.session["oidc_states"] == {
"mock_state": {},
**initial_oidc_states,
}
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
@mock.patch.object(OIDCLogoutView, "persist_state")
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
def test_view_logout_construct_oidc_logout_url(
mocked_get_random_string, mocked_persist_state
):
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["oidc_id_token"] = "mocked_oidc_id_token"
request.session.save()
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
mocked_persist_state.assert_called_once()
mocked_get_random_string.assert_called_once()
params = parse_qs(urlparse(redirect_url).query)
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
assert params["state"][0] == "mocked_state"
url = reverse("oidc_logout_callback")
assert url in params["post_logout_redirect_uri"][0]
@override_settings(LOGOUT_REDIRECT_URL="/")
def test_view_logout_construct_oidc_logout_url_none_id_token():
"""If no ID token is available in the session,
the user should be redirected to the final URL."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
assert redirect_url == "/"
@pytest.mark.parametrize(
"initial_state",
[None, {"other_state": "foo"}],
)
def test_view_logout_callback_wrong_state(initial_state):
"""Should raise an error if OIDC state doesn't match session data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_state:
request.session["oidc_states"] = initial_state
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with pytest.raises(SuspiciousOperation) as excinfo:
callback_view(request)
assert (
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
)
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback():
"""If state matches, callback should clear OIDC state and redirects."""
user = factories.UserFactory()
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
mocked_state = "mocked_state"
request.session["oidc_states"] = {mocked_state: {}}
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
def clear_user(request):
# Assert state is cleared prior to logout
assert request.session["oidc_states"] == {}
request.user = AnonymousUser()
mock_logout.side_effect = clear_user
response = callback_view(request)
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@@ -52,8 +52,8 @@ def test_api_contacts_update_authenticated_owned(django_assert_num_queries):
).data
new_contact_values["override"] = str(factories.ContactFactory().id)
with django_assert_num_queries(8):
# user, 2x contact, user, 3x check, update contact
with django_assert_num_queries(14):
# user, 2x contact, user, 3x check, update contact, 3x savepoint/release
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
+1
View File
@@ -0,0 +1 @@
"""Test fixtures."""
+75
View File
@@ -0,0 +1,75 @@
"""Define here some fake responses from Matrix API, useful to mock responses in tests."""
from rest_framework import status
# JOIN ROOMS
def mock_join_room_successful(room_id):
"""Mock response when succesfully joining room. Same response if already in room."""
return {"message": {"room_id": str(room_id)}, "status_code": status.HTTP_200_OK}
def mock_join_room_no_known_servers():
"""Mock response when room to join cannot be found."""
return {
"message": {"errcode": "M_UNKNOWN", "error": "No known servers"},
"status_code": status.HTTP_404_NOT_FOUND,
}
def mock_join_room_forbidden():
"""Mock response when room cannot be joined."""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": "You do not belong to any of the required rooms/spaces to join this room.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
# INVITE USER
def mock_invite_successful():
"""Mock response when invite request was succesful. Does not check the user exists."""
return {"message": {}, "status_code": status.HTTP_200_OK}
def mock_invite_user_already_in_room(user):
"""Mock response when invitation forbidden for People user."""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": f"{user.email.replace('@', ':')} is already in the room.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
# KICK USER
def mock_kick_successful():
"""Mock response when succesfully joining room."""
return {"message": {}, "status_code": status.HTTP_200_OK}
def mock_kick_user_forbidden(user):
"""Mock response when kick request is forbidden (i.e. wrong permission or user is room admin."""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": f"You cannot kick user @{user.email.replace('@', ':')}.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
def mock_kick_user_not_in_room():
"""
Mock response when trying to kick a user who isn't in the room. Don't check the user exists.
"""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": "The target user is not in the room",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
@@ -11,16 +11,35 @@ from joserfc import jwe as jose_jwe
from joserfc import jwt as jose_jwt
from joserfc.rfc7518.rsa_key import RSAKey
from jwt.utils import to_base64url_uint
from lasuite.oidc_resource_server.authentication import (
ResourceServerAuthentication,
get_resource_server_backend,
)
from rest_framework.request import Request as DRFRequest
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
from core.factories import UserFactory
from core.models import ServiceProvider
from core.resource_server.authentication import ResourceServerAuthentication
pytestmark = pytest.mark.django_db
@pytest.fixture(name="jwt_resource_server_backend")
def jwt_resource_server_backend_fixture(settings):
"""Fixture to switch the backend to the JWTResourceServerBackend."""
_original_backend = str(settings.OIDC_RS_BACKEND_CLASS)
settings.OIDC_RS_BACKEND_CLASS = (
"lasuite.oidc_resource_server.backend.JWTResourceServerBackend"
)
get_resource_server_backend.cache_clear()
yield
settings.OIDC_RS_BACKEND_CLASS = _original_backend
get_resource_server_backend.cache_clear()
def build_authorization_bearer(token):
"""
Build an Authorization Bearer header value from a token.
@@ -47,6 +66,88 @@ def test_resource_server_authentication_class(client, settings):
`resource_server_token_audience` attribute which is used in
the resource server views.
This test uses the `/resource-server/v1.0/teams/` URL as an example
because we don't want to create a new URL just for this test.
"""
assert (
settings.OIDC_RS_BACKEND_CLASS
== "lasuite.oidc_resource_server.backend.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid groups",
"active": True,
},
)
# Try to authenticate while the user does not exist => 401
response = client.get(
"/resource-server/v1.0/teams/", # use an exising URL here
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert ServiceProvider.objects.count() == 0
# Create a user with the specific sub, the access is authorized
UserFactory(sub="very-specific-sub")
response = client.get(
"/resource-server/v1.0/teams/", # use an exising URL here
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
)
assert response.status_code == HTTP_200_OK
response_request = response.renderer_context.get("request")
assert isinstance(response_request, DRFRequest)
assert isinstance(
response_request.successful_authenticator, ResourceServerAuthentication
)
# Check that the user is authenticated
assert response_request.user.is_authenticated
# Check the request contains the resource server token audience
assert response_request.resource_server_token_audience == "some_service_provider"
# Check that no service provider is created here
assert ServiceProvider.objects.count() == 0
@responses.activate
def test_jwt_resource_server_authentication_class( # pylint: disable=unused-argument
client, jwt_resource_server_backend, settings
):
"""
Defines the settings for the resource server
for a full authentication with introspection process.
This is an integration test that checks the authentication process
when using the ResourceServerAuthentication class.
This test asserts the DRF request object contains the
`resource_server_token_audience` attribute which is used in
the resource server views.
This test uses the `/resource-server/v1.0/teams/` URL as an example
because we don't want to create a new URL just for this test.
"""
@@ -134,7 +235,8 @@ def test_resource_server_authentication_class(client, settings):
"token_introspection": {
"sub": "very-specific-sub",
"iss": "https://oidc.example.com",
"aud": "some_service_provider",
"aud": "some_client_id",
"client_id": "some_service_provider",
"scope": "openid groups",
"active": True,
},
@@ -1,447 +0,0 @@
"""
Test for the Resource Server (RS) Backend.
"""
# pylint: disable=W0212
from logging import Logger
from unittest.mock import Mock, patch
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
from joserfc.errors import InvalidClaimError, InvalidTokenError
from joserfc.jwt import JWTClaimsRegistry
from requests.exceptions import HTTPError
from core.resource_server.backend import ResourceServerBackend
@pytest.fixture(name="mock_authorization_server")
def fixture_mock_authorization_server():
"""Mock an Authorization Server client."""
mock_server = Mock()
mock_server.url = "https://auth.server.com"
return mock_server
@pytest.fixture(name="mock_token")
def fixture_mock_token():
"""Mock a token"""
mock_token = Mock()
mock_token.claims = {"sub": "user123", "iss": "https://auth.server.com"}
return mock_token
@pytest.fixture(name="resource_server_backend")
def fixture_resource_server_backend(settings, mock_authorization_server):
"""Generate a Resource Server backend."""
settings.OIDC_RS_CLIENT_ID = "client_id"
settings.OIDC_RS_CLIENT_SECRET = "client_secret"
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
settings.OIDC_RS_SIGNING_ALGO = "ES256"
settings.OIDC_RS_SCOPES = ["groups"]
return ResourceServerBackend(mock_authorization_server)
@override_settings(OIDC_RS_CLIENT_ID="client_id")
@override_settings(OIDC_RS_CLIENT_SECRET="client_secret")
@override_settings(OIDC_RS_ENCRYPTION_ENCODING="A256GCM")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RSA-OAEP")
@override_settings(OIDC_RS_SIGNING_ALGO="RS256")
@override_settings(OIDC_RS_SCOPES=["scopes"])
@patch.object(auth, "get_user_model", return_value="foo")
def test_backend_initialization(mock_get_user_model, mock_authorization_server):
"""Test the ResourceServerBackend initialization."""
backend = ResourceServerBackend(mock_authorization_server)
mock_get_user_model.assert_called_once()
assert backend.UserModel == "foo"
assert backend._client_id == "client_id"
assert backend._client_secret == "client_secret"
assert backend._encryption_encoding == "A256GCM"
assert backend._encryption_algorithm == "RSA-OAEP"
assert backend._signing_algorithm == "RS256"
assert backend._scopes == ["scopes"]
assert backend._authorization_server_client == mock_authorization_server
assert isinstance(backend._claims_registry, JWTClaimsRegistry)
assert backend._claims_registry.options == {
"iss": {"essential": True, "value": "https://auth.server.com"},
"aud": {"essential": True, "value": "client_id"},
"token_introspection": {"essential": True},
}
@patch.object(ResourceServerBackend, "get_user", return_value="user")
def test_get_or_create_user(mock_get_user, resource_server_backend):
"""Test 'get_or_create_user' method."""
access_token = "access_token"
res = resource_server_backend.get_or_create_user(access_token, None, None)
mock_get_user.assert_called_once_with(access_token)
assert res == "user"
def test_verify_claims_success(resource_server_backend, mock_token):
"""Test '_verify_claims' method with a successful response."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
resource_server_backend._verify_claims(mock_token)
mock_validate.assert_called_once_with(mock_token.claims)
def test_verify_claims_invalid_claim_error(resource_server_backend, mock_token):
"""Test '_verify_claims' method with an invalid claim error."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
mock_validate.side_effect = InvalidClaimError("claim_name")
expected_message = "Failed to validate token's claims"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_claims(mock_token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_verify_claims_invalid_token_error(resource_server_backend, mock_token):
"""Test '_verify_claims' method with an invalid token error."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
mock_validate.side_effect = InvalidTokenError
expected_message = "Failed to validate token's claims"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_claims(mock_token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_decode_success(resource_server_backend):
"""Test '_decode' method with a successful response."""
encoded_token = Mock()
encoded_token.plaintext = "valid_encoded_token"
public_key_set = Mock()
expected_decoded_token = {"sub": "user123"}
with patch(
"joserfc.jwt.decode", return_value=expected_decoded_token
) as mock_decode:
decoded_token = resource_server_backend._decode(encoded_token, public_key_set)
mock_decode.assert_called_once_with(
"valid_encoded_token", public_key_set, algorithms=["ES256"]
)
assert decoded_token == expected_decoded_token
def test_decode_failure(resource_server_backend):
"""Test '_decode' method with a ValueError"""
encoded_token = Mock()
encoded_token.plaintext = "invalid_encoded_token"
public_key_set = Mock()
with patch("joserfc.jwt.decode", side_effect=ValueError):
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match="Token decoding failed"):
resource_server_backend._decode(encoded_token, public_key_set)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", "Token decoding failed", exc_info=True
)
def test_decrypt_success(resource_server_backend):
"""Test '_decrypt' method with a successful response."""
encrypted_token = "valid_encrypted_token"
private_key = "private_key"
expected_decrypted_token = {"sub": "user123"}
with patch(
"joserfc.jwe.decrypt_compact", return_value=expected_decrypted_token
) as mock_decrypt:
decrypted_token = resource_server_backend._decrypt(encrypted_token, private_key)
mock_decrypt.assert_called_once_with(
encrypted_token, private_key, algorithms=["RSA-OAEP", "A256GCM"]
)
assert decrypted_token == expected_decrypted_token
def test_decrypt_failure(resource_server_backend):
"""Test '_decrypt' method with an Exception."""
encrypted_token = "invalid_encrypted_token"
private_key = "private_key"
with patch(
"joserfc.jwe.decrypt_compact", side_effect=Exception("Decryption error")
):
expected_message = "Token decryption failed"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._decrypt(encrypted_token, private_key)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
@patch(
"core.resource_server.utils.import_private_key_from_settings",
return_value="private_key",
)
# pylint: disable=unused-argument
def test_introspect_success(
mock_import_private_key_from_settings, resource_server_backend
):
"""Test '_introspect' method with a successful response."""
token = "valid_token"
jwe = "valid_jwe"
jws = "valid_jws"
jwt = {"sub": "user123"}
resource_server_backend._authorization_server_client.get_introspection = Mock(
return_value=jwe
)
resource_server_backend._decrypt = Mock(return_value=jws)
resource_server_backend._authorization_server_client.import_public_keys = Mock(
return_value="public_key_set"
)
resource_server_backend._decode = Mock(return_value=jwt)
result = resource_server_backend._introspect(token)
assert result == jwt
resource_server_backend._authorization_server_client.get_introspection.assert_called_once_with(
"client_id", "client_secret", token
)
resource_server_backend._decrypt.assert_called_once_with(
jwe, private_key="private_key"
)
resource_server_backend._authorization_server_client.import_public_keys.assert_called_once()
resource_server_backend._decode.assert_called_once_with(jws, "public_key_set")
def test_introspect_introspection_failure(resource_server_backend):
"""Test '_introspect' method when introspection to the AS fails."""
token = "invalid_token"
resource_server_backend._authorization_server_client.get_introspection.side_effect = HTTPError(
"Introspection error"
)
with patch.object(Logger, "debug") as mock_logger_debug:
expected_message = "Could not fetch introspection"
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._introspect(token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
@patch(
"core.resource_server.utils.import_private_key_from_settings",
return_value="private_key",
)
# pylint: disable=unused-argument
def test_introspect_public_key_import_failure(
mock_import_private_key_from_settings, resource_server_backend
):
"""Test '_introspect' method when fetching AS's jwks fails."""
token = "valid_token"
jwe = "valid_jwe"
jws = "valid_jws"
resource_server_backend._authorization_server_client.get_introspection = Mock(
return_value=jwe
)
resource_server_backend._decrypt = Mock(return_value=jws)
resource_server_backend._authorization_server_client.import_public_keys.side_effect = HTTPError(
"Public key error"
)
with patch.object(Logger, "debug") as mock_logger_debug:
expected_message = "Could get authorization server JWKS"
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._introspect(token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_verify_user_info_success(resource_server_backend):
"""Test '_verify_user_info' with a successful response."""
introspection_response = {"active": True, "scope": "groups", "aud": "123"}
result = resource_server_backend._verify_user_info(introspection_response)
assert result == introspection_response
def test_verify_user_info_inactive(resource_server_backend):
"""Test '_verify_user_info' with an inactive introspection response."""
introspection_response = {"active": False, "scope": "groups"}
expected_message = "Introspection response is not active."
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_user_info(introspection_response)
mock_logger_debug.assert_called_once_with(expected_message)
def test_verify_user_info_wrong_scopes(resource_server_backend):
"""Test '_verify_user_info' with wrong requested scopes."""
introspection_response = {"active": True, "scope": "wrong-scopes"}
expected_message = "Introspection response contains any required scopes."
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_user_info(introspection_response)
mock_logger_debug.assert_called_once_with(expected_message)
def test_get_user_success(resource_server_backend):
"""Test '_get_user' with a successful response."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {"sub": "user123", "aud": "123"}}
mock_user = Mock()
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get = Mock(return_value=mock_user)
user = resource_server_backend.get_user(access_token)
assert user == mock_user
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_called_once_with(
mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get.assert_called_once_with(sub="user123")
def test_get_user_could_not_introspect(resource_server_backend):
"""Test '_get_user' with introspection failing."""
access_token = "valid_access_token"
resource_server_backend._introspect = Mock(
side_effect=SuspiciousOperation("Invalid jwt")
)
resource_server_backend._verify_claims = Mock()
resource_server_backend._verify_user_info = Mock()
with pytest.raises(SuspiciousOperation, match="Invalid jwt"):
resource_server_backend.get_user(access_token)
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_not_called()
resource_server_backend._verify_user_info.assert_not_called()
def test_get_user_invalid_introspection_response(resource_server_backend):
"""Test '_get_user' with an invalid introspection response."""
access_token = "valid_access_token"
mock_jwt = Mock()
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(
side_effect=SuspiciousOperation("Invalid claims")
)
resource_server_backend._verify_user_info = Mock()
with pytest.raises(SuspiciousOperation, match="Invalid claims"):
resource_server_backend.get_user(access_token)
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_not_called()
def test_get_user_user_not_found(resource_server_backend):
"""Test '_get_user' if the user is not found."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {"sub": "user123"}}
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get = Mock(
side_effect=resource_server_backend.UserModel.DoesNotExist
)
with patch.object(Logger, "debug") as mock_logger_debug:
user = resource_server_backend.get_user(access_token)
assert user is None
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_called_once_with(
mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get.assert_called_once_with(
sub="user123"
)
mock_logger_debug.assert_called_once_with(
"Login failed: No user with %s found", "user123"
)
def test_get_user_no_user_identification(resource_server_backend):
"""Test '_get_user' if the response miss a user identification."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {}}
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
expected_message = "User info contained no recognizable user identification"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend.get_user(access_token)
mock_logger_debug.assert_called_once_with(expected_message)
@@ -1,187 +0,0 @@
"""
Test for the Resource Server (RS) clients classes.
"""
# pylint: disable=W0212
from unittest.mock import MagicMock, patch
import pytest
from joserfc.jwk import KeySet, RSAKey
from requests.exceptions import HTTPError
from core.resource_server.clients import AuthorizationServerClient
@pytest.fixture(name="client")
def fixture_client():
"""Generate an Authorization Server client."""
return AuthorizationServerClient(
url="https://auth.example.com/api/v2",
url_jwks="https://auth.example.com/api/v2/jwks",
url_introspection="https://auth.example.com/api/v2/introspect",
verify_ssl=True,
timeout=5,
proxy=None,
)
def test_authorization_server_client_initialization():
"""Test the AuthorizationServerClient initialization."""
new_client = AuthorizationServerClient(
url="https://auth.example.com/api/v2",
url_jwks="https://auth.example.com/api/v2/jwks",
url_introspection="https://auth.example.com/api/v2/checktoken/foo",
verify_ssl=True,
timeout=5,
proxy=None,
)
assert new_client.url == "https://auth.example.com/api/v2"
assert (
new_client._url_introspection
== "https://auth.example.com/api/v2/checktoken/foo"
)
assert new_client._url_jwks == "https://auth.example.com/api/v2/jwks"
assert new_client._verify_ssl is True
assert new_client._timeout == 5
assert new_client._proxy is None
def test_introspection_headers(client):
"""Test the introspection headers to ensure they match the expected values."""
assert client._introspection_headers == {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
}
@patch("requests.post")
def test_get_introspection_success(mock_post, client):
"""Test 'get_introspection' method with a successful response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.text = "introspection response"
mock_post.return_value = mock_response
result = client.get_introspection("client_id", "client_secret", "token")
assert result == "introspection response"
mock_post.assert_called_once_with(
"https://auth.example.com/api/v2/introspect",
data={
"client_id": "client_id",
"client_secret": "client_secret",
"token": "token",
},
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
},
verify=True,
timeout=5,
proxies=None,
)
@patch("requests.post", side_effect=HTTPError())
# pylint: disable=(unused-argument
def test_get_introspection_error(mock_post, client):
"""Test 'get_introspection' method with an HTTPError."""
with pytest.raises(HTTPError):
client.get_introspection("client_id", "client_secret", "token")
@patch("requests.get")
def test_get_jwks_success(mock_get, client):
"""Test 'get_jwks' method with a successful response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"jwks": "foo"}
mock_get.return_value = mock_response
result = client.get_jwks()
assert result == {"jwks": "foo"}
mock_get.assert_called_once_with(
"https://auth.example.com/api/v2/jwks",
verify=client._verify_ssl,
timeout=client._timeout,
proxies=client._proxy,
)
@patch("requests.get")
def test_get_jwks_error(mock_get, client):
"""Test 'get_jwks' method with an HTTPError."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = HTTPError(
response=MagicMock(status=500)
)
mock_get.return_value = mock_response
with pytest.raises(HTTPError):
client.get_jwks()
@patch("requests.get")
def test_import_public_keys_valid(mock_get, client):
"""Test 'import_public_keys' method with a successful response."""
mocked_key = RSAKey.generate_key(2048)
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": [mocked_key.as_dict()]}
mock_get.return_value = mock_response
response = client.import_public_keys()
assert isinstance(response, KeySet)
assert response.as_dict() == KeySet([mocked_key]).as_dict()
@patch("requests.get")
def test_import_public_keys_http_error(mock_get, client):
"""Test 'import_public_keys' method with an HTTPError."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = HTTPError(
response=MagicMock(status=500)
)
mock_get.return_value = mock_response
with pytest.raises(HTTPError):
client.import_public_keys()
@patch("requests.get")
def test_import_public_keys_empty_jwks(mock_get, client):
"""Test 'import_public_keys' method with empty keys response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": []}
mock_get.return_value = mock_response
response = client.import_public_keys()
assert isinstance(response, KeySet)
assert response.as_dict() == {"keys": []}
@patch("requests.get")
def test_import_public_keys_invalid_jwks(mock_get, client):
"""Test 'import_public_keys' method with invalid keys response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": [{"foo": "foo"}]}
mock_get.return_value = mock_response
with pytest.raises(ValueError):
client.import_public_keys()
@@ -1,88 +0,0 @@
"""
Test for the Resource Server (RS) utils functions.
"""
from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
import pytest
from joserfc.jwk import ECKey, RSAKey
from core.resource_server.utils import import_private_key_from_settings
PRIVATE_KEY_STR_MOCKED = """-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
MbyqKyC6DAzv4jEEhHaN7oY=
-----END PRIVATE KEY-----
"""
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@pytest.mark.parametrize("mocked_private_key", [None, ""])
def test_import_private_key_from_settings_missing_or_empty_key(
settings, mocked_private_key
):
"""Should raise an exception if the settings 'OIDC_RS_PRIVATE_KEY_STR' is missing or empty."""
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
with pytest.raises(
ImproperlyConfigured,
match="OIDC_RS_PRIVATE_KEY_STR setting is missing or empty.",
):
import_private_key_from_settings()
@pytest.mark.parametrize("mocked_private_key", ["123", "foo", "invalid_key"])
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
def test_import_private_key_from_settings_incorrect_key(settings, mocked_private_key):
"""Should raise an exception if the setting 'OIDC_RS_PRIVATE_KEY_STR' has an incorrect value."""
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
with pytest.raises(
ImproperlyConfigured, match="OIDC_RS_PRIVATE_KEY_STR setting is wrong."
):
import_private_key_from_settings()
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
def test_import_private_key_from_settings_success_rsa_key():
"""Should import private key string as an RSA key."""
private_key = import_private_key_from_settings()
assert isinstance(private_key, RSAKey)
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="EC")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="ES256")
def test_import_private_key_from_settings_success_ec_key():
"""Should import private key string as an EC key."""
private_key = import_private_key_from_settings()
assert isinstance(private_key, ECKey)
@@ -1,70 +0,0 @@
"""
Tests for the Resource Server (RS) Views.
"""
from unittest import mock
from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse
import pytest
from joserfc.jwk import RSAKey
from rest_framework.test import APIClient
pytestmark = pytest.mark.django_db
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_valid_public_key(mock_import_private_key_from_settings):
"""JWKs endpoint should return a set of valid Json Web Key"""
mocked_key = RSAKey.generate_key(2048)
mock_import_private_key_from_settings.return_value = mocked_key
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 200
assert response["Content-Type"] == "application/json"
jwks = response.json()
assert jwks == {"keys": [mocked_key.as_dict(private=False)]}
# Security checks to make sure no details from the private key are exposed
private_details = ["d", "p", "q", "dp", "dq", "qi", "oth", "r", "t"]
assert all(
private_detail not in jwks["keys"][0].keys()
for private_detail in private_details
)
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_invalid_private_key(mock_import_private_key_from_settings):
"""JWKS endpoint should return a proper exception when loading keys fails."""
mock_import_private_key_from_settings.return_value = "wrong_key"
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 500
assert response.json() == {"error": "Could not load key"}
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_missing_private_key(mock_import_private_key_from_settings):
"""JWKS endpoint should return a proper exception when private key is missing."""
mock_import_private_key_from_settings.side_effect = ImproperlyConfigured("foo.")
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 500
assert response.json() == {"error": "foo."}
@@ -9,8 +9,7 @@ from django.contrib.auth import get_user_model
import pytest
import responses
from faker import Faker
from core.resource_server.authentication import ResourceServerAuthentication
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
User = get_user_model()
fake = Faker()
@@ -48,7 +47,7 @@ def _force_login_via_resource_server(
):
client_fixture.force_login(
user,
backend="core.resource_server.authentication.ResourceServerAuthentication",
backend="lasuite.oidc_resource_server.authentication.ResourceServerAuthentication",
)
yield
@@ -0,0 +1 @@
"""Tests for the resource server SCIM API endpoints."""
@@ -0,0 +1,186 @@
"""
Tests for the SCIM Me API endpoint in People's core app
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
HTTP_405_METHOD_NOT_ALLOWED,
)
from core import factories
from core.models import RoleChoices
pytestmark = pytest.mark.django_db
def test_api_me_anonymous(client):
"""Anonymous users should not be allowed to access the Me endpoint."""
response = client.get("/resource-server/v1.0/scim/Me/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "401",
"detail": "",
}
def test_api_me_authenticated(
client, force_login_via_resource_server, django_assert_num_queries
):
"""
Authenticated users should be able to access their own information
in SCIM format.
"""
user = factories.UserFactory(name="Test User", email="test@example.com")
service_provider = factories.ServiceProviderFactory()
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
with django_assert_num_queries(2):
response = client.get(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": str(user.pk),
"active": True,
"userName": user.sub,
"displayName": user.name,
"emails": [
{
"value": user.email,
"primary": True,
"type": "work",
}
],
"groups": [],
"meta": {
"resourceType": "User",
"created": user.created_at.isoformat(),
"lastModified": user.updated_at.isoformat(),
"location": "http://testserver/resource-server/v1.0/scim/Me/",
},
}
def test_api_me_authenticated_with_team_access(
client, force_login_via_resource_server, django_assert_num_queries
):
"""
Authenticated users with TeamAccess should see their team information
in SCIM format, but only for teams visible to the service provider.
"""
user = factories.UserFactory(name="Test User", email="test@example.com")
service_provider = factories.ServiceProviderFactory()
# Create teams with different visibility settings
team_visible = factories.TeamFactory(name="Visible Team")
team_visible.service_providers.add(service_provider)
team_all_services = factories.TeamFactory(
name="All Services Team", is_visible_all_services=True
)
team_not_visible = factories.TeamFactory(name="Not Visible Team")
# This team is not associated with the service provider
# Add user to all teams
factories.TeamAccessFactory(user=user, team=team_visible, role=RoleChoices.MEMBER)
factories.TeamAccessFactory(
user=user, team=team_all_services, role=RoleChoices.ADMIN
)
factories.TeamAccessFactory(
user=user, team=team_not_visible, role=RoleChoices.OWNER
)
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
with django_assert_num_queries(
2
): # User + TeamAccess (with select_related teams)
response = client.get(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": str(user.pk),
"active": True,
"userName": user.sub,
"displayName": user.name,
"emails": [
{
"value": user.email,
"primary": True,
"type": "work",
}
],
"groups": [
{
"value": str(team_visible.external_id),
"display": team_visible.name,
"type": "direct",
},
{
"value": str(team_all_services.external_id),
"display": team_all_services.name,
"type": "direct",
},
],
"meta": {
"resourceType": "User",
"created": user.created_at.isoformat(),
"lastModified": user.updated_at.isoformat(),
"location": "http://testserver/resource-server/v1.0/scim/Me/",
},
}
@pytest.mark.parametrize(
"http_method",
["post", "put", "patch", "delete"],
ids=["POST", "PUT", "PATCH", "DELETE"],
)
def test_api_me_method_not_allowed(
client, force_login_via_resource_server, http_method
):
"""Test that methods other than GET are not allowed for the Me endpoint."""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
client_method = getattr(client, http_method)
response = client_method(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": str(HTTP_405_METHOD_NOT_ALLOWED),
"detail": "",
}
@@ -3,14 +3,17 @@ Test for team accesses API endpoints in People's core app : create
"""
import json
import logging
import random
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories, models
from core import enums, factories, models
from core.tests.fixtures import matrix
pytestmark = pytest.mark.django_db
@@ -171,14 +174,17 @@ def test_api_team_accesses_create_authenticated_owner():
}
def test_api_team_accesses_create_webhook():
def test_api_team_accesses_create__with_scim_webhook():
"""
When the team has a webhook, creating a team access should fire a call.
If a team has a SCIM webhook, creating a team access should fire a call
with the expected payload.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory(team=team)
webhook = factories.TeamWebhookFactory(
team=team, protocol=enums.WebhookProtocolChoices.SCIM
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
@@ -226,3 +232,139 @@ def test_api_team_accesses_create_webhook():
}
],
}
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
def test_api_team_accesses_create__multiple_webhooks_success(caplog):
"""
When the team has multiple webhooks, creating a team access should fire all the expected calls.
If all responses are positive, proceeds to add the user to the team.
"""
caplog.set_level(logging.INFO)
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook_scim = factories.TeamWebhookFactory(
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
)
webhook_matrix = factories.TeamWebhookFactory(
team=team,
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
protocol=enums.WebhookProtocolChoices.MATRIX,
secret="yo",
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
# Logger
log_messages = [msg.message for msg in caplog.records]
for webhook in [webhook_scim, webhook_matrix]:
assert (
f"add_user_to_group synchronization succeeded with {webhook.url}"
in log_messages
)
# Status
for webhook in [webhook_scim, webhook_matrix]:
webhook.refresh_from_db()
assert webhook.status == "success"
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@responses.activate
def test_api_team_accesses_create__multiple_webhooks_failure(caplog):
"""When a webhook fails, user should still be added to the team."""
caplog.set_level(logging.INFO)
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook_scim = factories.TeamWebhookFactory(
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
)
webhook_matrix = factories.TeamWebhookFactory(
team=team,
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
protocol=enums.WebhookProtocolChoices.MATRIX,
secret="secret",
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
responses.patch(
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_forbidden()["message"]),
status=str(matrix.mock_join_room_forbidden()["status_code"]),
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# Logger
log_messages = [msg.message for msg in caplog.records]
assert (
f"add_user_to_group synchronization succeeded with {webhook_scim.url}"
in log_messages
)
assert (
f"add_user_to_group synchronization failed with {webhook_matrix.url}"
in log_messages
)
# Status
webhook_scim.status = "success"
webhook_matrix.status = "failure"
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@@ -4,7 +4,6 @@ Test for team accesses API endpoints in People's core app : delete
import json
import random
import re
import pytest
import responses
@@ -157,16 +156,18 @@ def test_api_team_accesses_delete_owners_last_owner():
assert models.TeamAccess.objects.count() == 1
@responses.activate
def test_api_team_accesses_delete_webhook():
"""
When the team has a webhook, deleting a team access should fire a call.
"""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
webhook = factories.TeamWebhookFactory(team=team)
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
# add webhook after access to prevent webhook from being triggered
webhook = factories.TeamWebhookFactory(team=team)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
@@ -174,42 +175,34 @@ def test_api_team_accesses_delete_webhook():
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
patch_response = responses.patch(webhook.url, status=200, json={})
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Check the request was made
assert patch_response.call_count == 1
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": access.user.email,
"type": "User",
}
],
}
],
}
# Payload sent to scim provider
scim_payload = json.loads(patch_response.calls[0].request.body)
assert scim_payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": access.user.email,
"type": "User",
}
],
}
],
}
assert models.TeamAccess.objects.count() == 1
assert models.TeamAccess.objects.filter(user=access.user).exists() is False
@@ -19,6 +19,7 @@ def test_api_config_anonymous():
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"CRISP_WEBSITE_ID": None,
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
"COMMIT": "NA",
"FEATURES": {
@@ -42,6 +43,7 @@ def test_api_config_authenticated():
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"CRISP_WEBSITE_ID": None,
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
"COMMIT": "NA",
"FEATURES": {
+6 -5
View File
@@ -19,7 +19,7 @@ pytestmark = pytest.mark.django_db
def test_api_stats__anonymous(django_assert_num_queries):
"""Stats endpoint should be available even when not connected."""
domains_factories.MailDomainFactory.create_batch(5)
domains_factories.MailDomainEnabledFactory.create_batch(5)
core_factories.TeamFactory.create_batch(3)
# clear cache to allow stats count
@@ -30,7 +30,7 @@ def test_api_stats__anonymous(django_assert_num_queries):
assert response.json() == {
"total_users": 0,
"mau": 0,
"domains": 5,
"active_domains": 5,
"mailboxes": 0,
"teams": 3,
}
@@ -41,7 +41,7 @@ def test_api_stats__anonymous(django_assert_num_queries):
assert response.json() == {
"total_users": 0,
"mau": 0,
"domains": 5,
"active_domains": 5,
"mailboxes": 0,
"teams": 3,
}
@@ -57,7 +57,8 @@ def test_api_stats__expected_count():
client.force_login(user)
core_factories.TeamFactory.create_batch(3)
domains_factories.MailDomainFactory.create_batch(2)
domains_factories.MailDomainFactory.create_batch(1)
domains_factories.MailDomainEnabledFactory.create_batch(2)
domains_factories.MailboxFactory.create_batch(
10, domain=domains_models.MailDomain.objects.all()[1]
)
@@ -69,7 +70,7 @@ def test_api_stats__expected_count():
assert response.json() == {
"total_users": 10,
"mau": 6,
"domains": 2,
"active_domains": 2,
"mailboxes": 10,
"teams": 3,
}
@@ -0,0 +1,36 @@
"""Tests the core application loads the management command as tasks."""
from unittest.mock import patch
from people.celery_app import app as celery_app
def test_fill_organization_metadata_as_task(settings):
"""Check the fill_organization_metadata command is loaded as a task."""
# Verify the command is configured to be loaded as a task
assert "fill_organization_metadata" in settings.MANAGEMENT_COMMAND_AS_TASK
# The task should be registered in the format "app_name.command_name"
task_name = "core.fill_organization_metadata"
assert task_name in celery_app.tasks
# Test that the task can be executed properly
with patch("core.apps.call_command") as mock_call_command:
# Get the registered task
task = celery_app.tasks[task_name]
# Execute the task
result = task("arg1", "arg2", kwarg1="value1", kwarg2="value2")
# Verify call_command was called with the correct command name
mock_call_command.assert_called_once()
assert mock_call_command.call_args[0][0] == "fill_organization_metadata"
assert mock_call_command.call_args[0][1] == "arg1"
assert mock_call_command.call_args[0][2] == "arg2"
assert mock_call_command.call_args[1]["kwarg1"] == "value1"
assert mock_call_command.call_args[1]["kwarg2"] == "value2"
# Verify the task returns a dictionary with stdout and stderr
assert isinstance(result, dict)
assert "stdout" in result
assert "stderr" in result
@@ -0,0 +1,46 @@
"""
Unit tests for the Team model
"""
from django.core.exceptions import ValidationError
from django.test import override_settings
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
def test_models_account_services_validate_scope():
"""Test that account service scopes are validated."""
# Valid scope from settings.ACCOUNT_SERVICE_SCOPES
account_service = factories.AccountServiceFactory(
scopes=["la-suite-list-organizations-siret"]
)
assert account_service.scopes == ["la-suite-list-organizations-siret"]
# Invalid scope
with pytest.raises(ValidationError, match="Invalid scope: invalid-scope"):
factories.AccountServiceFactory(scopes=["invalid-scope"])
# Multiple scopes with one invalid
with pytest.raises(ValidationError, match="Invalid scope: invalid-scope"):
factories.AccountServiceFactory(
scopes=["la-suite-list-organizations-siret", "invalid-scope"]
)
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
def test_models_account_services_name_null():
"""The "name" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null."):
models.AccountService.objects.create(name=None)
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
def test_models_account_services_name_empty():
"""The "name" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.AccountService.objects.create(name="")
@@ -35,7 +35,7 @@ def test_models_team_accesses_unique():
with pytest.raises(
ValidationError,
match="Team/user relation with this User and Team already exists.",
match="This user is already in this team.",
):
factories.TeamAccessFactory(user=access.user, team=access.team)
@@ -83,47 +83,41 @@ def test_models_team_accesses_create_webhook():
}
@responses.activate
def test_models_team_accesses_delete_webhook():
"""
When the team has a webhook, deleting a team access should fire a call.
"""
team = factories.TeamFactory()
webhook = factories.TeamWebhookFactory(team=team)
access = factories.TeamAccessFactory(team=team)
# add webhook after access to prevent webhook from being triggered
webhook = factories.TeamWebhookFactory(team=team)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
# Ensure successful response by scim provider using "responses":
rsp = responses.patch(webhook.url, status=200, json={})
access.delete()
access.delete()
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
assert rsp.call_count == 1
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": access.user.email,
"type": "User",
}
],
}
],
}
# Payload sent to scim provider
payload = json.loads(rsp.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": access.user.email,
"type": "User",
}
],
}
],
}
assert models.TeamAccess.objects.exists() is False
@@ -0,0 +1,50 @@
"""Test the production settings for password validation is correct."""
from django.contrib.auth.password_validation import (
get_default_password_validators,
validate_password,
)
import pytest
from core.factories import UserFactory
from people.settings import Production
pytestmark = pytest.mark.django_db
@pytest.fixture(name="use_production_password_validators")
def use_production_password_validators_fixture(settings):
"""Set the production password validators."""
settings.AUTH_PASSWORD_VALIDATORS = Production.AUTH_PASSWORD_VALIDATORS
get_default_password_validators.cache_clear()
assert len(get_default_password_validators()) == 5
yield
get_default_password_validators.cache_clear()
@pytest.mark.parametrize(
"password, error",
[
("password", "This password is too common."),
("password123", "This password is too common."),
("123", "This password is too common."),
("coucou", "This password is too common."),
("john doe 123", "The password is too similar to the name"),
],
)
def test_validate_password_validator(
use_production_password_validators, # pylint: disable=unused-argument
password,
error,
):
"""Test the Mailbox password validation."""
user = UserFactory(name="John Doe")
with pytest.raises(Exception) as excinfo:
validate_password(password, user)
assert error in str(excinfo.value)
@@ -31,26 +31,29 @@ def test_api_users_list_anonymous():
def test_api_users_list_authenticated():
"""
Authenticated users should be able to list all users.
Authenticated users should be able to list users from their organization.
"""
user = factories.UserFactory()
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(2)
factories.UserFactory(organization=organization)
factories.UserFactory.create_batch(2) # 2 users outside organization
response = client.get(
"/api/v1.0/users/",
)
assert response.status_code == HTTP_200_OK
assert len(response.json()["results"]) == 3
assert len(response.json()["results"]) == 2
def test_api_users_list_authenticated_response_content(
client, django_assert_num_queries
):
"""
Authenticated users should be able to list all users with the expected output.
Authenticated users should be able to list all users from their organization
with the expected output.
"""
user_organization = factories.OrganizationFactory(
with_registration_id=True, name="HAL 9000"
@@ -67,7 +70,7 @@ def test_api_users_list_authenticated_response_content(
other_user_organization = factories.OrganizationFactory(
with_registration_id=True, name="Corp. Inc."
)
other_user = factories.UserFactory(
factories.UserFactory(
organization=other_user_organization,
email="sara83@example.com",
name="Christopher Thompson",
@@ -85,23 +88,10 @@ def test_api_users_list_authenticated_response_content(
.first()
)
assert edited_json == {
"count": 2,
"count": 1,
"next": None,
"previous": None,
"results": [
{
"email": "sara83@example.com",
"id": str(other_user.pk),
"is_device": False,
"is_staff": False,
"language": "fr-fr",
"name": "Christopher Thompson",
"organization": {
"id": str(other_user.organization.pk),
"name": "Corp. Inc.",
},
"timezone": "UTC",
},
{
"email": "kylefields@example.net",
"id": str(user.pk),
@@ -124,13 +114,17 @@ def test_api_users_authenticated_list_by_email():
Authenticated users should be able to search users with a case-insensitive and
partial query on the email.
"""
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
dave = factories.UserFactory(email="david.bowman@work.com", name=None)
user = factories.UserFactory(
email="tester@ministry.fr", name="john doe", with_organization=True
)
dave = factories.UserFactory(
email="david.bowman@work.com", name=None, organization=user.organization
)
nicole = factories.UserFactory(
email="nicole_foole@work.com", name=None, with_organization=True
email="nicole_foole@work.com", name=None, organization=user.organization
)
frank = factories.UserFactory(
email="frank_poole@work.com", name=None, with_organization=True
email="frank_poole@work.com", name=None, organization=user.organization
)
factories.UserFactory(email="heywood_floyd@work.com", name=None)
@@ -203,13 +197,17 @@ def test_api_users_authenticated_list_by_name():
Authenticated users should be able to search users with a case-insensitive and
partial query on the name.
"""
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
dave = factories.UserFactory(name="Dave bowman", email=None)
user = factories.UserFactory(
email="tester@ministry.fr", name="john doe", with_organization=True
)
dave = factories.UserFactory(
name="Dave bowman", email=None, organization=user.organization
)
nicole = factories.UserFactory(
name="nicole foole", email=None, with_organization=True
name="nicole foole", email=None, organization=user.organization
)
frank = factories.UserFactory(
name="frank poolé", email=None, with_organization=True
name="frank poolé", email=None, organization=user.organization
)
factories.UserFactory(name="heywood floyd", email=None)
@@ -0,0 +1,277 @@
"""Test Team synchronization webhooks : focus on matrix client."""
import json
import logging
import re
from django.test import override_settings
import pytest
import responses
from rest_framework import status
from core import factories
from core.enums import WebhookProtocolChoices
from core.tests.fixtures import matrix
from core.utils.webhooks import webhooks_synchronizer
pytestmark = pytest.mark.django_db
## INVITE
@responses.activate
def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
"""Cannot invite when Matrix returns forbidden. This might mean the user is an admin."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
error = matrix.mock_kick_user_forbidden(user)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/invite"),
body=str(error["message"]),
status=error["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
@responses.activate
def test_matrix_webhook__invite_user_to_room_already_in_room(caplog):
"""If user is already in room, webhooks should be set to success."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_user_already_in_room(user)["message"]),
status=matrix.mock_invite_user_already_in_room(user)["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
# Logger
log_messages = [msg.message for msg in caplog.records]
expected_messages = (
f"add_user_to_group synchronization succeeded with {webhook.url}"
)
assert expected_messages in log_messages
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
def test_matrix_webhook__invite_user_to_room_success(caplog):
"""The user passed to the function should get invited."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
# Check headers
headers = responses.calls[0].request.headers
assert webhook.secret in headers["Authorization"]
# Check payloads sent to Matrix API
assert json.loads(responses.calls[1].request.body) == {
"user_id": f"@{user.email.replace('@', ':')}",
"reason": f"User added to team {webhook.team} on People",
}
# Logger
log_messages = [msg.message for msg in caplog.records]
expected_messages = (
f"add_user_to_group synchronization succeeded with {webhook.url}"
)
assert expected_messages in log_messages
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
@override_settings(TCHAP_ACCESS_TOKEN="TCHAP_TOKEN")
def test_matrix_webhook__override_secret_for_tchap():
"""The user passed to the function should get invited."""
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.tchap.gouv.fr/#/room/room_id:home_server",
secret=None,
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
# Check headers
headers = responses.calls[0].request.headers
assert "TCHAP_TOKEN" in headers["Authorization"]
## KICK
@responses.activate
def test_matrix_webhook__kick_user_from_room_not_in_room(caplog):
"""Webhook should report a success when user was already not in room."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/kick"),
body=str(matrix.mock_kick_user_not_in_room()["message"]),
status=matrix.mock_kick_user_not_in_room()["status_code"],
)
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Logger
log_messages = [msg.message for msg in caplog.records]
assert (
f"remove_user_from_group synchronization succeeded with {webhook.url}"
in log_messages
)
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
def test_matrix_webhook__kick_user_from_room_success(caplog):
"""The user passed to the function should get removed."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/kick"),
body=str(matrix.mock_kick_successful),
status=status.HTTP_200_OK,
)
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Check payloads sent to Matrix API
assert json.loads(responses.calls[1].request.body) == {
"user_id": f"@{user.email.replace('@', ':')}",
"reason": f"User removed from team {webhook.team} on People",
}
# Logger
log_messages = [msg.message for msg in caplog.records]
expected_messages = (
f"remove_user_from_group synchronization succeeded with {webhook.url}"
)
assert expected_messages in log_messages
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
"""Cannot kick an admin."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
error = matrix.mock_kick_user_forbidden(user)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/kick"),
body=str(error["message"]),
status=error["status_code"],
)
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Logger
log_messages = [msg.message for msg in caplog.records]
assert (
f"remove_user_from_group synchronization failed with {webhook.url}"
in log_messages
)
# Status
webhook.refresh_from_db()
assert webhook.status == "failure"
@@ -1,4 +1,4 @@
"""Test Team synchronization webhooks."""
"""Test Team synchronization webhooks : focus on scim client"""
import json
import random
@@ -10,7 +10,7 @@ import pytest
import responses
from core import factories
from core.utils.webhooks import scim_synchronizer
from core.utils.webhooks import webhooks_synchronizer
pytestmark = pytest.mark.django_db
@@ -20,7 +20,7 @@ def test_utils_webhooks_add_user_to_group_no_webhooks():
access = factories.TeamAccessFactory()
with responses.RequestsMock():
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
assert len(responses.calls) == 0
@@ -42,7 +42,7 @@ def test_utils_webhooks_add_user_to_group_success(mock_info):
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
@@ -107,7 +107,7 @@ def test_utils_webhooks_remove_user_from_group_success(mock_info):
content_type="application/json",
)
scim_synchronizer.remove_user_from_group(access.team, access.user)
webhooks_synchronizer.remove_user_from_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
@@ -163,11 +163,11 @@ def test_utils_webhooks_add_user_to_group_failure(mock_error):
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=random.choice([404, 301, 302]),
status=404,
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
@@ -228,7 +228,7 @@ def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
rsps.add(rsps.PATCH, url, status=200, content_type="application/json"),
]
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
for i in range(4):
assert all_rsps[i].call_count == 1
@@ -285,7 +285,7 @@ def test_utils_synchronize_course_runs_max_retries_exceeded(mock_error):
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
assert rsp.call_count == 5
assert rsps.calls[0].request.url == webhook.url
@@ -339,7 +339,7 @@ def test_utils_webhooks_add_user_to_group_authorization():
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
assert rsps.calls[0].request.url == webhook.url
# Check headers
+49
View File
@@ -0,0 +1,49 @@
"""Utility module providing I/O related classes and functions."""
from io import StringIO
class TeeStringIO:
"""String IO implementation that captures output while preserving original logger output."""
def __init__(self, logger_output):
"""Initialize a TeeStringIO instance.
Args:
logger_output: A callable that will receive captured output.
"""
self.logger_output = logger_output
self.buffer = StringIO()
def write(self, value):
"""Write a string to both the logger and internal buffer.
Args:
value: The string to write.
"""
self.logger_output(value.strip("\n"))
self.buffer.write(value)
def read(self):
"""Read the contents of the buffer.
Returns:
The buffer contents as a string.
"""
return self.buffer.read()
def seek(self, *args, **kwargs):
"""Set the buffer's position.
Args:
*args: Positional arguments passed to the underlying buffer.
**kwargs: Keyword arguments passed to the underlying buffer.
Returns:
The new position in the buffer.
"""
return self.buffer.seek(*args, **kwargs)
def flush(self):
"""Flush the internal buffer."""
self.buffer.flush()
+136
View File
@@ -0,0 +1,136 @@
"""Matrix client for interoperability to synchronize with remote service providers."""
import logging
from django.conf import settings
import requests
from rest_framework.status import (
HTTP_200_OK,
)
from urllib3.util import Retry
logger = logging.getLogger(__name__)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=4,
backoff_factor=0.1,
status_forcelist=[500, 502],
allowed_methods=["POST"],
)
)
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
class MatrixAPIClient:
"""A client to interact with Matrix API"""
secret = settings.TCHAP_ACCESS_TOKEN
def get_headers(self, webhook):
"""Build header dict from webhook object."""
headers = {"Content-Type": "application/json"}
if "tchap.gouv.fr" in webhook.url:
token = settings.TCHAP_ACCESS_TOKEN
elif webhook.secret:
token = webhook.secret
else:
raise ValueError("Please configure this webhook's secret access token.")
headers["Authorization"] = f"Bearer {token}"
return headers
def _get_room_url(self, webhook_url):
"""Returns room id from webhook url."""
room_id = webhook_url.split("/room/")[1]
base_url = room_id.split(":")[1]
if "tchap.gouv.fr" in webhook_url:
base_url = f"matrix.{base_url}"
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
def get_user_id(self, user):
"""Returns user id from email."""
if user.email is None:
raise ValueError("You must first set an email for the user.")
return f"@{user.email.replace('@', ':')}"
def join_room(self, webhook):
"""Accept invitation to the room. As of today, it is a mandatory step
to make sure our account will be able to invite/remove users."""
return session.post(
f"{self._get_room_url(webhook.url)}/join",
json={},
headers=self.get_headers(webhook),
verify=False,
timeout=3,
)
def add_user_to_group(self, webhook, user):
"""Send request to invite an user to a room or space upon adding them to group.."""
join_response = self.join_room(webhook)
if join_response.status_code != HTTP_200_OK:
logger.error(
"Synchronization failed (cannot join room) %s",
webhook.url,
)
return join_response, False
user_id = self.get_user_id(user)
response = session.post(
f"{self._get_room_url(webhook.url)}/invite",
json={
"user_id": user_id,
"reason": f"User added to team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=False,
timeout=3,
)
# Checks for false negative
# (i.e. trying to invite user already in room)
webhook_succeeded = False
if (
response.status_code == HTTP_200_OK
or b"is already in the room." in response.content
):
webhook_succeeded = True
return response, webhook_succeeded
def remove_user_from_group(self, webhook, user):
"""Send request to kick an user from a room or space upon removing them from group."""
join_response = self.join_room(webhook)
if join_response.status_code != HTTP_200_OK:
logger.error(
"Synchronization failed (cannot join room) %s",
webhook.url,
)
return join_response, False
user_id = self.get_user_id(user)
response = session.post(
f"{self._get_room_url(webhook.url)}/kick",
json={
"user_id": user_id,
"reason": f"User removed from team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=False,
timeout=3,
)
# Checks for false negative
# (i.e. trying to remove user who already left the room)
webhook_succeeded = False
if (
response.status_code == HTTP_200_OK
or b"The target user is not in the room" in response.content
):
webhook_succeeded = True
return response, webhook_succeeded
+6 -2
View File
@@ -39,7 +39,7 @@ class SCIMClient:
],
}
return session.patch(
response = session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
@@ -47,6 +47,8 @@ class SCIMClient:
timeout=3,
)
return response, response.ok
def remove_user_from_group(self, webhook, user):
"""Remove a user from a group by its ID or email."""
payload = {
@@ -61,10 +63,12 @@ class SCIMClient:
}
],
}
return session.patch(
response = session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
verify=False,
timeout=3,
)
return response, response.ok
+42 -24
View File
@@ -4,14 +4,16 @@ import logging
import requests
from core import enums
from core.enums import WebhookStatusChoices
from .matrix import MatrixAPIClient
from .scim import SCIMClient
logger = logging.getLogger(__name__)
class WebhookSCIMClient:
class WebhookClient:
"""Wraps the SCIM client to record call results on webhooks."""
def __getattr__(self, name):
@@ -26,31 +28,15 @@ class WebhookSCIMClient:
if not webhook.url:
continue
client = SCIMClient()
status = WebhookStatusChoices.FAILURE
try:
response = getattr(client, name)(webhook, user)
response, webhook_succeeded = self._get_response_and_status(
name, webhook, user
)
except requests.exceptions.RetryError as exc:
logger.error(
"%s synchronization failed due to max retries exceeded with url %s",
name,
webhook.url,
exc_info=exc,
)
except requests.exceptions.RequestException as exc:
logger.error(
"%s synchronization failed with %s.",
name,
webhook.url,
exc_info=exc,
)
else:
extra = {
"response": response.content,
}
if response is not None:
extra = {"response": response.content}
# pylint: disable=no-member
if response.status_code == requests.codes.ok:
if webhook_succeeded:
logger.info(
"%s synchronization succeeded with %s",
name,
@@ -71,5 +57,37 @@ class WebhookSCIMClient:
return wrapper
def _get_client(self, webhook):
"""Get client depending on the protocol."""
if webhook.protocol == enums.WebhookProtocolChoices.MATRIX:
return MatrixAPIClient()
scim_synchronizer = WebhookSCIMClient()
return SCIMClient()
def _get_response_and_status(self, name, webhook, user):
"""Get response from webhook outside party."""
client = self._get_client(webhook)
try:
response, webhook_succeeded = getattr(client, name)(webhook, user)
except requests.exceptions.RetryError as exc:
logger.error(
"%s synchronization failed due to max retries exceeded with url %s",
name,
webhook.url,
exc_info=exc,
)
except requests.exceptions.RequestException as exc:
logger.error(
"%s synchronization failed with %s.",
name,
webhook.url,
exc_info=exc,
)
else:
return response, webhook_succeeded
return None, False
webhooks_synchronizer = WebhookClient()
@@ -21,7 +21,11 @@ from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from mailbox_manager.enums import MailboxStatusChoices, MailDomainStatusChoices
from mailbox_manager.enums import (
MailboxStatusChoices,
MailDomainRoleChoices,
MailDomainStatusChoices,
)
fake = Faker()
@@ -201,6 +205,10 @@ def create_demo(stdout): # pylint: disable=too-many-locals
)
# this is a quick fix to fix e2e tests
# tests needs some no random data
organization, _created = models.Organization.objects.get_or_create(
name="13002526500013",
registration_id_list=["13002526500013"],
)
queue.push(
models.User(
sub=uuid4(),
@@ -210,6 +218,7 @@ def create_demo(stdout): # pylint: disable=too-many-locals
is_superuser=False,
is_active=True,
is_staff=False,
organization=organization,
language=random.choice(settings.LANGUAGES)[0],
)
)
@@ -222,6 +231,7 @@ def create_demo(stdout): # pylint: disable=too-many-locals
is_superuser=False,
is_active=True,
is_staff=False,
organization=organization,
language=random.choice(settings.LANGUAGES)[0],
)
)
@@ -261,10 +271,11 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.push(
models.TeamAccess(team_id=team_id, user_id=user_id, role=role[0])
)
queue.flush()
with Timeit(stdout, "Creating domains"):
for i in range(defaults.NB_OBJECTS["domains"]):
name = f"{fake.domain_name()}-i{i:d}"
name = fake.domain_name().replace(".", f"-i{i:d}.")
queue.push(
mailbox_models.MailDomain(
@@ -272,6 +283,7 @@ def create_demo(stdout): # pylint: disable=too-many-locals
# slug should be automatic but bulk_create doesn't use save
slug=slugify(name),
status=random.choice(MailDomainStatusChoices.values),
support_email="support@example.com",
)
)
queue.flush()
@@ -359,6 +371,19 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.flush()
# Enabled domain for 2E2 tests
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="enabled-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@enabled-domain.com",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=enabled_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
# OIDC configuration
if settings.OAUTH2_PROVIDER.get("OIDC_ENABLED", False):
stdout.write("Creating OIDC client for People Identity Provider")
@@ -40,13 +40,16 @@ def test_commands_create_demo(settings):
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1
assert (
mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1 + 1
)
# 3 domain access for each user with domain rights
# 3 x 3 domain access for each user with both rights
# 1 domain for E2E mail owner user
assert (
mailbox_models.MailDomainAccess.objects.count()
== TEST_NB_OBJECTS["domains"] + 3 + 9
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 1
)
Binary file not shown.
+368 -190
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-14 11:28+0000\n"
"PO-Revision-Date: 2025-03-14 11:29\n"
"POT-Creation-Date: 2025-06-10 16:13+0000\n"
"PO-Revision-Date: 2025-06-11 09:29\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,50 +17,55 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: build/lib/core/admin.py:64 core/admin.py:64
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:75 core/admin.py:75
#: build/lib/core/admin.py:73 core/admin.py:73
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:87 core/admin.py:87
#: build/lib/core/admin.py:85 core/admin.py:85
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:126 core/admin.py:126
#: build/lib/core/admin.py:124 core/admin.py:124
msgid "User"
msgstr ""
#: build/lib/core/admin.py:228 core/admin.py:228
#: build/lib/core/admin.py:226 core/admin.py:226
msgid "Run post creation plugins"
msgstr ""
#: build/lib/core/admin.py:236 core/admin.py:236
#: build/lib/core/admin.py:234 core/admin.py:234
msgid "Post creation plugins have been run for the selected organizations."
msgstr ""
#: build/lib/core/authentication/backends.py:94
#: core/authentication/backends.py:94
msgid "User info contained no recognizable user identification"
#: build/lib/core/apps.py:65 core/apps.py:65
msgid "People core application"
msgstr ""
#: build/lib/core/authentication/backends.py:115
#: core/authentication/backends.py:115
msgid "User account is disabled"
msgstr ""
#: build/lib/core/authentication/backends.py:161
#: core/authentication/backends.py:161
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
msgid "Claims contained no recognizable user identification"
msgstr ""
#: build/lib/core/authentication/backends.py:180
#: core/authentication/backends.py:180
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
msgid "Claims contained no recognizable organization identification"
msgstr ""
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
msgid "Invalid authorization header."
msgstr ""
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
msgid "Invalid api key."
msgstr ""
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Failure"
msgstr ""
@@ -75,260 +80,281 @@ msgstr ""
msgid "Success"
msgstr ""
#: build/lib/core/models.py:78 core/models.py:78
#: build/lib/core/models.py:76 core/models.py:76
msgid "Member"
msgstr ""
#: build/lib/core/models.py:79 build/lib/core/models.py:91
#: build/lib/mailbox_manager/enums.py:14 core/models.py:79 core/models.py:91
#: build/lib/core/models.py:77 build/lib/core/models.py:89
#: build/lib/mailbox_manager/enums.py:14 core/models.py:77 core/models.py:89
#: mailbox_manager/enums.py:14
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:80 build/lib/mailbox_manager/enums.py:15
#: core/models.py:80 mailbox_manager/enums.py:15
#: build/lib/core/models.py:78 build/lib/mailbox_manager/enums.py:15
#: core/models.py:78 mailbox_manager/enums.py:15
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:103 core/models.py:103
#: build/lib/core/models.py:101 core/models.py:101
msgid "id"
msgstr ""
#: build/lib/core/models.py:104 core/models.py:104
#: build/lib/core/models.py:102 core/models.py:102
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:110 core/models.py:110
#: build/lib/core/models.py:108 core/models.py:108
msgid "created at"
msgstr ""
#: build/lib/core/models.py:111 core/models.py:111
#: build/lib/core/models.py:109 core/models.py:109
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:116 core/models.py:116
#: build/lib/core/models.py:114 core/models.py:114
msgid "updated at"
msgstr ""
#: build/lib/core/models.py:117 core/models.py:117
#: build/lib/core/models.py:115 core/models.py:115
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:154 core/models.py:154
msgid "full name"
msgstr ""
#: build/lib/core/models.py:157 core/models.py:157
#: build/lib/core/models.py:155 core/models.py:155
msgid "short name"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "notes"
msgstr ""
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:160 core/models.py:160
msgid "contact information"
msgstr ""
#: build/lib/core/models.py:163 core/models.py:163
#: build/lib/core/models.py:161 core/models.py:161
msgid "A JSON object containing the contact information"
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:175 core/models.py:175
msgid "contact"
msgstr ""
#: build/lib/core/models.py:178 core/models.py:178
#: build/lib/core/models.py:176 core/models.py:176
msgid "contacts"
msgstr ""
#: build/lib/core/models.py:252 build/lib/core/models.py:366
#: build/lib/core/models.py:511 build/lib/mailbox_manager/models.py:26
#: core/models.py:252 core/models.py:366 core/models.py:511
#: mailbox_manager/models.py:26
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1106
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1106 mailbox_manager/models.py:53
msgid "name"
msgstr ""
#: build/lib/core/models.py:254 core/models.py:254
#: build/lib/core/models.py:252 core/models.py:252
msgid "audience id"
msgstr ""
#: build/lib/core/models.py:259 core/models.py:259
#: build/lib/core/models.py:257 core/models.py:257
msgid "service provider"
msgstr ""
#: build/lib/core/models.py:260 core/models.py:260
#: build/lib/core/models.py:258 core/models.py:258
msgid "service providers"
msgstr ""
#: build/lib/core/models.py:374 core/models.py:374
#: build/lib/core/models.py:372 core/models.py:372
msgid "registration ID list"
msgstr ""
#: build/lib/core/models.py:381 core/models.py:381
#: build/lib/core/models.py:379 core/models.py:379
msgid "domain list"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
#: build/lib/core/models.py:386 core/models.py:386
msgid "metadata"
msgstr ""
#: build/lib/core/models.py:389 core/models.py:389
#: build/lib/core/models.py:387 core/models.py:387
msgid "A JSON object containing the organization metadata"
msgstr ""
#: build/lib/core/models.py:404 core/models.py:404
msgid "organization"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "organizations"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
msgid "An organization must have at least a registration ID or a domain."
msgstr ""
#: build/lib/core/models.py:496 core/models.py:496
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: build/lib/core/models.py:502 core/models.py:502
msgid "sub"
msgstr ""
#: build/lib/core/models.py:504 core/models.py:504
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: build/lib/core/models.py:510 build/lib/core/models.py:959 core/models.py:510
#: core/models.py:959
msgid "email address"
msgstr ""
#: build/lib/core/models.py:516 core/models.py:516
msgid "language"
msgstr ""
#: build/lib/core/models.py:517 core/models.py:517
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:523 core/models.py:523
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:526 core/models.py:526
msgid "device"
msgstr ""
#: build/lib/core/models.py:528 core/models.py:528
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:531 core/models.py:531
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:533 core/models.py:533
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:536 core/models.py:536
#: build/lib/core/models.py:397 build/lib/core/models.py:535 core/models.py:397
#: core/models.py:535
msgid "active"
msgstr ""
#: build/lib/core/models.py:539 core/models.py:539
#: build/lib/core/models.py:403 core/models.py:403
msgid "organization"
msgstr ""
#: build/lib/core/models.py:404 core/models.py:404
msgid "organizations"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
msgid "An organization must have at least a registration ID or a domain."
msgstr ""
#: build/lib/core/models.py:495 core/models.py:495
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: build/lib/core/models.py:501 core/models.py:501
msgid "sub"
msgstr ""
#: build/lib/core/models.py:503 core/models.py:503
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: build/lib/core/models.py:509 build/lib/core/models.py:958 core/models.py:509
#: core/models.py:958
msgid "email address"
msgstr ""
#: build/lib/core/models.py:515 core/models.py:515
msgid "language"
msgstr ""
#: build/lib/core/models.py:516 core/models.py:516
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:522 core/models.py:522
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:525 core/models.py:525
msgid "device"
msgstr ""
#: build/lib/core/models.py:527 core/models.py:527
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:530 core/models.py:530
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:532 core/models.py:532
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:538 core/models.py:538
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:558 core/models.py:558
#: build/lib/core/models.py:557 core/models.py:557
msgid "user"
msgstr ""
#: build/lib/core/models.py:559 core/models.py:559
#: build/lib/core/models.py:558 core/models.py:558
msgid "users"
msgstr ""
#: build/lib/core/models.py:697 core/models.py:697
#: build/lib/core/models.py:696 core/models.py:696
msgid "Organization/user relation"
msgstr ""
#: build/lib/core/models.py:698 core/models.py:698
#: build/lib/core/models.py:697 core/models.py:697
msgid "Organization/user relations"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
#: build/lib/core/models.py:702 core/models.py:702
msgid "This user is already in this organization."
msgstr ""
#: build/lib/core/models.py:775 core/models.py:775
#: build/lib/core/models.py:774 core/models.py:774
msgid "is visible for all SP"
msgstr ""
#: build/lib/core/models.py:777 core/models.py:777
#: build/lib/core/models.py:776 core/models.py:776
msgid "Whether this team is visible to all service providers."
msgstr ""
#: build/lib/core/models.py:785 core/models.py:785
#: build/lib/core/models.py:784 core/models.py:784
msgid "Team"
msgstr ""
#: build/lib/core/models.py:786 core/models.py:786
#: build/lib/core/models.py:785 core/models.py:785
msgid "Teams"
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:836 core/models.py:836
msgid "Team/user relation"
msgstr ""
#: build/lib/core/models.py:838 core/models.py:838
#: build/lib/core/models.py:837 core/models.py:837
msgid "Team/user relations"
msgstr ""
#: build/lib/core/models.py:843 core/models.py:843
#: build/lib/core/models.py:842 core/models.py:842
msgid "This user is already in this team."
msgstr ""
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:931 core/models.py:931
msgid "url"
msgstr ""
#: build/lib/core/models.py:933 core/models.py:933
#: build/lib/core/models.py:932 core/models.py:932
msgid "secret"
msgstr ""
#: build/lib/core/models.py:942 core/models.py:942
#: build/lib/core/models.py:941 core/models.py:941
msgid "Team webhook"
msgstr ""
#: build/lib/core/models.py:943 core/models.py:943
#: build/lib/core/models.py:942 core/models.py:942
msgid "Team webhooks"
msgstr ""
#: build/lib/core/models.py:987 core/models.py:987
#: build/lib/core/models.py:986 core/models.py:986
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/core/models.py:1001 core/models.py:1001
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Invitation to join La Régie!"
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Team invitation"
msgstr ""
#: build/lib/core/models.py:1048 core/models.py:1048
#: build/lib/core/models.py:1047 core/models.py:1047
msgid "Team invitations"
msgstr ""
#: build/lib/core/models.py:1061 core/models.py:1061
#: build/lib/core/models.py:1060 core/models.py:1060
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr ""
#: build/lib/core/models.py:1108 core/models.py:1108
msgid "api key"
msgstr ""
#: build/lib/core/models.py:1113 core/models.py:1113
msgid "allowed scopes"
msgstr ""
#: build/lib/core/models.py:1114 core/models.py:1114
msgid "Allowed scopes for this service"
msgstr ""
#: build/lib/core/models.py:1119 core/models.py:1119
msgid "Account service"
msgstr ""
#: build/lib/core/models.py:1120 core/models.py:1120
msgid "Account services"
msgstr ""
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
msgid "Synchronise from dimail"
msgid "Import emails from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
@@ -396,6 +422,25 @@ msgstr ""
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:138 mailbox_manager/admin.py:138
msgid "Send pending mailboxes to dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:154 mailbox_manager/admin.py:154
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:160 mailbox_manager/admin.py:160
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:166 mailbox_manager/admin.py:166
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr ""
@@ -423,91 +468,221 @@ msgstr ""
msgid "Action required"
msgstr ""
#: build/lib/mailbox_manager/models.py:41 mailbox_manager/models.py:41
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
msgid "[La Suite] Your domain is ready"
msgstr ""
#: build/lib/mailbox_manager/models.py:37 mailbox_manager/models.py:37
msgid "[La Suite] Your domain requires action"
msgstr ""
#: build/lib/mailbox_manager/models.py:42 mailbox_manager/models.py:42
msgid "[La Suite] Your domain has failed"
msgstr ""
#: build/lib/mailbox_manager/models.py:68 mailbox_manager/models.py:68
msgid "support email"
msgstr ""
#: build/lib/mailbox_manager/models.py:45 mailbox_manager/models.py:45
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
msgid "last check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:46 mailbox_manager/models.py:46
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
msgid "A JSON object containing the last health check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:51 mailbox_manager/models.py:51
#: build/lib/mailbox_manager/models.py:78 mailbox_manager/models.py:78
msgid "expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:52 mailbox_manager/models.py:52
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
msgid "A JSON object containing the expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:57 mailbox_manager/models.py:57
#: build/lib/mailbox_manager/models.py:84 mailbox_manager/models.py:84
msgid "Mail domain"
msgstr ""
#: build/lib/mailbox_manager/models.py:58 mailbox_manager/models.py:58
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
msgid "Mail domains"
msgstr ""
#: build/lib/mailbox_manager/models.py:133 mailbox_manager/models.py:133
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
msgid "User/mail domain relation"
msgstr ""
#: build/lib/mailbox_manager/models.py:134 mailbox_manager/models.py:134
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
msgid "User/mail domain relations"
msgstr ""
#: build/lib/mailbox_manager/models.py:207 mailbox_manager/models.py:207
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
msgid "local_part"
msgstr ""
#: build/lib/mailbox_manager/models.py:221 mailbox_manager/models.py:221
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
msgid "secondary email address"
msgstr ""
#: build/lib/mailbox_manager/models.py:232 mailbox_manager/models.py:232
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
msgid "email"
msgstr ""
#: build/lib/mailbox_manager/models.py:238 mailbox_manager/models.py:238
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
msgid "Mailbox"
msgstr ""
#: build/lib/mailbox_manager/models.py:239 mailbox_manager/models.py:239
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
msgid "Mailboxes"
msgstr ""
#: build/lib/mailbox_manager/models.py:265 mailbox_manager/models.py:265
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: build/lib/mailbox_manager/models.py:303 mailbox_manager/models.py:303
#: build/lib/mailbox_manager/models.py:369 mailbox_manager/models.py:369
msgid "Mail domain invitation"
msgstr ""
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
msgid "Mail domain invitations"
msgstr ""
#: build/lib/mailbox_manager/models.py:316 mailbox_manager/models.py:316
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:270
#: mailbox_manager/utils/dimail.py:270
#: build/lib/mailbox_manager/utils/dimail.py:268
#: mailbox_manager/utils/dimail.py:268
msgid "Your new mailbox information"
msgstr ""
#: build/lib/people/settings.py:155 people/settings.py:155
#: build/lib/mailbox_manager/utils/dimail.py:279
#: mailbox_manager/utils/dimail.py:279
msgid "Your password has been updated"
msgstr ""
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr ""
#: build/lib/people/settings.py:156 people/settings.py:156
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr ""
#: core/templates/mail/html/maildomain_action_required.html:195
#: core/templates/mail/text/maildomain_action_required.txt:5
msgid "Some actions are required on your domain"
msgstr ""
#: core/templates/mail/html/maildomain_action_required.html:201
#: core/templates/mail/html/maildomain_failed.html:201
#: core/templates/mail/html/maildomain_invitation.html:201
#: core/templates/mail/html/team_invitation.html:201
#: core/templates/mail/text/maildomain_action_required.txt:6
#: core/templates/mail/text/maildomain_failed.txt:6
#: core/templates/mail/text/maildomain_invitation.txt:6
#: core/templates/mail/text/team_invitation.txt:6
msgid "Hello,"
msgstr ""
#: core/templates/mail/html/maildomain_action_required.html:206
#, python-format
msgid "Your domain <b>%(name)s</b> cannot be used until the required actions have been completed."
msgstr ""
#: core/templates/mail/html/maildomain_action_required.html:211
#: core/templates/mail/text/maildomain_action_required.txt:8
msgid "To solve this problem, please log in La Régie via ProConnect and follow instructions, by following this link:"
msgstr ""
#: core/templates/mail/html/maildomain_action_required.html:221
#: core/templates/mail/html/maildomain_enabled.html:221
#: core/templates/mail/html/maildomain_failed.html:221
#: core/templates/mail/html/maildomain_invitation.html:221
#: core/templates/mail/html/team_invitation.html:221
#: core/templates/mail/text/maildomain_action_required.txt:10
#: core/templates/mail/text/maildomain_enabled.txt:10
#: core/templates/mail/text/maildomain_failed.txt:11
#: core/templates/mail/text/maildomain_invitation.txt:10
#: core/templates/mail/text/team_invitation.txt:10
msgid "Go to La Régie"
msgstr ""
#: core/templates/mail/html/maildomain_action_required.html:232
#: core/templates/mail/html/maildomain_enabled.html:232
#: core/templates/mail/html/maildomain_failed.html:232
#: core/templates/mail/html/maildomain_invitation.html:255
#: core/templates/mail/html/team_invitation.html:268
#: core/templates/mail/text/maildomain_action_required.txt:12
#: core/templates/mail/text/maildomain_enabled.txt:12
#: core/templates/mail/text/maildomain_failed.txt:13
#: core/templates/mail/text/maildomain_invitation.txt:22
#: core/templates/mail/text/team_invitation.txt:20
msgid "Regards,"
msgstr ""
#: core/templates/mail/html/maildomain_action_required.html:233
#: core/templates/mail/html/maildomain_enabled.html:233
#: core/templates/mail/html/maildomain_failed.html:233
#: core/templates/mail/html/maildomain_invitation.html:256
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/html/reset_password.html:268
#: core/templates/mail/html/team_invitation.html:269
#: core/templates/mail/text/maildomain_action_required.txt:14
#: core/templates/mail/text/maildomain_enabled.txt:14
#: core/templates/mail/text/maildomain_failed.txt:15
#: core/templates/mail/text/maildomain_invitation.txt:24
#: core/templates/mail/text/new_mailbox.txt:17
#: core/templates/mail/text/reset_password.txt:16
#: core/templates/mail/text/team_invitation.txt:22
msgid "La Suite Team"
msgstr ""
#: core/templates/mail/html/maildomain_enabled.html:195
#: core/templates/mail/text/maildomain_enabled.txt:5
msgid "Your domain is ready"
msgstr ""
#: core/templates/mail/html/maildomain_enabled.html:201
#: core/templates/mail/text/maildomain_enabled.txt:6
msgid "Hurray!"
msgstr ""
#: core/templates/mail/html/maildomain_enabled.html:206
#, python-format
msgid "Your domain <b>%(name)s</b> can be used now."
msgstr ""
#: core/templates/mail/html/maildomain_enabled.html:211
#: core/templates/mail/html/maildomain_invitation.html:211
#: core/templates/mail/html/team_invitation.html:211
#: core/templates/mail/text/maildomain_enabled.txt:8
#: core/templates/mail/text/maildomain_invitation.txt:8
#: core/templates/mail/text/team_invitation.txt:8
msgid "To do so, please log in La Régie via ProConnect, by following this link:"
msgstr ""
#: core/templates/mail/html/maildomain_failed.html:195
#: core/templates/mail/text/maildomain_failed.txt:5
msgid "Your domain has failed"
msgstr ""
#: core/templates/mail/html/maildomain_failed.html:206
#, python-format
msgid "The domain <b>%(name)s</b> has encountered an error. As long as this error persists, all related mailboxes will remain disabled. Technical support is currently working on the issue."
msgstr ""
#: core/templates/mail/html/maildomain_failed.html:211
#: core/templates/mail/text/maildomain_failed.txt:8
msgid "You can track the status of your domain on the management interface."
msgstr ""
#: core/templates/mail/html/maildomain_failed.html:211
#: core/templates/mail/text/maildomain_failed.txt:9
msgid "To do this, please log in to La Régie via ProConnect, by following this link:"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:195
#: core/templates/mail/html/team_invitation.html:195
#: core/templates/mail/text/maildomain_invitation.txt:5
@@ -515,33 +690,12 @@ msgstr ""
msgid "Welcome to La Régie!"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:201
#: core/templates/mail/html/team_invitation.html:201
#: core/templates/mail/text/maildomain_invitation.txt:6
#: core/templates/mail/text/team_invitation.txt:6
msgid "Hello,"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:206
#: core/templates/mail/text/maildomain_invitation.txt:7
#, python-format
msgid "You have been invited to join La Régie to be %(role)s of the domain %(domain)s."
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:211
#: core/templates/mail/html/team_invitation.html:211
#: core/templates/mail/text/maildomain_invitation.txt:8
#: core/templates/mail/text/team_invitation.txt:8
msgid "To do so, please log in La Régie via ProConnect, by following this link:"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:221
#: core/templates/mail/html/team_invitation.html:221
#: core/templates/mail/text/maildomain_invitation.txt:10
#: core/templates/mail/text/team_invitation.txt:10
msgid "Go to La Régie"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:231
#: core/templates/mail/html/team_invitation.html:231
#: core/templates/mail/text/maildomain_invitation.txt:12
@@ -586,24 +740,10 @@ msgstr ""
msgid "Welcome aboard!"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:255
#: core/templates/mail/html/team_invitation.html:268
#: core/templates/mail/text/maildomain_invitation.txt:22
#: core/templates/mail/text/team_invitation.txt:20
msgid "Regards,"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:256
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/html/team_invitation.html:269
#: core/templates/mail/text/maildomain_invitation.txt:24
#: core/templates/mail/text/new_mailbox.txt:17
#: core/templates/mail/text/team_invitation.txt:22
msgid "La Suite Team"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/html/reset_password.html:159
#: core/templates/mail/text/new_mailbox.txt:3
#: core/templates/mail/text/reset_password.txt:3
msgid "La Messagerie"
msgstr ""
@@ -628,25 +768,48 @@ msgid "Please find below your login info: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/html/reset_password.html:223
#: core/templates/mail/text/new_mailbox.txt:10
#: core/templates/mail/text/reset_password.txt:9
msgid "Email address: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:233
#: core/templates/mail/html/reset_password.html:228
#: core/templates/mail/text/new_mailbox.txt:11
msgid "Temporary password (to be modify on first login): "
#: core/templates/mail/text/reset_password.txt:10
msgid "Temporary password (to be modified on first login): "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/html/reset_password.html:256
#: core/templates/mail/text/new_mailbox.txt:13
#: core/templates/mail/text/reset_password.txt:12
msgid "Go to La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:272
#: core/templates/mail/html/reset_password.html:267
#: core/templates/mail/text/new_mailbox.txt:15
#: core/templates/mail/text/reset_password.txt:14
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/reset_password.html:188
#: core/templates/mail/text/reset_password.txt:5
msgid "Your password has been reset"
msgstr ""
#: core/templates/mail/html/reset_password.html:194
#: core/templates/mail/text/reset_password.txt:6
msgid "Your password has been reset."
msgstr ""
#: core/templates/mail/html/reset_password.html:199
#: core/templates/mail/text/reset_password.txt:7
msgid "Please find below your new login information: "
msgstr ""
#: core/templates/mail/html/team_invitation.html:206
#: core/templates/mail/text/team_invitation.txt:7
#, python-format
@@ -678,6 +841,21 @@ msgstr ""
msgid "For more information: <a href=\"%(link)s\">Visit the website for La Suite numérique</a> to discover what tools we offer."
msgstr ""
#: core/templates/mail/text/maildomain_action_required.txt:7
#, python-format
msgid "Your domain %(name)s cannot be used until the required actions have been completed."
msgstr ""
#: core/templates/mail/text/maildomain_enabled.txt:7
#, python-format
msgid "Your domain %(name)s can be used now."
msgstr ""
#: core/templates/mail/text/maildomain_failed.txt:7
#, python-format
msgid "The domain %(name)s has encountered an error. As long as this error persists, all related mailboxes will remain disabled. Technical support is currently working on the issue."
msgstr ""
#: core/templates/mail/text/team_invitation.txt:17
#, python-format
msgid "For more information: Visit the website for La Suite numérique [%(link)s] to discover what tools we offer."
Binary file not shown.
+372 -194
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-14 11:28+0000\n"
"PO-Revision-Date: 2025-03-17 09:23\n"
"POT-Creation-Date: 2025-06-10 16:13+0000\n"
"PO-Revision-Date: 2025-06-11 09:29\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,50 +17,55 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: build/lib/core/admin.py:64 core/admin.py:64
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Personal info"
msgstr "Infos Personnelles"
msgstr "Informations personnelles"
#: build/lib/core/admin.py:75 core/admin.py:75
#: build/lib/core/admin.py:73 core/admin.py:73
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:87 core/admin.py:87
#: build/lib/core/admin.py:85 core/admin.py:85
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:126 core/admin.py:126
#: build/lib/core/admin.py:124 core/admin.py:124
msgid "User"
msgstr "Utilisateur"
#: build/lib/core/admin.py:228 core/admin.py:228
#: build/lib/core/admin.py:226 core/admin.py:226
msgid "Run post creation plugins"
msgstr "Exécuter les plugins de post-création"
#: build/lib/core/admin.py:236 core/admin.py:236
#: build/lib/core/admin.py:234 core/admin.py:234
msgid "Post creation plugins have been run for the selected organizations."
msgstr "Les plugins de post-création ont été exécutés pour les organisations sélectionnées."
#: build/lib/core/authentication/backends.py:94
#: core/authentication/backends.py:94
msgid "User info contained no recognizable user identification"
msgstr "Les informations de l'utilisateur ne contiennent aucune identification reconnaissable"
#: build/lib/core/apps.py:65 core/apps.py:65
msgid "People core application"
msgstr "Application cœur de People"
#: build/lib/core/authentication/backends.py:115
#: core/authentication/backends.py:115
msgid "User account is disabled"
msgstr "Le compte de l'utilisateur est désactivé"
#: build/lib/core/authentication/backends.py:161
#: core/authentication/backends.py:161
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
msgid "Claims contained no recognizable user identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: build/lib/core/authentication/backends.py:180
#: core/authentication/backends.py:180
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
msgid "Claims contained no recognizable organization identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
msgid "Invalid authorization header."
msgstr "En-tête d'autorisation invalide."
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
msgid "Invalid api key."
msgstr "Clé API invalide."
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Failure"
msgstr "En échec"
@@ -75,261 +80,282 @@ msgstr "En attente"
msgid "Success"
msgstr "Réussi"
#: build/lib/core/models.py:78 core/models.py:78
#: build/lib/core/models.py:76 core/models.py:76
msgid "Member"
msgstr "Membre"
#: build/lib/core/models.py:79 build/lib/core/models.py:91
#: build/lib/mailbox_manager/enums.py:14 core/models.py:79 core/models.py:91
#: build/lib/core/models.py:77 build/lib/core/models.py:89
#: build/lib/mailbox_manager/enums.py:14 core/models.py:77 core/models.py:89
#: mailbox_manager/enums.py:14
msgid "Administrator"
msgstr "Administrateur"
#: build/lib/core/models.py:80 build/lib/mailbox_manager/enums.py:15
#: core/models.py:80 mailbox_manager/enums.py:15
#: build/lib/core/models.py:78 build/lib/mailbox_manager/enums.py:15
#: core/models.py:78 mailbox_manager/enums.py:15
msgid "Owner"
msgstr "Propriétaire"
#: build/lib/core/models.py:103 core/models.py:103
#: build/lib/core/models.py:101 core/models.py:101
msgid "id"
msgstr "id"
#: build/lib/core/models.py:104 core/models.py:104
#: build/lib/core/models.py:102 core/models.py:102
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement en tant que UUID"
#: build/lib/core/models.py:110 core/models.py:110
#: build/lib/core/models.py:108 core/models.py:108
msgid "created at"
msgstr "créé le"
#: build/lib/core/models.py:111 core/models.py:111
#: build/lib/core/models.py:109 core/models.py:109
msgid "date and time at which a record was created"
msgstr "date et heure de création de l'enregistrement"
#: build/lib/core/models.py:116 core/models.py:116
#: build/lib/core/models.py:114 core/models.py:114
msgid "updated at"
msgstr "mis à jour le"
#: build/lib/core/models.py:117 core/models.py:117
#: build/lib/core/models.py:115 core/models.py:115
msgid "date and time at which a record was last updated"
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:154 core/models.py:154
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:157 core/models.py:157
#: build/lib/core/models.py:155 core/models.py:155
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "notes"
msgstr "notes"
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:160 core/models.py:160
msgid "contact information"
msgstr "informations de contact"
#: build/lib/core/models.py:163 core/models.py:163
#: build/lib/core/models.py:161 core/models.py:161
msgid "A JSON object containing the contact information"
msgstr "Un objet JSON contenant les informations de contact"
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:175 core/models.py:175
msgid "contact"
msgstr "contact"
#: build/lib/core/models.py:178 core/models.py:178
#: build/lib/core/models.py:176 core/models.py:176
msgid "contacts"
msgstr "contacts"
#: build/lib/core/models.py:252 build/lib/core/models.py:366
#: build/lib/core/models.py:511 build/lib/mailbox_manager/models.py:26
#: core/models.py:252 core/models.py:366 core/models.py:511
#: mailbox_manager/models.py:26
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1106
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1106 mailbox_manager/models.py:53
msgid "name"
msgstr "nom"
#: build/lib/core/models.py:254 core/models.py:254
#: build/lib/core/models.py:252 core/models.py:252
msgid "audience id"
msgstr "ID d'audience"
#: build/lib/core/models.py:259 core/models.py:259
#: build/lib/core/models.py:257 core/models.py:257
msgid "service provider"
msgstr "fournisseur de services"
#: build/lib/core/models.py:260 core/models.py:260
#: build/lib/core/models.py:258 core/models.py:258
msgid "service providers"
msgstr "fournisseurs de services"
#: build/lib/core/models.py:374 core/models.py:374
#: build/lib/core/models.py:372 core/models.py:372
msgid "registration ID list"
msgstr "liste d'identifiants d'inscription"
#: build/lib/core/models.py:381 core/models.py:381
#: build/lib/core/models.py:379 core/models.py:379
msgid "domain list"
msgstr "liste des domaines"
#: build/lib/core/models.py:388 core/models.py:388
#: build/lib/core/models.py:386 core/models.py:386
msgid "metadata"
msgstr "métadonnées"
#: build/lib/core/models.py:389 core/models.py:389
#: build/lib/core/models.py:387 core/models.py:387
msgid "A JSON object containing the organization metadata"
msgstr "Un objet JSON contenant les métadonnées de l'organisation"
#: build/lib/core/models.py:404 core/models.py:404
msgid "organization"
msgstr "organisation"
#: build/lib/core/models.py:405 core/models.py:405
msgid "organizations"
msgstr "organisations"
#: build/lib/core/models.py:412 core/models.py:412
msgid "An organization must have at least a registration ID or a domain."
msgstr "Une organisation doit avoir au moins un ID d'enregistrement ou un domaine."
#: build/lib/core/models.py:496 core/models.py:496
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr "Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des chiffres et des caractères @/./+/-/_."
#: build/lib/core/models.py:502 core/models.py:502
msgid "sub"
msgstr "sub"
#: build/lib/core/models.py:504 core/models.py:504
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ seulement."
#: build/lib/core/models.py:510 build/lib/core/models.py:959 core/models.py:510
#: core/models.py:959
msgid "email address"
msgstr "adresse email"
#: build/lib/core/models.py:516 core/models.py:516
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:517 core/models.py:517
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: build/lib/core/models.py:523 core/models.py:523
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: build/lib/core/models.py:526 core/models.py:526
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:528 core/models.py:528
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: build/lib/core/models.py:531 core/models.py:531
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:533 core/models.py:533
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: build/lib/core/models.py:536 core/models.py:536
#: build/lib/core/models.py:397 build/lib/core/models.py:535 core/models.py:397
#: core/models.py:535
msgid "active"
msgstr "actif"
#: build/lib/core/models.py:539 core/models.py:539
#: build/lib/core/models.py:403 core/models.py:403
msgid "organization"
msgstr "organisation"
#: build/lib/core/models.py:404 core/models.py:404
msgid "organizations"
msgstr "organisations"
#: build/lib/core/models.py:411 core/models.py:411
msgid "An organization must have at least a registration ID or a domain."
msgstr "Une organisation doit avoir au moins un ID d'enregistrement ou un domaine."
#: build/lib/core/models.py:495 core/models.py:495
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr "Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des chiffres et des caractères @/./+/-/_."
#: build/lib/core/models.py:501 core/models.py:501
msgid "sub"
msgstr "sub"
#: build/lib/core/models.py:503 core/models.py:503
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ seulement."
#: build/lib/core/models.py:509 build/lib/core/models.py:958 core/models.py:509
#: core/models.py:958
msgid "email address"
msgstr "adresse email"
#: build/lib/core/models.py:515 core/models.py:515
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:516 core/models.py:516
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: build/lib/core/models.py:522 core/models.py:522
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: build/lib/core/models.py:525 core/models.py:525
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:527 core/models.py:527
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: build/lib/core/models.py:530 core/models.py:530
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:532 core/models.py:532
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: build/lib/core/models.py:538 core/models.py:538
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: build/lib/core/models.py:558 core/models.py:558
#: build/lib/core/models.py:557 core/models.py:557
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:559 core/models.py:559
#: build/lib/core/models.py:558 core/models.py:558
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:697 core/models.py:697
#: build/lib/core/models.py:696 core/models.py:696
msgid "Organization/user relation"
msgstr "Relation organisation/utilisateur"
#: build/lib/core/models.py:698 core/models.py:698
#: build/lib/core/models.py:697 core/models.py:697
msgid "Organization/user relations"
msgstr "Relations organisation/utilisateur"
#: build/lib/core/models.py:703 core/models.py:703
#: build/lib/core/models.py:702 core/models.py:702
msgid "This user is already in this organization."
msgstr "Cet utilisateur est déjà dans cette organisation."
#: build/lib/core/models.py:775 core/models.py:775
#: build/lib/core/models.py:774 core/models.py:774
msgid "is visible for all SP"
msgstr "est visible pour tous les SP"
msgstr "est visible pour tous les fournisseurs de service"
#: build/lib/core/models.py:777 core/models.py:777
#: build/lib/core/models.py:776 core/models.py:776
msgid "Whether this team is visible to all service providers."
msgstr "Si cette équipe est visible pour tous les fournisseurs de services."
#: build/lib/core/models.py:785 core/models.py:785
#: build/lib/core/models.py:784 core/models.py:784
msgid "Team"
msgstr "Équipe"
#: build/lib/core/models.py:786 core/models.py:786
#: build/lib/core/models.py:785 core/models.py:785
msgid "Teams"
msgstr "Équipes"
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:836 core/models.py:836
msgid "Team/user relation"
msgstr "Relation équipe/utilisateur"
#: build/lib/core/models.py:838 core/models.py:838
#: build/lib/core/models.py:837 core/models.py:837
msgid "Team/user relations"
msgstr "Relations équipe/utilisateur"
#: build/lib/core/models.py:843 core/models.py:843
#: build/lib/core/models.py:842 core/models.py:842
msgid "This user is already in this team."
msgstr "Cet utilisateur est déjà dans cette équipe."
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:931 core/models.py:931
msgid "url"
msgstr "url"
#: build/lib/core/models.py:933 core/models.py:933
#: build/lib/core/models.py:932 core/models.py:932
msgid "secret"
msgstr "secret"
#: build/lib/core/models.py:942 core/models.py:942
#: build/lib/core/models.py:941 core/models.py:941
msgid "Team webhook"
msgstr "Webhook d'équipe"
#: build/lib/core/models.py:943 core/models.py:943
#: build/lib/core/models.py:942 core/models.py:942
msgid "Team webhooks"
msgstr "Webhooks d'équipe"
#: build/lib/core/models.py:987 core/models.py:987
#: build/lib/core/models.py:986 core/models.py:986
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
#: build/lib/core/models.py:1001 core/models.py:1001
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Invitation to join La Régie!"
msgstr "Invitation à rejoindre La Régie !"
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Team invitation"
msgstr "Invitation d'équipe"
#: build/lib/core/models.py:1048 core/models.py:1048
#: build/lib/core/models.py:1047 core/models.py:1047
msgid "Team invitations"
msgstr "Invitations d'équipe"
#: build/lib/core/models.py:1061 core/models.py:1061
#: build/lib/core/models.py:1060 core/models.py:1060
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr "[La Suite] Vous avez été invité(e) à être %(role)s d'un groupe"
#: build/lib/core/models.py:1108 core/models.py:1108
msgid "api key"
msgstr "Clé d'API"
#: build/lib/core/models.py:1113 core/models.py:1113
msgid "allowed scopes"
msgstr "périmètres autorisés"
#: build/lib/core/models.py:1114 core/models.py:1114
msgid "Allowed scopes for this service"
msgstr "Périmètres d'application autorisés pour ce service"
#: build/lib/core/models.py:1119 core/models.py:1119
msgid "Account service"
msgstr "Compte de service"
#: build/lib/core/models.py:1120 core/models.py:1120
msgid "Account services"
msgstr "Comptes de service"
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
msgid "Synchronise from dimail"
msgstr "Synchroniser à partir de dimail"
msgid "Import emails from dimail"
msgstr "Importer les emails depuis dimail"
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
#, python-format
@@ -396,6 +422,25 @@ msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
#: build/lib/mailbox_manager/admin.py:138 mailbox_manager/admin.py:138
msgid "Send pending mailboxes to dimail"
msgstr "Envoyer les adresses mail en attente à dimail"
#: build/lib/mailbox_manager/admin.py:154 mailbox_manager/admin.py:154
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
#: build/lib/mailbox_manager/admin.py:160 mailbox_manager/admin.py:160
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
#: build/lib/mailbox_manager/admin.py:166 mailbox_manager/admin.py:166
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr "Gestion des boîtes mails"
@@ -423,91 +468,221 @@ msgstr "Désactivé"
msgid "Action required"
msgstr "Action requise"
#: build/lib/mailbox_manager/models.py:41 mailbox_manager/models.py:41
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
msgid "[La Suite] Your domain is ready"
msgstr "[La Suite] Votre domaine est prêt"
#: build/lib/mailbox_manager/models.py:37 mailbox_manager/models.py:37
msgid "[La Suite] Your domain requires action"
msgstr "[La Suite] Des actions sont requises sur votre domaine"
#: build/lib/mailbox_manager/models.py:42 mailbox_manager/models.py:42
msgid "[La Suite] Your domain has failed"
msgstr "[La Suite] Votre domaine est en erreur"
#: build/lib/mailbox_manager/models.py:68 mailbox_manager/models.py:68
msgid "support email"
msgstr "adresse email du support"
#: build/lib/mailbox_manager/models.py:45 mailbox_manager/models.py:45
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
msgid "last check details"
msgstr "détails de la dernière vérification"
#: build/lib/mailbox_manager/models.py:46 mailbox_manager/models.py:46
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
#: build/lib/mailbox_manager/models.py:51 mailbox_manager/models.py:51
#: build/lib/mailbox_manager/models.py:78 mailbox_manager/models.py:78
msgid "expected config"
msgstr "configuration attendue"
#: build/lib/mailbox_manager/models.py:52 mailbox_manager/models.py:52
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
msgid "A JSON object containing the expected config"
msgstr "Un objet JSON contenant la configuration attendue"
#: build/lib/mailbox_manager/models.py:57 mailbox_manager/models.py:57
#: build/lib/mailbox_manager/models.py:84 mailbox_manager/models.py:84
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: build/lib/mailbox_manager/models.py:58 mailbox_manager/models.py:58
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: build/lib/mailbox_manager/models.py:133 mailbox_manager/models.py:133
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
msgid "User/mail domain relation"
msgstr "Relation utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:134 mailbox_manager/models.py:134
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
msgid "User/mail domain relations"
msgstr "Relations utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:207 mailbox_manager/models.py:207
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
msgid "local_part"
msgstr "local_part"
#: build/lib/mailbox_manager/models.py:221 mailbox_manager/models.py:221
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
msgid "secondary email address"
msgstr "adresse email secondaire"
#: build/lib/mailbox_manager/models.py:232 mailbox_manager/models.py:232
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
msgid "email"
msgstr "email"
#: build/lib/mailbox_manager/models.py:238 mailbox_manager/models.py:238
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
msgid "Mailbox"
msgstr "Boîte mail"
#: build/lib/mailbox_manager/models.py:239 mailbox_manager/models.py:239
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
msgid "Mailboxes"
msgstr "Boîtes mail"
#: build/lib/mailbox_manager/models.py:265 mailbox_manager/models.py:265
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
#: build/lib/mailbox_manager/models.py:303 mailbox_manager/models.py:303
#: build/lib/mailbox_manager/models.py:369 mailbox_manager/models.py:369
msgid "Mail domain invitation"
msgstr "Invitation au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
msgid "Mail domain invitations"
msgstr "Invitations au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:316 mailbox_manager/models.py:316
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
msgid "[La Suite] You have been invited to join La Régie"
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
#: build/lib/mailbox_manager/utils/dimail.py:270
#: mailbox_manager/utils/dimail.py:270
#: build/lib/mailbox_manager/utils/dimail.py:268
#: mailbox_manager/utils/dimail.py:268
msgid "Your new mailbox information"
msgstr "Informations sur votre nouvelle boîte mail"
#: build/lib/people/settings.py:155 people/settings.py:155
#: build/lib/mailbox_manager/utils/dimail.py:279
#: mailbox_manager/utils/dimail.py:279
msgid "Your password has been updated"
msgstr "Votre mot de passe a été mis à jour"
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr "Anglais"
#: build/lib/people/settings.py:156 people/settings.py:156
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr "Français"
#: core/templates/mail/html/maildomain_action_required.html:195
#: core/templates/mail/text/maildomain_action_required.txt:5
msgid "Some actions are required on your domain"
msgstr "Des actions sont requises sur votre domaine"
#: core/templates/mail/html/maildomain_action_required.html:201
#: core/templates/mail/html/maildomain_failed.html:201
#: core/templates/mail/html/maildomain_invitation.html:201
#: core/templates/mail/html/team_invitation.html:201
#: core/templates/mail/text/maildomain_action_required.txt:6
#: core/templates/mail/text/maildomain_failed.txt:6
#: core/templates/mail/text/maildomain_invitation.txt:6
#: core/templates/mail/text/team_invitation.txt:6
msgid "Hello,"
msgstr "Bonjour,"
#: core/templates/mail/html/maildomain_action_required.html:206
#, python-format
msgid "Your domain <b>%(name)s</b> cannot be used until the required actions have been completed."
msgstr "Votre domaine <b>%(name)s</b> ne peut pas être utilisé tant que certaines actions nont pas été menées."
#: core/templates/mail/html/maildomain_action_required.html:211
#: core/templates/mail/text/maildomain_action_required.txt:8
msgid "To solve this problem, please log in La Régie via ProConnect and follow instructions, by following this link:"
msgstr "Pour résoudre ce problème, merci de vous connecter à la Régie grâce à ProConnect et de suivre les instructions, en suivant le lien ci-dessous :"
#: core/templates/mail/html/maildomain_action_required.html:221
#: core/templates/mail/html/maildomain_enabled.html:221
#: core/templates/mail/html/maildomain_failed.html:221
#: core/templates/mail/html/maildomain_invitation.html:221
#: core/templates/mail/html/team_invitation.html:221
#: core/templates/mail/text/maildomain_action_required.txt:10
#: core/templates/mail/text/maildomain_enabled.txt:10
#: core/templates/mail/text/maildomain_failed.txt:11
#: core/templates/mail/text/maildomain_invitation.txt:10
#: core/templates/mail/text/team_invitation.txt:10
msgid "Go to La Régie"
msgstr "Accéder à la Régie"
#: core/templates/mail/html/maildomain_action_required.html:232
#: core/templates/mail/html/maildomain_enabled.html:232
#: core/templates/mail/html/maildomain_failed.html:232
#: core/templates/mail/html/maildomain_invitation.html:255
#: core/templates/mail/html/team_invitation.html:268
#: core/templates/mail/text/maildomain_action_required.txt:12
#: core/templates/mail/text/maildomain_enabled.txt:12
#: core/templates/mail/text/maildomain_failed.txt:13
#: core/templates/mail/text/maildomain_invitation.txt:22
#: core/templates/mail/text/team_invitation.txt:20
msgid "Regards,"
msgstr "Cordialement,"
#: core/templates/mail/html/maildomain_action_required.html:233
#: core/templates/mail/html/maildomain_enabled.html:233
#: core/templates/mail/html/maildomain_failed.html:233
#: core/templates/mail/html/maildomain_invitation.html:256
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/html/reset_password.html:268
#: core/templates/mail/html/team_invitation.html:269
#: core/templates/mail/text/maildomain_action_required.txt:14
#: core/templates/mail/text/maildomain_enabled.txt:14
#: core/templates/mail/text/maildomain_failed.txt:15
#: core/templates/mail/text/maildomain_invitation.txt:24
#: core/templates/mail/text/new_mailbox.txt:17
#: core/templates/mail/text/reset_password.txt:16
#: core/templates/mail/text/team_invitation.txt:22
msgid "La Suite Team"
msgstr "L'équipe de La Suite"
#: core/templates/mail/html/maildomain_enabled.html:195
#: core/templates/mail/text/maildomain_enabled.txt:5
msgid "Your domain is ready"
msgstr "Votre domaine est prêt"
#: core/templates/mail/html/maildomain_enabled.html:201
#: core/templates/mail/text/maildomain_enabled.txt:6
msgid "Hurray!"
msgstr "Hourra !"
#: core/templates/mail/html/maildomain_enabled.html:206
#, python-format
msgid "Your domain <b>%(name)s</b> can be used now."
msgstr "Votre domaine <b>%(name)s</b> est prêt à être exploité."
#: core/templates/mail/html/maildomain_enabled.html:211
#: core/templates/mail/html/maildomain_invitation.html:211
#: core/templates/mail/html/team_invitation.html:211
#: core/templates/mail/text/maildomain_enabled.txt:8
#: core/templates/mail/text/maildomain_invitation.txt:8
#: core/templates/mail/text/team_invitation.txt:8
msgid "To do so, please log in La Régie via ProConnect, by following this link:"
msgstr "Pour cela, merci de vous connecter une première fois à la Régie grâce à ProConnect, en suivant le lien ci-dessous :"
#: core/templates/mail/html/maildomain_failed.html:195
#: core/templates/mail/text/maildomain_failed.txt:5
msgid "Your domain has failed"
msgstr "Votre domaine est en erreur"
#: core/templates/mail/html/maildomain_failed.html:206
#, python-format
msgid "The domain <b>%(name)s</b> has encountered an error. As long as this error persists, all related mailboxes will remain disabled. Technical support is currently working on the issue."
msgstr "Le domaine <b>%(name)s</b> a rencontré une erreur. Tant que cette erreur persiste, toutes les boîtes mail demeureront désactivées. Le support technique est en train de travailler à résoudre cette erreur."
#: core/templates/mail/html/maildomain_failed.html:211
#: core/templates/mail/text/maildomain_failed.txt:8
msgid "You can track the status of your domain on the management interface."
msgstr "Vous pouvez suivre l’évolution du statut de votre domaine sur linterface de gestion dédiée."
#: core/templates/mail/html/maildomain_failed.html:211
#: core/templates/mail/text/maildomain_failed.txt:9
msgid "To do this, please log in to La Régie via ProConnect, by following this link:"
msgstr "Pour cela, merci de vous connecter à la Régie grâce à ProConnect, en suivant le lien ci-dessous :"
#: core/templates/mail/html/maildomain_invitation.html:195
#: core/templates/mail/html/team_invitation.html:195
#: core/templates/mail/text/maildomain_invitation.txt:5
@@ -515,33 +690,12 @@ msgstr "Français"
msgid "Welcome to La Régie!"
msgstr "Bienvenue sur La Régie !"
#: core/templates/mail/html/maildomain_invitation.html:201
#: core/templates/mail/html/team_invitation.html:201
#: core/templates/mail/text/maildomain_invitation.txt:6
#: core/templates/mail/text/team_invitation.txt:6
msgid "Hello,"
msgstr "Bonjour,"
#: core/templates/mail/html/maildomain_invitation.html:206
#: core/templates/mail/text/maildomain_invitation.txt:7
#, python-format
msgid "You have been invited to join La Régie to be %(role)s of the domain %(domain)s."
msgstr "Vous avez été invité(e) à rejoindre la Régie pour devenir %(role)s du domaine %(domain)s."
#: core/templates/mail/html/maildomain_invitation.html:211
#: core/templates/mail/html/team_invitation.html:211
#: core/templates/mail/text/maildomain_invitation.txt:8
#: core/templates/mail/text/team_invitation.txt:8
msgid "To do so, please log in La Régie via ProConnect, by following this link:"
msgstr "Pour cela, merci de vous connecter une première fois à la Régie grâce à ProConnect, en suivant le lien ci-dessous :"
#: core/templates/mail/html/maildomain_invitation.html:221
#: core/templates/mail/html/team_invitation.html:221
#: core/templates/mail/text/maildomain_invitation.txt:10
#: core/templates/mail/text/team_invitation.txt:10
msgid "Go to La Régie"
msgstr "Accéder à la Régie"
#: core/templates/mail/html/maildomain_invitation.html:231
#: core/templates/mail/html/team_invitation.html:231
#: core/templates/mail/text/maildomain_invitation.txt:12
@@ -586,24 +740,10 @@ msgstr "etc."
msgid "Welcome aboard!"
msgstr "Bienvenue à bord !"
#: core/templates/mail/html/maildomain_invitation.html:255
#: core/templates/mail/html/team_invitation.html:268
#: core/templates/mail/text/maildomain_invitation.txt:22
#: core/templates/mail/text/team_invitation.txt:20
msgid "Regards,"
msgstr "Cordialement,"
#: core/templates/mail/html/maildomain_invitation.html:256
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/html/team_invitation.html:269
#: core/templates/mail/text/maildomain_invitation.txt:24
#: core/templates/mail/text/new_mailbox.txt:17
#: core/templates/mail/text/team_invitation.txt:22
msgid "La Suite Team"
msgstr "L'équipe de La Suite"
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/html/reset_password.html:159
#: core/templates/mail/text/new_mailbox.txt:3
#: core/templates/mail/text/reset_password.txt:3
msgid "La Messagerie"
msgstr "La Messagerie"
@@ -628,25 +768,48 @@ msgid "Please find below your login info: "
msgstr "Voici vos identifiants de connexion : "
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/html/reset_password.html:223
#: core/templates/mail/text/new_mailbox.txt:10
#: core/templates/mail/text/reset_password.txt:9
msgid "Email address: "
msgstr "Adresse email : "
#: core/templates/mail/html/new_mailbox.html:233
#: core/templates/mail/html/reset_password.html:228
#: core/templates/mail/text/new_mailbox.txt:11
msgid "Temporary password (to be modify on first login): "
#: core/templates/mail/text/reset_password.txt:10
msgid "Temporary password (to be modified on first login): "
msgstr "Mot de passe temporaire (à modifier à la première connexion) : "
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/html/reset_password.html:256
#: core/templates/mail/text/new_mailbox.txt:13
#: core/templates/mail/text/reset_password.txt:12
msgid "Go to La Messagerie"
msgstr "Accéder à La Messagerie"
#: core/templates/mail/html/new_mailbox.html:272
#: core/templates/mail/html/reset_password.html:267
#: core/templates/mail/text/new_mailbox.txt:15
#: core/templates/mail/text/reset_password.txt:14
msgid "Sincerely,"
msgstr "Cordialement,"
#: core/templates/mail/html/reset_password.html:188
#: core/templates/mail/text/reset_password.txt:5
msgid "Your password has been reset"
msgstr "Votre mot de passe a été réinitialisé"
#: core/templates/mail/html/reset_password.html:194
#: core/templates/mail/text/reset_password.txt:6
msgid "Your password has been reset."
msgstr "Votre mot de passe a été réinitialisé."
#: core/templates/mail/html/reset_password.html:199
#: core/templates/mail/text/reset_password.txt:7
msgid "Please find below your new login information: "
msgstr "Veuillez trouver ci-dessous vos nouvelles informations de connexion : "
#: core/templates/mail/html/team_invitation.html:206
#: core/templates/mail/text/team_invitation.txt:7
#, python-format
@@ -678,6 +841,21 @@ msgstr "Avec la Suite, vous pouvez créer, organiser et collaborer en ligne !"
msgid "For more information: <a href=\"%(link)s\">Visit the website for La Suite numérique</a> to discover what tools we offer."
msgstr "Pour en savoir plus : <a href=\"%(link)s\">Visitez le site de la Suite numérique</a> pour découvrir lensemble des outils proposés."
#: core/templates/mail/text/maildomain_action_required.txt:7
#, python-format
msgid "Your domain %(name)s cannot be used until the required actions have been completed."
msgstr "Votre domaine %(name)s ne peut pas être utilisé tant que certaines actions nont pas été menées."
#: core/templates/mail/text/maildomain_enabled.txt:7
#, python-format
msgid "Your domain %(name)s can be used now."
msgstr "Votre domaine %(name)s est prêt à être exploité."
#: core/templates/mail/text/maildomain_failed.txt:7
#, python-format
msgid "The domain %(name)s has encountered an error. As long as this error persists, all related mailboxes will remain disabled. Technical support is currently working on the issue."
msgstr "Le domaine %(name)s a rencontré une erreur. Tant que cette erreur persiste, toutes les boîtes mail demeureront désactivées. Le support technique est en train de travailler à résoudre cette erreur."
#: core/templates/mail/text/team_invitation.txt:17
#, python-format
msgid "For more information: Visit the website for La Suite numérique [%(link)s] to discover what tools we offer."
+35 -1
View File
@@ -14,7 +14,7 @@ from mailbox_manager.utils.dimail import DimailAPIClient
# ruff: noqa: S308
@admin.action(description=_("Synchronise from dimail"))
@admin.action(description=_("Import emails from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to synchronize existing mailboxes from dimail to our database.
Only works on enabled domains."""
@@ -135,6 +135,39 @@ def fetch_domain_expected_config_from_dimail(modeladmin, request, queryset): #
)
@admin.action(description=_("Send pending mailboxes to dimail"))
def send_pending_mailboxes(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Send pending mailboxes"""
client = DimailAPIClient()
excluded_domains = []
for domain in queryset:
# do not check disabled domains
if domain.status != enums.MailDomainStatusChoices.ENABLED:
excluded_domains.append(domain.name)
continue
results = client.send_pending_mailboxes(domain)
if failed_mailboxes := results["failed_mailboxes"]:
messages.error(
request,
_("Failed to send the following mailboxes : %(mailboxes)s.")
% {"mailboxes": ", ".join(failed_mailboxes)},
)
else:
messages.success(
request,
_("Pending mailboxes successfully sent for %(domain)s.")
% {"domain": domain.name},
)
if excluded_domains:
messages.warning(
request,
_("Domains disabled are excluded from : %(domains)s")
% {"domains": ", ".join(excluded_domains)},
)
class UserMailDomainAccessInline(admin.TabularInline):
"""Inline admin class for mail domain accesses."""
@@ -163,6 +196,7 @@ class MailDomainAdmin(admin.ModelAdmin):
sync_mailboxes_from_dimail,
fetch_domain_status_from_dimail,
fetch_domain_expected_config_from_dimail,
send_pending_mailboxes,
)
autocomplete_fields = ["organization"]
@@ -53,12 +53,19 @@ class MailboxSerializer(serializers.ModelSerializer):
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox.save()
# send confirmation email
client.notify_mailbox_creation(
recipient=mailbox.secondary_email,
mailbox_data=response.json(),
issuer=self.context["request"].user,
)
if mailbox.secondary_email:
# send confirmation email
client.notify_mailbox_creation(
recipient=mailbox.secondary_email,
mailbox_data=response.json(),
issuer=self.context["request"].user,
)
else:
logger.warning(
"Email notification for %s creation not sent "
"because no secondary email found",
mailbox,
)
# actually save mailbox on our database
return mailbox
@@ -228,29 +235,6 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
)
return attrs
def create(self, validated_data):
"""
Override create function to fire requests to dimail on access creation.
"""
dimail = DimailAPIClient()
user = validated_data["user"]
domain = validated_data["domain"]
if validated_data["role"] in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.OWNER,
]:
try:
dimail.create_user(user.sub)
dimail.create_allow(user.sub, domain.name)
except HTTPError:
logger.exception("[DIMAIL] access creation failed %s")
domain.status = enums.MailDomainStatusChoices.FAILED
domain.save()
return super().create(validated_data)
class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer):
"""Serialize mail domain access for list and retrieve actions."""
@@ -241,7 +241,7 @@ class MailBoxViewSet(
- first_name: str
- last_name: str
- local_part: str
- secondary_email: str
- secondary_email: str (optional)
Sends request to email provisioning API and returns newly created mailbox
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/disable/
@@ -249,6 +249,9 @@ class MailBoxViewSet(
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/enable/
Send a request to dimail to enable mailbox and change status of the mailbox in our DB
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/reset/
Send a request to mail-provider to reset password.
"""
permission_classes = [permissions.MailBoxPermission]
@@ -293,6 +296,15 @@ class MailBoxViewSet(
mailbox.save()
return Response(serializers.MailboxSerializer(mailbox).data)
@action(detail=True, methods=["post"])
def reset_password(self, request, domain_slug, pk=None): # pylint: disable=unused-argument
"""Send a request to dimail to change password
and email new password to mailbox's secondary email."""
mailbox = self.get_object()
dimail = DimailAPIClient()
dimail.reset_password(mailbox)
return Response(serializers.MailboxSerializer(mailbox).data)
class MailDomainInvitationViewset(
mixins.CreateModelMixin,
@@ -9,6 +9,7 @@ from rest_framework import status
from mailbox_manager import enums
from mailbox_manager.models import MailDomain, MailDomainAccess
from mailbox_manager.utils.dimail import DimailAPIClient
User = get_user_model()
@@ -23,6 +24,8 @@ class Command(BaseCommand):
Management command populate local dimail database, to ease dev
"""
client = DimailAPIClient()
help = "Populate local dimail database, for dev purposes."
def add_arguments(self, parser):
@@ -44,9 +47,9 @@ class Command(BaseCommand):
f"This command is not meant to run in {settings.CONFIGURATION} environment."
)
# Create a first superuser for dimail-api container. User creation is usually
# protected behind admin rights but dimail allows to create a first user
# when database is empty
# Create a first superuser for dimail-api container.
# User creation is usually protected behind admin rights
# but dimail allows to create a first user when database is empty
self.create_user(
auth=("", ""),
name=admin["username"],
@@ -54,8 +57,8 @@ class Command(BaseCommand):
perms=[],
)
# Create Regie user, auth for all remaining requests
# and your own dev
# Create Regie user,
# auth for all remaining requests and your own local setup
self.create_user(
auth=(admin["username"], admin["password"]),
name=regie["username"],
@@ -63,28 +66,22 @@ class Command(BaseCommand):
perms=["new_domain", "create_users", "manage_users"],
)
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
domain = MailDomain.objects.get_or_create(
name=domain_name,
defaults={
"status": enums.MailDomainStatusChoices.ENABLED,
"support_email": f"support@{domain_name}",
},
)[0]
self.create_domain(domain_name)
# we create a dimail user for keycloak+regie user John Doe
# This way, la Régie will be able to make request in the name of
# this user
# Create a test domain for local development
try:
people_base_user = User.objects.get(email="people@people.world")
except User.DoesNotExist:
self.stdout.write("people@people.world user not found", ending="\n")
else:
domain_name = "test.domain.com"
domain = MailDomain.objects.get_or_create(
name=domain_name,
defaults={
"status": enums.MailDomainStatusChoices.ENABLED,
"support_email": f"support@{domain_name}",
},
)[0]
self.create_domain(domain_name)
# create accesses for john doe
self.create_user(name=people_base_user.sub)
self.create_allows(people_base_user.sub, domain_name)
MailDomainAccess.objects.get_or_create(
user=people_base_user,
domain=domain,
@@ -94,7 +91,7 @@ class Command(BaseCommand):
if options["populate_from_people"]:
self._populate_dimail_from_people()
self.stdout.write("DONE", ending="\n")
self.stdout.write("DONE 🎉", ending="\n")
def create_user(
self,
@@ -128,20 +125,11 @@ class Command(BaseCommand):
)
)
def create_domain(self, name, auth=(regie["username"], regie["password"])):
def create_domain(self, name):
"""
Send a request to create a new domain.
Send a request to create a new domain using DimailAPIClient.
"""
response = requests.post(
url=f"{DIMAIL_URL}/domains/",
json={
"name": name,
"context_name": "context",
"features": ["webmail", "mailbox", "alias"],
},
auth=auth,
timeout=10,
)
response = self.client.create_domain(name, request_user="setup_dimail_db.py")
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
@@ -154,49 +142,12 @@ class Command(BaseCommand):
)
)
def create_allows(self, user, domain, auth=(regie["username"], regie["password"])):
"""
Send a request to create a new allows between user and domain.
"""
response = requests.post(
url=f"{DIMAIL_URL}/allows/",
json={
"domain": domain,
"user": user,
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
self.style.SUCCESS(
f"Creating permissions for {user} on {domain} ........ OK"
)
)
else:
self.stdout.write(
self.style.ERROR(
f"Creating permissions for {user} on {domain}\
........ failed: {response.json()['detail']}"
)
)
def _populate_dimail_from_people(self):
self.stdout.write("Creating accounts from people database", ending="\n")
user_to_create = set()
domain_to_create = set()
access_to_create = set()
for mail_access in MailDomainAccess.objects.select_related(
"domain", "user"
).all():
user_to_create.add(mail_access.user)
domain_to_create.add(mail_access.domain)
access_to_create.add(mail_access)
"""Populate dimail so that it reflects people's domains."""
self.stdout.write("Creating domain from people database", ending="\n")
# create missing domains
for domain in domain_to_create:
for domain in MailDomain.objects.all():
# enforce domain status
if domain.status != enums.MailDomainStatusChoices.ENABLED:
self.stdout.write(
@@ -205,15 +156,3 @@ class Command(BaseCommand):
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
self.create_domain(domain.name)
# create missing users
for user in user_to_create:
self.create_user(
auth=(admin["username"], admin["password"]),
name=user.sub,
perms=[], # no permission needed for "classic" users
)
# create missing accesses
for access in access_to_create:
self.create_allows(access.user.sub, access.domain.name)
@@ -0,0 +1,18 @@
# Generated by Django 5.1.8 on 2025-04-08 20:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0024_domaininvitation'),
]
operations = [
migrations.AlterField(
model_name='mailbox',
name='secondary_email',
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='secondary email address'),
),
]
+69 -3
View File
@@ -2,12 +2,17 @@
Declare and configure the models for the People additional application : mailbox_manager
"""
import logging
import smtplib
from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import exceptions, validators
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.db import models
from django.template.loader import render_to_string
from django.utils.text import slugify
from django.utils.translation import gettext
from django.utils.translation import get_language, gettext, override
from django.utils.translation import gettext_lazy as _
from core.models import BaseInvitation, BaseModel, Organization, User
@@ -18,6 +23,28 @@ from mailbox_manager.enums import (
MailDomainStatusChoices,
)
logger = logging.getLogger(__name__)
STATUS_NOTIFICATION_MAILS = {
# new status domain: (mail subject, mail template html, mail template text)
MailDomainStatusChoices.ENABLED: (
_("[La Suite] Your domain is ready"),
"mail/html/maildomain_enabled.html",
"mail/text/maildomain_enabled.txt",
),
MailDomainStatusChoices.ACTION_REQUIRED: (
_("[La Suite] Your domain requires action"),
"mail/html/maildomain_action_required.html",
"mail/text/maildomain_action_required.txt",
),
MailDomainStatusChoices.FAILED: (
_("[La Suite] Your domain has failed"),
"mail/html/maildomain_failed.html",
"mail/text/maildomain_failed.txt",
),
}
class MailDomain(BaseModel):
"""Domain names from which we will create email addresses (mailboxes)."""
@@ -104,6 +131,45 @@ class MailDomain(BaseModel):
bool(self.organization) and self.status == MailDomainStatusChoices.ENABLED
)
def notify_status_change(self, recipients=None, language=None):
"""
Notify the support team that the domain status has changed.
"""
subject, template_html, template_text = STATUS_NOTIFICATION_MAILS.get(
self.status, (None, None, None)
)
if not subject:
return
context = {
"title": subject,
"domain_name": self.name,
"manage_domain_url": (
f"{Site.objects.get_current().domain}/mail-domains/{self.slug}/"
),
}
try:
with override(language or get_language()):
mail.send_mail(
subject,
render_to_string(template_text, context),
settings.EMAIL_FROM,
recipients or [self.support_email],
html_message=render_to_string(template_html, context),
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error(
"Notification email to %s was not sent: %s",
self.support_email,
exception,
)
else:
logger.info(
"Information about domain %s sent to %s.",
self.name,
self.support_email,
)
class MailDomainAccess(BaseModel):
"""Allow to manage users' accesses to mail domains."""
@@ -218,7 +284,7 @@ class Mailbox(AbstractBaseUser, BaseModel):
blank=False,
)
secondary_email = models.EmailField(
_("secondary email address"), null=False, blank=False
_("secondary email address"), null=True, blank=True
)
status = models.CharField(
max_length=20,
+5 -20
View File
@@ -12,9 +12,7 @@ from django.utils import timezone
from core.models import User
from mailbox_manager import enums
from mailbox_manager.models import MailDomainAccess, MailDomainInvitation
from mailbox_manager.utils.dimail import DimailAPIClient
logger = logging.getLogger(__name__)
@@ -25,7 +23,6 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
Convert valid domain invitations to domain accesses for a given user.
Expired invitations are ignored.
"""
logger.info("Convert domain invitations for user %s", instance)
if created:
valid_domain_invitations = MailDomainInvitation.objects.filter(
email=instance.email,
@@ -38,6 +35,11 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
if not valid_domain_invitations.exists():
return
logger.info(
"Converting %s domain invitations for new user %s",
len(valid_domain_invitations),
instance,
)
MailDomainAccess.objects.bulk_create(
[
MailDomainAccess(
@@ -47,21 +49,4 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
]
)
management_role = set(valid_domain_invitations.values_list("role", flat="True"))
if (
enums.MailDomainRoleChoices.OWNER in management_role
or enums.MailDomainRoleChoices.ADMIN in management_role
):
# Sync with dimail
dimail = DimailAPIClient()
dimail.create_user(instance.sub)
for invitation in valid_domain_invitations:
if invitation.role in [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
]:
dimail.create_allow(instance.sub, invitation.domain.name)
valid_domain_invitations.delete()
logger.info("Invitations converted to domain accesses for user %s", instance)
+4 -1
View File
@@ -2,6 +2,8 @@
import time
from django.conf import settings
import requests
from celery import Celery
from celery.schedules import crontab
@@ -54,12 +56,13 @@ def fetch_domains_status_task(status: str):
for domain in MailDomain.objects.filter(status=status):
old_status = domain.status
# wait 10 seconds between each domain treatment to avoid overloading dimail
time.sleep(10)
time.sleep(settings.MAIL_CHECK_DOMAIN_INTERVAL)
try:
client.fetch_domain_status(domain)
except requests.exceptions.HTTPError as err:
logger.error("Failed to fetch status for domain %s: %s", domain.name, err)
else:
if old_status != domain.status:
domain.notify_status_change()
changed_domains.append(f"{domain.name} ({domain.status})")
return changed_domains
@@ -141,3 +141,26 @@ def test_api_domain_invitations__should_not_create_duplicate_invitations():
assert response.json()["__all__"] == [
"Mail domain invitation with this Email address and Domain already exists."
]
def test_api_domain_invitations__should_not_invite_when_user_already_exists():
"""Already existing users should not be invited but given access directly."""
existing_user = core_factories.UserFactory()
# Grant privileged role on the domain to the user
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build(email=existing_user.email)
).data
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
@@ -82,27 +82,6 @@ def test_api_mail_domains__create_authenticated():
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain1["name"] = domain_name
responses.add(
@@ -166,101 +145,6 @@ def test_api_mail_domains__create_authenticated():
assert domain.accesses.filter(role="owner", user=user).exists()
@responses.activate
def test_api_mail_domains__create_authenticated__dimail_failure(caplog):
"""
Despite a dimail failure for user and/or allow creation,
an authenticated user should be able to create a mail domain
and should automatically be added as owner of the newly created domain.
"""
caplog.set_level(logging.ERROR)
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain_name = "test.domain.fr"
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
dimail_error = {
"status_code": status.HTTP_401_UNAUTHORIZED,
"detail": "Not authorized",
}
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(dimail_error),
status=dimail_error["status_code"],
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"context": "null",
"features": ["webmail"],
"support_email": f"support@{domain_name}",
},
format="json",
)
domain = models.MailDomain.objects.get()
# response is as expected
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": enums.MailDomainStatusChoices.FAILED,
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
"count_mailboxes": 0,
"support_email": domain.support_email,
"last_check_details": None,
"action_required_details": {},
"expected_config": None,
}
# a new domain with status "failed" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.FAILED
assert domain.name == domain_name
assert domain.accesses.filter(role="owner", user=user).exists()
assert caplog.records[0].levelname == "ERROR"
assert "Not authorized" in caplog.records[0].message
## SYNC TO DIMAIL
@responses.activate
def test_api_mail_domains__create_dimail_domain(caplog):
@@ -285,28 +169,6 @@ def test_api_mail_domains__create_dimail_domain(caplog):
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain_name
responses.add(
@@ -340,11 +202,6 @@ def test_api_mail_domains__create_dimail_domain(caplog):
f"Domain {domain_name} successfully created on dimail by user {user.sub}"
in log_messages
)
assert f'[DIMAIL] User "{user.sub}" successfully created on dimail' in log_messages
assert (
f'[DIMAIL] Permissions granted for user "{user.sub}" on domain {domain_name}.'
in log_messages
)
@responses.activate
@@ -359,27 +216,6 @@ def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
"status_code": status.HTTP_409_CONFLICT,
"detail": "Domain already exists",
}
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/domains/"),
@@ -406,5 +242,5 @@ def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
assert record.levelname == "ERROR"
assert (
record.message
== "[DIMAIL] unexpected error : 409 {'detail': 'Domain already exists'}"
== "[DIMAIL] unexpected error: 409 {'detail': 'Domain already exists'}"
)
@@ -2,12 +2,9 @@
Test for mail domain accesses API endpoints in People's core app : create
"""
import logging
import random
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
@@ -108,16 +105,15 @@ def test_api_mail_domain__accesses_create_authenticated_administrator():
client = APIClient()
client.force_login(authenticated_user)
with responses.RequestsMock() as rsps:
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": enums.MailDomainRoleChoices.OWNER,
},
format="json",
)
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": enums.MailDomainRoleChoices.OWNER,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
@@ -127,38 +123,14 @@ def test_api_mail_domain__accesses_create_authenticated_administrator():
# It should be allowed to create a lower access
for role in [enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.VIEWER]:
other_user = core_factories.UserFactory()
with responses.RequestsMock() as rsps:
if role != enums.MailDomainRoleChoices.VIEWER:
# viewers don't have allows in dimail
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": str(other_user.sub),
"is_admin": "false",
"uuid": "71f60d74-a3ad-46bc-bc2b-20d79a2e36fb",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": other_user.sub, "domain": str(mail_domain.name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
new_mail_domain_access = models.MailDomainAccess.objects.filter(
user=other_user
@@ -182,37 +154,15 @@ def test_api_mail_domain__accesses_create_authenticated_owner():
client = APIClient()
client.force_login(authenticated_user)
with responses.RequestsMock() as rsps:
if role != enums.MailDomainRoleChoices.VIEWER:
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": str(other_user.sub),
"is_admin": "false",
"uuid": "71f60d74-a3ad-46bc-bc2b-20d79a2e36fb",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": other_user.sub, "domain": str(mail_domain.name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert models.MailDomainAccess.objects.filter(user=other_user).count() == 1
@@ -221,146 +171,3 @@ def test_api_mail_domain__accesses_create_authenticated_owner():
).get()
assert response.json()["id"] == str(new_mail_domain_access.id)
assert response.json()["role"] == role
## INTEROP WITH DIMAIL
def test_api_mail_domains_accesses__create_dimail_allows(caplog):
"""
Creating a domain access on our API should trigger a request to create an access on dimail too.
"""
caplog.set_level(logging.INFO)
authenticated_user = core_factories.UserFactory()
domain = factories.MailDomainFactory(status="enabled")
factories.MailDomainAccessFactory(
domain=domain, user=authenticated_user, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(authenticated_user)
allowed_user = core_factories.UserFactory()
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": str(allowed_user.sub),
"is_admin": "false",
"uuid": "71f60d74-a3ad-46bc-bc2b-20d79a2e36fb",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": allowed_user.sub, "domain": str(domain.name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/accesses/",
{
"user": str(allowed_user.id),
"role": enums.MailDomainRoleChoices.ADMIN,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
log_messages = [msg.message for msg in caplog.records]
# check logs
assert (
f'[DIMAIL] User "{allowed_user.sub}" successfully created on dimail'
in log_messages
)
assert (
f'[DIMAIL] Permissions granted for user "{allowed_user.sub}" on domain {domain.name}.'
in log_messages
)
def test_api_mail_domains_accesses__dont_create_dimail_allows_for_viewer():
"""Dimail should not be called when creating an access to a simple viewer."""
authenticated_user = core_factories.UserFactory()
domain = factories.MailDomainFactory(status="enabled")
factories.MailDomainAccessFactory(
domain=domain, user=authenticated_user, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(authenticated_user)
allowed_user = core_factories.UserFactory()
with responses.RequestsMock():
# No call expected
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/accesses/",
{
"user": str(allowed_user.id),
"role": enums.MailDomainRoleChoices.VIEWER,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
def test_api_mail_domains_accesses__user_already_on_dimail(caplog):
"""The expected allow should be created when an user already exists on dimail
(i.e. previous admin/owner of same domain or current on another domain)."""
caplog.set_level(logging.INFO)
authenticated_user = core_factories.UserFactory()
domain = factories.MailDomainFactory()
factories.MailDomainAccessFactory(
domain=domain, user=authenticated_user, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(authenticated_user)
allowed_user = core_factories.UserFactory()
with responses.RequestsMock() as rsps:
# No call expected
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{"detail": "User already exists"}
), # the user is already on dimail
status=status.HTTP_409_CONFLICT,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": allowed_user.sub, "domain": str(domain.name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/accesses/",
{
"user": str(allowed_user.id),
"role": enums.MailDomainRoleChoices.ADMIN,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# check logs
log_messages = [msg.message for msg in caplog.records]
assert (
f'[DIMAIL] Attempt to create user "{allowed_user.sub}" which already exists.'
in log_messages
)
assert (
f'[DIMAIL] Permissions granted for user "{allowed_user.sub}" on domain {domain.name}.'
in log_messages
)

Some files were not shown because too many files have changed in this diff Show More