Compare commits

...

712 Commits

Author SHA1 Message Date
Sabrina Demagny 70d22eecfa 🔖(minor) release version 1.12.0
Update all version files and changelog for minor release.
2025-02-18 08:51:31 +01:00
Laurent Bossavit 7379d70321 🐛(communes) add missing user creation in domain provisioning
Add user in Dimail for automatically provisioned domains.
2025-02-18 08:30:25 +01:00
Sabrina Demagny 29d0bbb692 (frontend) display button to re-run fetch domain from dimail
Add the button in the modal which describes actions required
to make the domain work
2025-02-17 20:39:03 +01:00
Sabrina Demagny cdb766b0e0 (domains) allow to run all fetch domain data from dimail
Fetch domain status and expected config from dimail.
2025-02-17 19:40:13 +01:00
Sabrina Demagny 38de864d68 (domains) add admin action to fetch domain DNS config
Create a Django admin action to allow retrieval of
the expected domain configuration from dimail.
These values shouldn't change unless external
intervention occurs. An admin command seems sufficient to handle
hypothetical changes.
2025-02-17 19:28:43 +01:00
Sabrina Demagny cad065da84 (frontend) display domain expected config for DNS
If any action is required on the domain, display the
expected domain configuration in the modal window to
inform the user of actions required to operate the domain.
2025-02-17 19:28:43 +01:00
Sabrina Demagny 3893fdf4d7 (domains) get domain expected config for DNS
Call dimail to get DNS configuration values
to make an external domain work and save it in our db.
Add values to serializer for displaying.
2025-02-17 19:28:43 +01:00
Laurent Bossavit d29b5141b1 ️(ci) save time on CI by not downloading already present browser
See https://github.com/microsoft/playwright/issues/23388
2025-02-17 10:30:06 +01:00
renovate[bot] 110fc82250 ⬆️(dependencies) update python dependencies 2025-02-17 09:03:33 +01:00
Sabrina Demagny 95f19f7c6c 🩹(domains) add missing migrations after define ordering
Add missing migration after define ordering by created_at
for Mailbox, MailDomain and MailDomainAccess by created_at.
2025-02-14 18:37:32 +01:00
Sabrina Demagny ab03cd9db9 (domains) check status after creation
Fetch domain status from dimail just after domain creation.
2025-02-14 16:47:53 +01:00
Quentin BEY a811431070 🧑‍💻(tilt) use maildev for local kube
Switch from mailcatcher to maildev for local work purpose.
2025-02-13 15:12:45 +01:00
Quentin BEY d23ac76f36 🧑‍💻(dev) use maildev for local developments
Switch from mailcatcher to maildev for local work purpose.
2025-02-13 15:12:45 +01:00
Sabrina Demagny 9377a96e87 💄(domains) remove useless bold text broken after fbb4797
Fix domain names displaying after fetch status
from dimail failure after commit fbb4797
2025-02-13 09:31:06 +01:00
Sabrina Demagny 25313d3e84 🔒️(docker) patch libssl3 and libcrypto3 to address CVE-2024-12797
Added temporary root privileges to update OpenSSL libraries. Upgrades libssl3
and libcrypto3 to 3.3.3-r0 to fix HIGH severity vulnerability. Properly
switches back to nginx user after updates. Maintains unprivileged execution
while addressing security concern affecting RFC7250 Raw Public Keys
authentication.

Security: CVE-2024-12797
2025-02-13 08:58:28 +01:00
Sabrina Demagny 9cd1b42c3d 💄(frontend) change support email example
The domain of support email suggested was
the same as the example domain name
suggested below.
This was a bit confusing because if the
domain is broken we need to contact someone
with a working email address.
2025-02-12 22:53:06 +01:00
Laurent Bossavit d08198e44d 🔊(prod) move logging config up to Base configuration class
This move makes it possible to set logging configuration on a per-deployment
basis in production.
2025-02-12 15:04:23 +01:00
Sabrina Demagny e55468862d 🧑‍💻(domains) change default ordering
Order by last created models MailDomain, Mailbox and MailDomainAccess.
2025-02-12 10:36:55 +01:00
Laurent Bossavit fbb4797f29 🚨(pytest) fix or suppress warnings during backend tests
Avoid unnecessary noise from testing processes
2025-02-11 16:52:26 +01:00
Sabrina Demagny 0f290df24a (frontend) display required actions details on Domain
On domain page, add link to display modal with needed informations
about actions to do to fix a domain.
2025-02-11 14:07:39 +01:00
Sabrina Demagny a2b2c71448 (api) add required actions to fix domain
Send all informations about required actions
to do to fix a domain and full check domain
health from dimail too.
2025-02-11 13:07:14 +01:00
Sabrina Demagny e1594493a7 🐛(plugin) fix support email
Use a real email to contact support in case
of actions required on the domain in collectivite.fr
2025-02-11 11:15:45 +01:00
renovate[bot] e903c5d4ca ⬆️(dependencies) update python dependencies 2025-02-11 10:57:20 +01:00
renovate[bot] 0f80d5f2db ⬆️(dependencies) update js dependencies 2025-02-11 10:42:56 +01:00
Laurent Bossavit 4cb695c2bf (plugin) add CommuneCreation plugin
Add unit tests and refactor name normalization and zone naming.
2025-02-11 09:53:31 +01:00
Laurent Bossavit a68f8171cb (plugin) add CommuneCreation plugin
Add E2E test to cover the API integration, access grant &c.
2025-02-11 09:53:31 +01:00
Laurent Bossavit 471f69d4ec (plugin) add CommuneCreation plugin
Extend plugin mechanism to be able to grant domain admin in Dimail
2025-02-11 09:53:31 +01:00
Laurent Bossavit dc938d3159 (plugin) add CommuneCreation plugin
Add test for zone creation call
2025-02-11 09:53:31 +01:00
Laurent Bossavit 87907d57de (plugin) add CommuneCreation plugin
Fix earlier test that was in error
2025-02-11 09:53:31 +01:00
Laurent Bossavit 57fd15100e (plugin) add CommuneCreation plugin
Add test for domain spec from Dimail
2025-02-11 09:53:31 +01:00
Laurent Bossavit 34c9dc6cd7 (plugin) add CommuneCreation plugin
Introduces some machinery for testing and executing API orchestrations.
Rolls back some changes in NameFromSiret plugin.
2025-02-11 09:53:31 +01:00
Sabrina Demagny 1be30496be 🌐(frontend) add missing translations
Upload and complete missing translations on crowdin
and download it
2025-02-10 14:46:03 +01:00
Sabrina Demagny 0ca5fa5318 (frontend) new status action required on domain
Display new status flag on mail domain.
2025-02-10 14:25:58 +01:00
Marie PUPO JEAMMET 4d3901b35d (auth) fix empty name from ProConnect
add proconnect scopes for names to be computed automatically
upon user creation
This commit fixes the way names are computed from ProConnect claims
2025-02-10 12:59:16 +01:00
Sabrina Demagny 961bceb64e (domains) store last check domain results
Store results of last dimail check on a domain.
2025-02-10 12:24:37 +01:00
Sabrina Demagny d7957547f8 (frontend) add support email field on domain creation form
Add new field to give email of support to manage actions
required on domain.
2025-02-10 11:37:07 +01:00
Sabrina Demagny 418db6194a (domains) add support email field on MailDomain
Add new field on MailDomain to allow contact support
if some actions are required to fix domain.
2025-02-10 11:37:07 +01:00
Jacques ROUSSEL c2899f66af 🔧(helm) add pdbs to deployments
In order to avoid a service interruption during a Kubernetes
(k8s)upgrade, we add a Pod Disruption Budget (PDB) to deployments.
2025-02-10 11:35:53 +01:00
Sabrina Demagny ba3f6a504f 🌐(backend) add missing translations
Re-run back-i18n-generate and back-i18n-compile
and add some missing translations
2025-02-07 22:11:37 +01:00
Marie PUPO JEAMMET 3de495a489 🚚(github) update all mentions to github repo
github repo moved from numerique-gouv to suitenumerique org
2025-02-07 14:26:27 +01:00
Sabrina Demagny e297a025c3 🔖(minor) release version 1.11.0
Update all version files and changelog for minor release.
2025-02-07 11:46:19 +01:00
Quentin BEY 351c696ef8 🐛(tilt) fix the dev-keycloak configuration
The mailcatcher configuration was missing from the
configuration file when using keycloak.
2025-02-06 10:38:46 +01:00
Sabrina Demagny 579dbdee10 (api) add count mailboxes to MailDomain serializer
Return number of mailboxes of a domain in our API.
2025-02-04 15:22:00 +01:00
Quentin BEY b4a877381a 🐛(teams) disable creation endpoint from abilities
When we don't allow the user to see the team creation button,
we also want to disable the corresponding API.
2025-02-04 15:20:48 +01:00
Quentin BEY 92753082c7 🚑️(teams) hide display add button when disallowed
The frontend should not display "add team" actions when the
user has not the ability.
2025-02-04 15:20:48 +01:00
Sabrina Demagny 4df4172151 (dimail) manage 'action required' status for MailDomain
Adapt fetch domain status call to manage internal and external
fixes required. Use the new status 'action required' to
manage actions expected from support.
Call a new dimail endpoint to run a fix for internal checks
when all external checks are OK.
2025-02-04 14:13:36 +01:00
Laurent Bossavit e7af1fd591 🐛(frontend) improve e2e tests avoiding race condition from mocks
Reorder mock setup with page.route so that it takes place before nav.
2025-02-04 14:03:35 +01:00
Sabrina Demagny 8a2b0d0a76 🐛(backend) fix flaky test after add fr translations
Define user language to test invitation email content.
2025-02-03 15:24:39 +01:00
Sabrina Demagny d48a3ff677 (domains) add action required status on MailDomain
Create a new domain status to handle cases where
action is required from an external domain owner
for a domain to be fully functional.
2025-02-03 15:24:32 +01:00
renovate[bot] 4fb5d87df9 ⬆️(dependencies) update python dependencies 2025-02-03 14:30:56 +01:00
renovate[bot] 6274764f90 ⬆️(dependencies) update js dependencies 2025-02-03 14:18:33 +01:00
Marie PUPO JEAMMET 7f0e231474 (api) restrict mailbox sync to enabled domains
Pending, failed and deactivated domains should not be sync'ed.
2025-02-03 13:47:02 +01:00
Marie PUPO JEAMMET 2c15609c1e 🎨(dimail) factorize dimail response upon successful mailbox creation
dimail's ok response upon mailbox creation is used in several tests.
All those tests now reference proper response available in fixtures.
2025-02-03 13:47:02 +01:00
Marie PUPO JEAMMET cd94dc5091 (dimail) send pending mailboxes upon domain activation
send creation requests to dimail for all pending mailboxes
when domain goes from "pending" to "enabled".
2025-02-03 13:47:02 +01:00
Sabrina Demagny 9d9216cf39 🌐(backend) add missing translations
Regenerate translations, translate some and
replace "Desk" and "Régie" to "La Régie".
2025-02-03 13:24:40 +01:00
Quentin BEY 7dd9eae5d9 💚(argocd) humble try to fix the webhook call
This is an attempt to fix:
`Webhook processing failed: HMAC verification failed`
2025-02-03 13:12:42 +01:00
Quentin BEY 914319c366 💚(argocd) fix deployment command
The command should use organization variables.
2025-02-03 12:48:50 +01:00
Quentin BEY c34ad00fae 💚(docker) fix docker login command
Use the secret from github organization.
2025-02-03 12:35:12 +01:00
Quentin BEY 289879962b 🧑‍💻(tilt) add mailcatcher to the stack
This allows to start a mailcatcher for local developments.
2025-02-03 12:21:34 +01:00
Quentin BEY cd88799943 💚(github) remove secret fetch
The secrets are not managed in the folder anymore.
2025-01-30 15:55:58 +01:00
Quentin BEY 4011c8e8ed 🚑️(plugins) fix name from SIRET specific case
For some SIRET, there are no "liste_enseignes" (null), in such
cases we fallback on the global "company" name.
2025-01-30 15:55:58 +01:00
Sabrina Demagny b98281fa72 🔖(patch) release version 1.10.1
Update all version files and changelog for patch release.
2025-01-27 15:11:01 +01:00
Sabrina Demagny 04bd154bad (scripts) improve release script
One of instructions was in a wrong place.
Release tag on github belongs to the project part.
2025-01-27 14:45:58 +01:00
Marie PUPO JEAMMET 227ecd0700 🐛(dimail) fix imported mailboxes should be enabled instead of pending
Importing mailboxes creates pending mailboxes ... but these mailboxes are
already active and, for most, functional. We thus mark them as "enabled".
2025-01-27 11:23:26 +01:00
renovate[bot] 6d618b4aff ⬆️(dependencies) update python dependencies 2025-01-27 11:09:38 +01:00
renovate[bot] 3a2b2b8a53 ⬆️(dependencies) update js dependencies 2025-01-27 09:33:51 +01:00
Sabrina Demagny f969b118cb (dimail) management command to fetch domain status
Add a management command for a future cron to
to check and update regularly domains status
from dimail.
2025-01-24 19:33:45 +01:00
Sabrina Demagny c48957612b ️(api) add missing cache for stats endpoint
Add cache to display stats for anonymous
and improve some code.
2025-01-24 16:52:02 +01:00
Sabrina Demagny e09e1d1b80 (scripts) adapts release script
Adapts the release script after moving
the deployment part to a new repository.
2025-01-23 18:14:00 +01:00
Marie PUPO JEAMMET 615d5894c9 🔖(minor) release version 1.10.0
Update all version files and changelog for minor release.
heml/env.d/preprod and prod directories are now on another repo.
2025-01-21 17:18:55 +01:00
Laurent Bossavit 914554e45c 🐛(linting) fix linting errors arising from Ruff update
This update affects assertion messages in particular.
2025-01-21 00:42:47 +01:00
renovate[bot] 3f990e93b7 ⬆️(dependencies) update python dependencies 2025-01-21 00:42:47 +01:00
Jacques ROUSSEL 9de20a496e 🐛(ci) fix argocd webhook to auto deploy on staging
Changing the deployment repository broke the automatic deployment of the
main branch. This commit fixes it.
2025-01-20 17:42:24 +01:00
Marie PUPO JEAMMET 870ef424f5 (api) create stats endpoint
create stats endpoint to expose public metrics
2025-01-20 17:32:25 +01:00
renovate[bot] 0e92c1cafa ⬆️(dependencies) update js dependencies 2025-01-20 09:56:30 +01:00
Sabrina Demagny defc18ea40 🐛(backend) fix flaky test with search contact
Sometimes emails generated by faker in data field match search.
So it's necessary to create contacts with empty data field
to search contacts by full_name in tests.
2025-01-17 19:56:35 +01:00
Quentin BEY 4ccea4655b (teams) add treebeard data to serializers
This will allow the frontend to represent teams as a
tree if needed.
2025-01-17 19:00:14 +01:00
Quentin BEY 45fd10fd2d (teams) return parent teams in resource server
Also return the parent teams in the user's team endpoints.
2025-01-17 19:00:14 +01:00
Quentin BEY 201864db3a (teams) return parent teams in API
Also return the parent teams in the user's team endpoints.

This is a first implementation which returns a flat list
of teams (not a tree). This is not really helpful, but
it allows to create hierarchical teams manually (via
admin) if an organization needs it.
2025-01-17 19:00:14 +01:00
Quentin BEY 182f9c1d17 🗃️(teams) add Team dependencies as a tree
This provides the technical way to create Team trees.
The implementation is quite naive.
2025-01-17 19:00:14 +01:00
Quentin BEY cff3d5c123 🐛(tilt) add missing file after previous commit
My previous PR was merged to quickly, I forgot to add
the file to create the secret for Tilt.
2025-01-17 18:11:51 +01:00
Quentin BEY 32a576bbe9 🧑‍💻(tilt) add sync dimail from people btn
Add a button to force sync of dimail accounts from
the people database.
2025-01-17 17:53:14 +01:00
Quentin BEY 010d3674de 🧑‍💻(tilt) add dimail
This adds dimail to the tilt kube deployment
2025-01-17 17:53:14 +01:00
Jacques ROUSSEL 80976e3761 👷(helm) add CI for publishing Helm charts
We need to publish a Helm chart to facilitate separating the code from
the deployment configuration.
2025-01-17 15:26:38 +01:00
Jacques ROUSSEL b848f9eca6 ♻️(dev) refacto tilt stack
To be able to move the repository on the new organization and to
facilitate external developer integration we need to create a standalone
dev stack and use external secret.
2025-01-17 15:26:38 +01:00
Sabrina Demagny cd7135da00 🐛(backend) fix flaky test with team access #646
Faker sometimes creates users whose name starts with "Ms." or "Mr."
The implemented test code computed the order without handling
these cases and failed.
2025-01-17 10:02:27 +01:00
Sabrina Demagny 0a795f6e6f 🧑‍💻(dimail) remove 'NoneType: None' log
Fixes "NoneType: None" log appearing in debug mode.
2025-01-17 08:56:18 +01:00
Sabrina Demagny 6c8329405d 🧑‍💻(setup_dimail_db) create missing access
Add missing John Doe access role on domain test.domain.com
2025-01-17 08:42:10 +01:00
Sabrina Demagny ea3a45ea87 (dimail) improve fetch domain status tests
Add missing test case and add a new fake data
for fetch domain status from dimail
2025-01-16 23:07:14 +01:00
Sabrina Demagny 86451df8b4 ♻️(tests) reorganization of test files
Create two new folders in mailbox_manager tests
2025-01-16 15:57:25 +01:00
Quentin BEY 76fc789eb6 (organization) add admin action for plugin
This allows admin user to run the post creation plugins
from the organization list.
2025-01-16 09:28:38 +01:00
renovate[bot] 632a78c339 ⬆️(dependencies) update django to v5.1.5 [SECURITY] 2025-01-15 10:21:07 +01:00
Laurent Bossavit 20cc173e93 (anct) fetch and display organization names of communes
ANCT-specific extraction of organization names for communes, front
end changes to match.
2025-01-13 15:01:54 +01:00
Marie PUPO JEAMMET d495ef3e19 🧑‍💻(admin) add read-only fields to mailbox admin
mark local_part and domain as read-only fields in admin,
in order to prevent mistakes/temptation. For now, if an local part
needs modification, you can simply delete/recreate the email you want.
Changing the domain is a bigger operation that cannot
be settled simply by changing it Django db.
2025-01-13 08:53:57 +01:00
Sabrina Demagny 4def80214c (frontend) display email if no username
Fallback to email if no username is defined for logged user.
2025-01-12 00:45:49 +01:00
Sabrina Demagny 2428229dbb 🐛(frontend) fix flaky e2e test
Small hack to fix clicking on member action button.
Action button of the third member in the list could not
be clickable every time.
The member list table needs and will be improved soon.
2025-01-12 00:32:56 +01:00
Sabrina Demagny 9b9aa2aa37 🐛(frontend) fix disable mailbox button display
Do not display disable/enable mailbox button
for viewers.
Only owner and admin can manage mailbox and use
this button.
2025-01-10 16:38:07 +01:00
renovate[bot] a28bb5e2a2 ⬆️(dependencies) update js dependencies (#591)
* ⬆️(dependencies) update js dependencies

* ⬆️(dependencies) fixes for React 19

* ⬆️(dependencies) unit test fixes for React 19

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Bossavit <laurent@MacBook-Pro-de-Laurent.local>
2025-01-10 15:25:38 +01:00
Sabrina Demagny 6962953625 🧑‍💻(admin) add filter for domain
Allow to filter domain by status
2025-01-09 14:12:18 +01:00
Sabrina Demagny b0b718e657 (backend) add admin action to check domain health
Allow to select some domains to check and update status
thanks to a dimail call.
2025-01-09 13:59:00 +01:00
Sabrina Demagny 0abfd49fee (dimail) check domain health
Call dimail to check if a domain still works.
Turn domain into failure status if dimail returns broken state.
And enable domain if dimail returns ok state.
2025-01-09 12:55:23 +01:00
Marie PUPO JEAMMET 5d2e63fc18 🐛(tests) fix flaky back-end test
fixed a test that would fail because of random auth user name being
too close to tested names (assert would find 2 results instead of 1).
Setting the name of auth user should fix the issue permanently.
2025-01-09 10:10:49 +01:00
Marie PUPO JEAMMET 94a443525e 🧑‍💻(admin) add filter and search function to admin mailboxes view
add fields to easily filter and search mailboxes on their statuses,
local part and domains
2025-01-07 17:38:36 +01:00
renovate[bot] 42d7d00772 ⬆️(dependencies) update python dependencies 2025-01-06 10:13:57 +01:00
renovate[bot] 5ec65b1202 ⬆️(dependencies) update next to v15.1.2 [SECURITY] 2025-01-04 13:04:06 +01:00
renovate[bot] 9ba30843c5 ⬆️(dependencies) update python dependencies 2025-01-03 01:16:57 +01:00
Marie PUPO JEAMMET 4de589a7e8 🧑‍💻(mailboxes) add and improve some tests
Add and improve some tests linked to mailbox creation.
2024-12-26 22:22:27 +01:00
Sabrina Demagny 151f030582 🐛(backend) fix dimail call despite mailbox creation failure on our side
When we try to create a duplicate email, a request
to dimail is sent despite a reject on our side.
To solve this issue, we call mailbox creation first
to benefit from validation of our Django model.

Mailbox creation try was called too late after dimail call.
So in attempt to create a duplicated email a request
to dimail was sent despite a failure in our side.
2024-12-26 19:43:04 +01:00
Sabrina Demagny a58152b9a9 📝(scripts) add missing release instructions
Do not forget to check docker image of frontend.
2024-12-26 13:40:35 +01:00
Laurent Bossavit 8fd55a61c5 (e2e) change accounts to facilitate SIRET and add e2e test
We also add registration ID info to the /me endpoint, via serializers
2024-12-23 20:18:44 +01:00
Laurent Bossavit 2435a59078 🧑‍💻(keycloak) add siret attribute and mapper to Keycloak
We can now find organization data as provided by ProConnect in user_info
2024-12-23 20:18:44 +01:00
renovate[bot] fd2c90f50d ⬆️(dependencies) update python dependencies 2024-12-23 17:22:34 +01:00
Quentin BEY 469014ac41 🧑‍💻(user) fix the User.language
The use of a lazy function here make the Django migration
detector to generate a migration every time we run `makemigrations`.
This is not mandatory to have a lazy here as the settings are loaded
once at runtime beginning.
As the choices makes noop migrations, we directly use the setting in
the initial migration.
2024-12-19 22:16:08 +01:00
Marie PUPO JEAMMET e60bae4321 🔖(patch) release version 1.9.1
Update all version files and changelog for patch release.
2024-12-18 15:48:45 +01:00
Marie 2e7f224e30 ⬆️(dimail) upgrade dimail to v0.1.1
Upgrade dimail containers to v0.1.1
2024-12-18 11:38:10 +01:00
Sabrina Demagny 8865e250a0 🔖(minor) release version 1.9.0
Update all version files and changelog for minor release.
2024-12-17 14:48:38 +01:00
Nathan Panchout fa114b1064 (frontend) disable mailbox and allow to create pending mailbox
Allow to disable or enable mailboxes.
And enable mailbox creation button when domain is pending to
allow creation of mailboxes in pending status.
2024-12-17 14:16:06 +01:00
Marie PUPO JEAMMET 38369a8312 🚚(backend) split users test file to improve readability
"test_api_users" was a single test file of 900+ lines.
We used gitfilesplit to split it into several shorter files
for readability.
2024-12-17 14:00:35 +01:00
Marie PUPO JEAMMET d3940f6d09 Split test_api_users.py into users/test_api_users_retrieve.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET 6061a65e44 Split test_api_users.py into users/test_api_users_create.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET 4a763396e2 Split test_api_users.py into users/test_api_users_delete.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET fcc63f9b82 Split test_api_users.py into users/test_api_users_update.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET 0f4db82b02 Split test_api_users.py into users/test_api_users_list.py 2024-12-17 13:48:18 +01:00
renovate[bot] bcdc57905b ⬆️(dependencies) update python dependencies 2024-12-17 12:12:57 +01:00
Marie PUPO JEAMMET 30832a20e1 ⬆️(dimail) upgrade dimail to 0.0.20
upgrade to 0.0.20 to fix weird anonymous admin behaviour when database
not initialized.
2024-12-17 11:48:52 +01:00
Sabrina Demagny a021e9eec6 🐛(maildomain) fix domain access creation
During a new domain creation, a call to dimail is made
to create user/allow on dimail side before owner role creation
on our side.
So when user/allow creation on dimain side fails,
the owner role is not created on our side.
Therefore the domain is created but invisible on the user interface.
The user will probably try to create the same domain again
and see the error message 'this domain already exists'.
To avoid this we make sure to create owner role on our side
despite dimail failure and set domain to failed status to retry
later dimail access creation.
2024-12-17 10:56:44 +01:00
Quentin BEY fa80edfaa8 🔧(helm) add organization name from siret plugin
This declares the use of the `NameFromSiretOrganizationPlugin`
to allow automatic SIRET -> Name guess when creating an
organization
2024-12-16 16:08:08 +01:00
Quentin BEY 38a5f158b5 (organizations) add siret to name conversion
This adds the plugin system to easily manage
Organization related customizations. This first
plugin tries (best effort) to get a proper name
for the Organization, using its SIRET. This
is French specificities but another plugin can
be defined for other cases.
2024-12-16 16:08:08 +01:00
Quentin BEY 6e14c2e61f (e2e) fix flaky test
The URL may or may not include the "page" query parameter.
2024-12-16 15:51:25 +01:00
Quentin BEY dd9a905dc0 📝(tilt) add startup guide to Tilt use
This provides an "how-to" to deploy a local dev
Kubernetes environment.
2024-12-16 12:35:48 +01:00
Quentin BEY 2f380b49d0 🧑‍💻(dimail) allow account populate from DB
Allow the dimail account creation command to create
accounts from the data in people's database.
2024-12-16 12:18:18 +01:00
Quentin BEY 9953dd2111 💄(frontend) redirect home according to abilities
We try to detect the landing page according to user
permissions (abilities) instead of just the configuration
setting.
This will be improved when the homepage is developed
2024-12-16 11:43:46 +01:00
Sabrina Demagny 5d84e226b7 (maildomain_access) add API endpoint to search users
Add new API endpoint to search for new users
to whom we can assign new roles.
2024-12-13 16:27:48 +01:00
Quentin BEY 6fde76fb46 (contacts) add "abilities" to API endpoint data
Returns the possible actions to the frontend using the
model's `get_abilities`.
2024-12-13 11:52:10 +01:00
Quentin BEY 7154a491f4 ♻️(contacts) switch API to get_abilities
Use the common way to define permissions on the API.

Note: we keep here the notion of "public" contacts,
even if the API does not really allows that. The use
case is not clear for that, but we allow contact w/o
owner to be displayed.
2024-12-13 11:52:10 +01:00
Quentin BEY 019ce99a86 (contacts) filter list API with email
This allows to lookup onto emails for the "magic
filter" on the API list endpoint.
2024-12-12 18:22:53 +01:00
Quentin BEY 579aac264e (contacts) list profile contacts from same org
Allow the contact API to list "profile" contacts for
user of the same organization.
2024-12-12 18:22:53 +01:00
Sabrina Demagny c54f010989 🔖(minor) release version 1.8.0
Update all version files and changelog for minor release.
2024-12-12 17:18:50 +01:00
Sabrina Demagny 841a6c27ba 🐛(backend) fix manage roles on domain admin view
Fix creation of new role to manage mail domain on admin interface
2024-12-12 16:19:51 +01:00
Sabrina Demagny b060bbea51 🐛(mailbox) fix activate mailbox feature
Fix 422 Unprocessable Entity error returned
by dimail because of some missing data sent.
2024-12-12 14:37:22 +01:00
Laurent Bossavit ac24dd91a7 🐛(dimail) ensure Dimail DB is initialized prior to E2E testing
Add a command to the testing workflow to ensure Dimail DB is set up.
2024-12-11 10:29:36 +01:00
Marie PUPO JEAMMET 867c89a2e6 ⬆️(dimail) change dev and tests dimail container version
upgrade dimail container to last version
2024-12-11 10:29:36 +01:00
Marie PUPO JEAMMET 54120eb179 (dimail) automate allows requests to dimail
Automatically send user creation and allow creation requests to dimail
upon creating domain access.
2024-12-11 10:29:36 +01:00
renovate[bot] cf155dc033 ⬆️(dependencies) update python dependencies 2024-12-09 10:47:27 +01:00
Marie PUPO JEAMMET e623c32c9a 🐛(dimail) fix dimail setup command
fix "dimail-setup-db" command
2024-12-09 10:03:12 +01:00
renovate[bot] a1892d2693 ⬆️(dependencies) update django to v5.1.4 [SECURITY] 2024-12-06 23:44:34 +01:00
Quentin BEY 632a96ccf4 🔧(helm) fix the configuration environment
The configuration also defines the Sentry environment
value, which is currently "production" for every Sentry
event.

- dev: Local
- staging: Staging
- preprod: PreProduction
- prod: Production
2024-12-06 09:52:38 +01:00
Quentin BEY d7c654e1aa 📝(contacts) update documentation
This update the documentation to reflect latest
changes:
- drop `Identity`
- `Contact` model updates
2024-12-05 18:40:32 +01:00
Quentin BEY 99b6181944 (e2e) sync keycloak users w/ backend users
The user full name was not the same, it would induce
flaky test, while the user name is updated at user
login from the KC data.
2024-12-05 11:39:58 +01:00
Quentin BEY 514414de16 ♻️(contacts) clean tests after split 2/2
We need to make several cleanup commits to
keep the git history clean.
2024-12-03 16:26:44 +01:00
Quentin BEY f1956f4e86 ♻️(contacts) clean tests after split 1/2
We need to make several cleanup commits to
keep the git history clean.
2024-12-03 16:26:44 +01:00
Quentin BEY f13e2c18b9 Merge branches 'split/core/tests/test_api_contacts.py-core/tests/contacts/test_core_api_contacts_list.py', 'split/core/tests/test_api_contacts.py-core/tests/contacts/test_core_api_contacts_retrieve.py', 'split/core/tests/test_api_contacts.py-core/tests/contacts/test_core_api_contacts_create.py' and 'split/core/tests/test_api_contacts.py-core/tests/contacts/test_core_api_contacts_update.py' into qbey/contacts_api_split_tests 2024-12-03 16:26:44 +01:00
Quentin BEY 4ba1901355 Split core/tests/test_api_contacts.py into core/tests/contacts/test_core_api_contacts_delete.py 2024-12-03 16:26:44 +01:00
Quentin BEY e821163269 Split core/tests/test_api_contacts.py into core/tests/contacts/test_core_api_contacts_list.py 2024-12-03 16:26:44 +01:00
Quentin BEY fc9499e211 Split core/tests/test_api_contacts.py into core/tests/contacts/test_core_api_contacts_retrieve.py 2024-12-03 16:26:44 +01:00
Quentin BEY 9dde2fe7e2 Split core/tests/test_api_contacts.py into core/tests/contacts/test_core_api_contacts_create.py 2024-12-03 16:26:44 +01:00
Quentin BEY 5dad700c1c Split core/tests/test_api_contacts.py into core/tests/contacts/test_core_api_contacts_update.py 2024-12-03 16:26:44 +01:00
Quentin BEY f759d318b9 ♻️(contacts) move user profile to contact
Move the user <-> contact relation for "profile" to
the contact model.

Now the Contact model is the only one to point to
User (and not backward).

Contact:

- FK to User for the owner
- FK to User for the profile
2024-12-03 16:25:25 +01:00
Quentin BEY 60ab61d125 🗃️(contacts) rename base to override
To improve code readability, I propose to rename
the contact field `override`. This comes along
with the fact a contact should not not always
override another (it's the case were I only want
to create some personal contacts).
2024-12-03 16:02:11 +01:00
Quentin BEY 625f122ad5 (contacts) add notes & force full_name
We make the full name mandatory and add a field to
allow user to store personnal notes on the contact.

This also make the "base" contact not mandatory because
user may want to create new contacts out of the blue.
2024-12-03 16:02:11 +01:00
Sabrina Demagny 6d5d0f7ebb 🔧(helm) fix siret validator for staging
This fixes validator for the Organization
registration ID for staging (to enforce SIRET format).
2024-12-03 10:47:43 +01:00
renovate[bot] b7908801d4 ⬆️(dependencies) update python dependencies 2024-12-02 14:18:31 +01:00
renovate[bot] 0785c3a5bc ⬆️(dependencies) update js dependencies 2024-12-02 13:58:40 +01:00
Sabrina Demagny 51eb3474e4 🔖(patch) release version 1.7.1
Update all version files and changelog for patch release.
2024-11-28 18:17:50 +01:00
Laurent Bossavit 10be01f6af 👽️(dimail) adapt dev deployment to modified endpoint of Dimail API
Changed: api.dev.ox.numerique.gouv.fr -> api.ovhdev.dimail1.numerique.gouv.fr
2024-11-28 18:17:15 +01:00
Sabrina Demagny 8e20f86d2c 🔖(minor) release version 1.7.0
Update all version files and changelog for minor release.
2024-11-28 17:29:54 +01:00
Laurent Bossavit 03c79b0412 👽️(dimail) adapt prod deployment to modified endpoint of Dimail API
Changed: api.ox.numerique.gouv.fr -> api.ovhprod.dimail1.numerique.gouv.fr
2024-11-28 17:29:08 +01:00
renovate[bot] a33822b38d ⬆️(dependencies) update js dependencies 2024-11-27 10:13:07 +01:00
Quentin BEY edbd1f0061 (user) add organization data on users API
This will allow the frontend to display data about
organizations when displaying a user or a list of
users.
2024-11-27 10:03:32 +01:00
Quentin BEY 5692c50f21 (organization) add API endpoints
This provides a way to get information about
the organization and update their name for
administrators.
2024-11-27 10:03:32 +01:00
Quentin BEY 6fe4818743 📝(docs) light doc about org & SP
This introduces a very light documentation about
the newly introduced models.
2024-11-27 09:53:32 +01:00
Sabrina Demagny 02c6048d2c (mailbox) allow to activate mailbox
We send a request to dimail API and change mailbox status to enabled.
2024-11-26 15:37:04 +01:00
Sabrina Demagny a6f409f6ed 🐛(dimail) pin dimail-api version
New feature to activate/desactivate mailboxes
is available in v0.0.13
2024-11-26 14:38:38 +01:00
Sabrina Demagny ccb06b3abf (mailbox) allow to disable mailbox
We send a request to dimail API and change mailbox status to disabled.
A disabled mailbox can no longer be used thus access to webmail
is disabled for user.
2024-11-26 14:38:38 +01:00
Sabrina Demagny 3469764697 ♻️(dimail) rename some methods of DimailAPIClient
Use more intuitive and shorter names for actions available in
DimailAPIClient.
2024-11-26 12:17:24 +01:00
renovate[bot] 892a5f10d6 ⬆️(dependencies) update python dependencies 2024-11-26 11:42:39 +01:00
Quentin BEY 0357caa75a 💄(admin) allow header color customization
This allows to use custom colors according to
environment.

FIXES #430
2024-11-26 10:10:59 +01:00
Quentin BEY 9785ce295d 📝(changelog) fix after PR merge
The git rebase moved the changelog line without
conflict so I did not detect it.
2024-11-25 17:01:26 +01:00
Quentin BEY a26e10909d 🐛(admin) add organization on user
User fields has to be listed, this one was forgotten
in the "add organization" commit...
2024-11-25 17:01:26 +01:00
Quentin BEY d6f1cae9e9 🚚(api) split API module in client/resource_server
To improve readability and code sharing we group all
APIs into the same "api" module for each application.

Next submodules might be "scim",
"resource_server_scim", ...

The only shared module is the "permissions" one for now.
2024-11-25 16:05:18 +01:00
Quentin BEY 478a3ffbd1 🎨(dockerfile) fix FromAsCasing warnings
Warning where raised because of the casing change between
`FROM` and `as`.
2024-11-25 16:05:18 +01:00
Quentin BEY 8e6b6318c9 (service_providers) add API endpoints
This allow to display service providers in the frontend.
Not used yet, but will allow to manage organization and
teams related service providers.
2024-11-25 16:05:18 +01:00
Quentin BEY b524369add 🐛(admin) fix organization validators
When updating an Organization in the Django admin, the validator
falsly raises a "duplicated" error because it does not exclude the
current object from the database lookup.
2024-11-25 16:05:18 +01:00
Quentin BEY a991737a59 🔒️(backend) restrict resource server views
We don't want every Service Provider to be able to request
every endpoint if those are not implementing a filtering on
the data returned. To prevent any data leak we enforce the
developers to manually "whitelist" each endpoint and add
the proper filtering when needed.
2024-11-25 16:05:18 +01:00
Quentin BEY a041296f8a (backend) add ServiceProvider
This adds the ServiceProvider notion to allow to better
manage which teams is available for each service provider.
2024-11-25 16:05:18 +01:00
Sabrina Demagny 512d9fe82c 📝(scripts) improve command to do to release
Remove useless option to create tag
2024-11-22 12:04:32 +01:00
Quentin BEY b205ad1d16 🔖(patch) release version 1.6.1
Update all version files and changelog for patch release.
2024-11-22 10:14:47 +01:00
Quentin BEY 0227231370 🚑️(backend) fix claim contains non user field
When we use the feature to get Organization registration
number, the claim contains this value and it does not
match with any user field.
I switched to a whitelist instead of a blacklist (and two
loops, with an if condition on each)
2024-11-22 09:55:28 +01:00
Sabrina Demagny a57070bfb8 🩹(mailbox) fix status of current mailboxes
All mailboxes created so far should be in active status
2024-11-21 23:20:51 +01:00
Quentin BEY 17a1c39dbf 🗃️(teams) remove the slug field in DB
This commit removes the slug field from
the database, now the nullable migration is in
production and the field has been remove from
the code deployed.

FIXES #505
2024-11-20 17:10:05 +01:00
Quentin BEY dfecb83c0a 🔊(backend) update logger config to get info
This sets the default logging level to INFO.
This will help to see what actually happens
in our several deployments.
2024-11-20 16:47:43 +01:00
Quentin BEY 8414a7af4d 🔧(helm) add missing OIDC setting
This setting is mandatory to be able to provision
Organization using their SIRET
2024-11-20 16:36:17 +01:00
Sabrina Demagny 33b364d386 🔖(minor) release version 1.6.0
Update all version files and changelog for minor release.
2024-11-20 14:37:20 +01:00
Laurent Bossavit b7f61e73c2 🐛(dimail) pin dimail-api version to fix main branch
Pin dimail-api version to fix main branch temporarily…
2024-11-20 14:01:07 +01:00
Sabrina Demagny a8e3d8d20e 🔥(teams) remove all search by trigram
Remove trigram search for team access and contact
2024-11-19 23:39:57 +01:00
Laurent Bossavit 43c18cb4e6 (version) convey version information to the /config endpoint and footer
We add the machinery to get version information and display it discreetly.
2024-11-19 18:24:57 +01:00
Laurent Bossavit bbe8f32b96 👷(build) create version.json files on both backend and frontend on push
This supplements the release process. We inject Github metadata into two
version.json files; the 'version' value will depend on the type of event,
for release tag events it should be the same as the release tag (i.e. the
app version). This should make version information available to the /config
endpoint on any push, and the frontend should display the backend version.
(For extra safety we will also want to get the frontend version and display
that, but this commit only supplies the barest necessities.)
2024-11-19 18:24:57 +01:00
Marie PUPO JEAMMET 1e025f034f 🔥(terraform) remove legacy terraform and OpenStack references
Some outdated references to Terraform and OpenStack were missed during
the project quickstart. These are legacy elements inherited from OpenFun.

This commit cleans up the codebase.
2024-11-19 14:04:17 +01:00
renovate[bot] 2f7449f727 ⬆️(dependencies) update js dependencies 2024-11-19 13:51:37 +01:00
Marie PUPO JEAMMET 863c85e3f0 👔(dimail) allow creation of "pending" mailboxes
Previously, mailbox creation was restricted to "enabled" domains.
We now allow users to create mailboxes on pending and failed domains.
Mailboxes thus created have the "pending" mailboxes status.
2024-11-19 10:29:21 +01:00
renovate[bot] 28a972e19e ⬆️(dependencies) update python dependencies 2024-11-18 11:57:28 +01:00
Quentin BEY 90a3e26c7f ♻️(features) rename "TEAMS" flag
To match recent changes we rename the "TEAMS" feature flag
to "TEAMS_DISPLAY".
2024-11-15 10:11:50 +01:00
Quentin BEY 59f3499799 (e2e) add specific accounts for testing
This creates a bunch of accounts with various profiles
to allow testing in a specific "mode"
2024-11-15 10:11:50 +01:00
Nathan Panchout 6123e11dd4 (frontend) use user abilities to show or not the features
use the abilities to show or not the Teams / Mailbox features as well as the
object creation button
2024-11-15 10:11:50 +01:00
Quentin BEY 6be1b63277 🔧(backend) disable contact/teams/mail in prod
We don't want to make these features available for everyone.
2024-11-15 10:11:50 +01:00
Quentin BEY ac853299d3 (backend) add user abilities for front
This allows, on a per user basis, the display of
features.

The main goal here is to allow Team admin or owner
to see the management views.
We also added the same for the two other features
(mailboxes and contacts)

This will be improved later if needed :)
2024-11-15 10:11:50 +01:00
renovate[bot] 4d3097e322 ⬆️(dependencies) update python dependencies 2024-11-14 19:05:05 +01:00
Laurent Bossavit a10f65a51f 💚(ci) call the Dimail container by its actual name (and port)
So that E2E tests in Github Actions can connect to Dimail container.
Previously we were attempting to connect as if from the outside. But
the E2E process is in fact inside the Docker Compose network.
("The tests came from inside the house !")
https://tvtropes.org/pmwiki/pmwiki.php/Main/TheCallsAreComingFromInsideTheHouse
2024-11-14 18:19:55 +01:00
Laurent Bossavit 33e05f7a2d 💚(ci) also save Dimail logs from E2E test runs
To help debug with Dimail interop, save logs from the Dimail container.
Also fix the tests' expectations…
2024-11-14 18:19:55 +01:00
Laurent Bossavit 1e45f1ffd1 (dimail) fix domain creation request to fit latest dimail
Adapt domain creationg request to latest protocol version, also
make error reporting more robust: don't assume utf-8 but use the
response's encoding, don't assume the error is JSON (it won't be
when getting a 500) but reproduce the whole thing instead.
2024-11-14 18:19:55 +01:00
Laurent Bossavit 9c894bdbe9 (dimail) add basic credentials to Develop environment
Make testing easier in a local environment by adding Test credentials.
2024-11-14 18:19:55 +01:00
Laurent Bossavit 20a8edd3aa (dimail) add dimail as a container dependency
Start the Dimail container (in CI and local testing) when starting
the app. The pull_policy should have no effect on CI (because it starts
from a blank slate) but ensure we test against the most recent version
of the chosen tag.
2024-11-14 18:19:55 +01:00
Marie PUPO JEAMMET 0b0b77cead 🐛(dimail) fix unexpected status_code for proper debug
Remove duplicate and catch errors more gracefully. Fixes tests accordingly.
2024-11-14 18:19:55 +01:00
Marie PUPO JEAMMET 21bf431940 (dimail) send domain creation request to dimail
Send domain creation request to dimail when someone creates a domain in people.
2024-11-14 18:19:55 +01:00
Sabrina Demagny 8f30264445 🔖(minor) release version 1.5.0
Update all version files and changelog for minor release.
2024-11-14 15:42:54 +01:00
Laurent Bossavit bde91d55da (ci) separate security scan for frontend too
Separate security scan from build-and-push, so we can make it optional
in CI; this was the case for the backend but frontend was overlooked…
2024-11-13 15:02:50 +01:00
renovate[bot] a328e16e53 ⬆️(dependencies) update js dependencies 2024-11-12 14:48:33 +01:00
Marie PUPO JEAMMET edde9c8d15 (dimail) synchronize mailboxes from dimail to our db
Synchronize mailboxes existing on dimail's api and not on our side,
on domains we are administrating.
2024-11-08 16:40:06 +01:00
Sabrina Demagny a18f06ed27 🐛(mail) fix button display on outlook
In confirmation email of mailbox creation,
button "Go to La Messagerie" disappears on Outlook.
We try to fix here this display bug.
2024-11-08 16:32:18 +01:00
Quentin BEY 72abe04c72 🗃️(teams) remove slug field
After some reflexion, the use of a slug field raises to many
problems without being really needed.

One problem is the slug is made from the group name, but we
don't have unicity on this, so a user might be blocked without
any clue.

We also want to allow group names to be reused (which is already
allowed except for the automatic slug).

The unique ID that will be shared with Service Providers will be
the PK/UUID.
2024-11-06 18:10:02 +01:00
Jacques ROUSSEL 79e92214ab 🔐(secret) add qbey age key
Welcome qbey, allow to run tilt locally
2024-11-06 14:45:08 +01:00
Quentin BEY e5f1151f58 🔧(helm) update settings after previous commit
This adds `siret`to the requested OIDC scopes.
This defines a validator for the Organization
registration ID, to enforce SIRET format.
2024-11-06 14:45:08 +01:00
Quentin BEY ca886c19b0 👔(backend) add Organization model
We introduce the Organization model has a "hat" for all
users and team.

Each User must have a "default" organization.
Each Team must have an organization.

When a User creates a new Team, the team is linked to their
default Organization.

For now the Organization should not be visible to end users
this is a purely technical aspect as it.

The models are also adding a permission to allow User to edit
an Organization, but for now there are no endpoints for that.

Next steps:
- Add an Organization to each User and Team on all environments
  to mark Organization as mandatory in database.
- Add scope to Organization to list the Service Provider list
  allowed for a User in an Organization.
- Add endpoints + frontend to manage Organization's scopes
2024-11-06 14:45:08 +01:00
Laurent Bossavit b602478406 ⬆️(dependencies) remove unneeded dependencies
Remove url-normalize as it is not referenced anywhere
2024-11-05 16:41:13 +01:00
Laurent Bossavit 8b0f942f9b ⬆️(dependencies) update js dependencies
Get react-aria-components only from Cunningham to avoid version clashes
2024-11-05 16:12:42 +01:00
renovate[bot] bd6fe584c6 ⬆️(dependencies) update js dependencies 2024-11-05 16:12:42 +01:00
Laurent Bossavit 821db276bc (ci) add security scan
Separate security scan from build-and-push, so we can make it optional in CI
2024-11-05 15:21:02 +01:00
Jacques ROUSSEL 639490d41e 🚑️(backend) fixe CVEs in backend image
Use alpine version for production image instead of debian in order
to have less CVEs.
2024-11-05 15:21:02 +01:00
Jacques ROUSSEL 748e24cab6 🚑️(frontend) fixe CVEs in frontend image
Use alpine version for production image instead of debian in order
tohave less CVEs.
2024-11-05 15:21:02 +01:00
Jacques ROUSSEL 55c0815c31 (ci) add security scan
Add a security scan for CVE with trivy
2024-11-05 15:21:02 +01:00
Marie PUPO JEAMMET 989239082e (tests) test dimail setup command
Test (and slightly improve) dimail setup command,
which populate database on local dimail container
2024-11-05 11:22:02 +01:00
renovate[bot] 988a091e53 ⬆️(dependencies) update python dependencies 2024-11-04 10:13:48 +01:00
Nathan Panchout 20a19d36b5 ♻️(frontend) delete infinite scroll
In order to be in agreement with the back, we must modify the front
so as to no longer have infinite scroll in the team part.
It is also necessary to modify the tests accordingly
2024-10-31 17:59:14 +01:00
Sabrina Demagny 7d695ab81c 🔥(teams) remove pagination of teams listing
For frontend pagination is useless for teams,
so we remove it.
2024-10-31 17:59:14 +01:00
Nathan Panchout a42e7a10db (frontend) adapt test with new list
After the modifications to the back, some adjustments are expected
on the front side to remove the random aspect of the user search
2024-10-30 19:32:46 +01:00
Sabrina Demagny ad4065e682 🚧(demo) modify data set to fix e2e tests
This part needs more refactoring.
Demo data set needs to be no totally random.
2024-10-30 19:32:46 +01:00
Sabrina Demagny ababcde0d6 🔥(teams) remove search users by trigram
This feature is not necessary for our users now
and we got some strange results so we decided
to remove this feature.
2024-10-30 19:32:46 +01:00
Sabrina Demagny faf8dcc7e5 (teams) register contacts on admin views
Allow to manage contact on admin interface
2024-10-30 15:33:29 +01:00
renovate[bot] 2bbed8de0c ⬆️(dependencies) update python dependencies 2024-10-30 11:31:12 +01:00
Laurent Bossavit a736a9143f 💚(ci) use list reporter in addition to html
This will output lines to the testing jobs' logs so we know how many
tests are run even when jobs are eventually cancelled.
2024-10-30 09:35:49 +01:00
Laurent Bossavit c4ea62dc1f 💚(ci) improve E2E tests
Disable retries and save trace for failed tests.

💚(ci) preserve server logs

Save server logs to the same place as Playwright reports to aid debugging.

💚(ci) move back to 1 worker on CI

At least three reasons
- seems redundant with sharding
- strong suspicion it's the reason for the ValidationError issue
- that way the comment no longer tells a lie ;)

💚(ci) improve E2E tests

Log into CHANGELOG to ensure the new test results impact PR status 🤷

💚(ci) make dummy data creation more robust

This is a QR (Quick Response) fix for the failures in the "Add dummy
data" step in E2E testing. Proper QC (Quality Control) needs a bit
more thought.
2024-10-29 14:58:25 +01:00
Laurent Bossavit 1d1f5cfbb6 🚨(linter) add missing docstrings
Title says all there is to say…
2024-10-29 09:08:35 +01:00
Laurent Bossavit 3934a0bc28 🚨(linter) configure exclusions
See PR for rationale.
2024-10-29 09:08:35 +01:00
Laurent Bossavit fd3ac00ea7 🚨(linter) add D1 pydocstyle rule
See PR for rationale.
2024-10-29 09:08:35 +01:00
Quentin BEY 2d46b7d504 🔧(backend) fix sentry deprecated scope
`sentry_sdk.configure_scope` is deprecated and will
be removed in the next major version.
2024-10-25 15:15:39 +02:00
Quentin BEY 54e5be81e2 🔇(backend) remove Sentry duplicated warning/errors
The `DockerflowMiddleware` job is to add logs, sadly the
Sentry LoggingIntegration also catch these logs (at
level WARNING and above) which results in an extra alert
for the same purpose (the exception + the logger.error).

see https://github.com/mozilla-services/Dockerflow/blob/main/docs/mozlog.md

The fix is to ask Sentry to ignore this specific loggger
(`request.summary`).
2024-10-25 15:15:39 +02:00
Marie PUPO JEAMMET 31944e8083 🔧(backend) restore Sentry default integrations
This reverts 30229e11f9
2024-10-25 15:15:39 +02:00
Sabrina Demagny d1421dbe8c ✏️(scripts) add some details for manual steps
Some instructions are missing to explain manual steps
2024-10-24 17:27:07 +02:00
Marie PUPO JEAMMET c8800259ae 🐛(changelog) move e2e sharding to unreleased
e2e sharding was merged without updating it to unreleased. Fixes that
2024-10-24 17:11:29 +02:00
NathanPanchout bfc2462103 👷(ci) add sharding e2e tests
e2e tests take too long to run. We can easily reduce this time by 2 by adding
shards
2024-10-24 16:35:07 +02:00
Sabrina Demagny 230ed75d8d 🔖(patch) release version 1.4.1
Update all version files and changelog for patch release.
2024-10-23 22:55:56 +02:00
NathanPanchout 5ac3454c5a 🚑️(frontend) fix MailDomainsLayout
The content is not scrollable. We add an auto overflow to solve this problem
2024-10-23 21:44:28 +02:00
Sabrina Demagny bbac34b62a 🔖(minor) release version 1.4.0 (#483)
Update all version files and changelog for minor release.
2024-10-23 19:15:14 +02:00
Sabrina Demagny 38508c272e 📝(CHANGELOG) fix modification declaration
One of unreleased modification declaration was
declared in a wrong place
2024-10-23 18:46:04 +02:00
Sabrina Demagny 10a81f14e6 ✏️(mail) fix typo in mailbox creation email
Whitespace missing in french translation after ':'
2024-10-23 17:56:40 +02:00
Nathan Panchout a08689a64d (frontend) add tabs for mail domain page (#466)
Currently, it is complicated to understand the navigation between mailbox
management and role management for an email domain.
This is why we add tabs with explicit naming
2024-10-23 17:45:42 +02:00
Marie PUPO JEAMMET 30229e11f9 🐛(sentry) fix duplicated sentry errors
errors were sent to sentry twice
2024-10-22 18:03:01 +02:00
Sabrina Demagny d11d1f3bd0 🐛(script) improve and fix release script
Use regex to replace version and add missing
frontend update version.
2024-10-22 00:28:08 +02:00
renovate[bot] 6a22169dae ⬆️(dependencies) update python dependencies 2024-10-21 12:22:49 +02:00
Marie PUPO JEAMMET 6e7a6e9d51 🔖(patch) release version 1.3.1
Update all version files and changelog for patch release.
2024-10-18 14:49:56 +02:00
Marie PUPO JEAMMET 95fb476041 🔖(minor) release version 1.3.0
Update all version files and changelog for minor release.
2024-10-18 11:53:36 +02:00
Sabrina Demagny 6b4ea1a2e7 💄(mail) improve mailbox creation email
Remove useless icons, modify text
and improve displaying.
2024-10-15 13:34:03 +02:00
Sabrina Demagny be55d6ea09 📝(CHANGELOG) fix some modifications declaration
Some unreleased declarations was declared in a wrong place
2024-10-15 12:55:27 +02:00
Nathan Panchout 9c9216bb51 (frontend) show version number in footer (#461)
In order to see the version number pushed into production
2024-10-14 16:54:21 +02:00
Sabrina Demagny 017f52a0dc (api) add RELEASE version on config endpoint
Add release version deployed to config endpoint
in order to display release info in La Régie footer.
2024-10-14 14:57:28 +02:00
Quentin BEY d635c484ae 🛂(backend) do not duplicate user when disabled
When a user is disabled and tries to login, we
don't want the user to be duplicated,
the user should not be able to login.

Fixes https://github.com/numerique-gouv/people/issues/455
2024-10-11 16:19:18 +02:00
Marie PUPO JEAMMET 0e48bc0f90 🛂(backend) match email if no existing user matches the sub
Some OIDC identity providers may provide a random value in the "sub"
field instead of an identifying ID. In this case, it may be a good
idea to fallback to matching the user on its email field.
2024-10-11 15:24:53 +02:00
Sabrina Demagny f243a2423f (backend) manage roles on domain admin view
Allow to manage mail domain roles on mail domain admin interface
2024-10-10 16:09:37 +02:00
Marie PUPO JEAMMET 763eb254a8 📝(dimai) update docs on dimail
update README and docs describing interoperability between people and dimail

Co-authored-by: Laurent Bossavit <216452+Morendil@users.noreply.github.com>
2024-10-09 10:59:07 +02:00
Marie PUPO JEAMMET 9b613e63a9 🔧(sentry) reduce trace
we reduce trace to reduce spamming on sentry
2024-10-08 14:35:40 +02:00
Nathan Panchout 6b7f00651f 🐛(frontend) Bug manage access role (#449)
* 🐛(frontend) update access role

Currently, when you click on modify a role, the roles displayed are not the
correct ones. We always took the role of the connected user.
Additionally, there was a warning and a ban on changing roles if you were the
last owner, but it was decided that it was the backend that was throwing the
error and the action was allowed.

* (frontend) adapt tests

We adapt the tests. Remove the check if you are the last owner and use the new
props inside the relevant tests
2024-10-08 10:02:15 +02:00
renovate[bot] fe249c5b6f ⬆️(dependencies) update python dependencies 2024-10-07 17:56:01 +02:00
Marie PUPO JEAMMET 579657afa4 🐛(dimail) add status code in case of unexpected error
add status code in case of unexpected error
2024-10-04 11:32:49 +02:00
Marie PUPO JEAMMET ce21a7552b 🔖(patch) release version 1.2.1
Update all version files and changelog for patch release.
2024-10-03 16:02:00 +02:00
Jacques ROUSSEL 4a63a26135 🔐(secret) bump secret
Bump secret in order to use a new djangoSecretKey
2024-10-03 12:16:28 +02:00
Marie PUPO JEAMMET 8e4472befc 🐛(helm) fix dimail api url
wrong value in dimail api url was causing prod to not work
2024-10-03 12:16:28 +02:00
daproclaima 315a6ab931 (frontend) add mail domain access management
- access management view is ready to use
get, patch and delete requests once backend
is ready. How to create accesses with post
will come later in a future commit.
- update translations and component tests.
- reduce gap between mail domains feature
logo and mail domain name in top banner
2024-10-02 17:19:25 +02:00
daproclaima 2894b9a999 🚚(frontend) reorganize mail domains feature
- separate feature into domain, mailboxes
as a member management feature is arriving in
the mail domains feature
- pages/mail-domains/[slug].tsx becomes
pages/mail-domains/[slug]/index.tsx
2024-10-02 17:19:25 +02:00
Jacques ROUSSEL aea15292ee 🔧(mail) use new scaleway email gateway
We modify multiples things :
* settings.py in order to manage the new way to send email with the
scaleway gateway
* helm template to manage new mandatory secret
* helm configuration for staging/preprod/production
2024-10-02 17:05:17 +02:00
Marie PUPO JEAMMET de46a50e8d 🔖(minor) release version 1.2.0
Update all version files and changelog for minor release.
2024-09-30 17:38:44 +02:00
Sabrina Demagny e6ed3c3be2 (backend) domain accesses delete API
Allow to delete a access for a domain.
2024-09-30 17:09:27 +02:00
daproclaima f04c8bc6aa 🥅(frontend) catch new errors on mailbox creation
- catch errors related to MAIL_PROVISIONING_API_CREDENTIALS
introduced in commit #ba30b1d3eec73718add6585f30c6b7959cb21850.
Intentionally does not parse the error
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
as it means the user is neither admin or owner of the domain and
should not access the mailbox creation form
- update translations and component tests
2024-09-30 15:31:26 +01:00
Marie PUPO JEAMMET ae05b430db 🚨(pylint) fix linting error introduced by new pylint version
pylint version v3.3.1 added a new error and broke our CI
2024-09-30 13:06:39 +02:00
Marie PUPO JEAMMET c98d173c0a ✏️(makefile) fix typo in makefile, for dimail setup
make boostrap command failed because of wrong naming of
dimail setup command
2024-09-30 13:06:39 +02:00
renovate[bot] dddc281778 ⬆️(dependencies) update python dependencies 2024-09-30 13:06:39 +02:00
Jacques ROUSSEL bb2279b6d5 🐛(argocd) fix job synchronisation issue
Sometime argocd is locked because of immutable fiels on jobs. The
annotation tells argocd to do a replace to avoid this behaviour
2024-09-30 12:51:36 +02:00
Jacques ROUSSEL 5420b20f43 (ci) add helmfile linter
Add a linter to avoid to merge config that breaks argocd sync
2024-09-30 12:51:36 +02:00
Jacques ROUSSEL fbb2accefb 🔧(backend) fix configuration to avoid different ssl warning
Fix following warning messages :
- You have not set a value for the SECURE_HSTS_SECONDS setting.
- Your SECURE_SSL_REDIRECT setting is not set to True.
2024-09-30 11:27:33 +02:00
renovate[bot] 75008d3e9a ⬆️(dependencies) update js dependencies 2024-09-30 11:11:13 +02:00
Sabrina Demagny c4c3e9de96 (backend) domain accesses create API
Allow to create (POST) a new access for a domain.
Role can be change only to a role available and
depending to the authenticated user.
2024-09-27 17:55:15 +02:00
Sabrina Demagny 00816e097c (backend) domain accesses update API
Allow to update (PUT, PATCH) an access.
Role can be change only to a role available
depending to the authenticated user.
2024-09-27 16:09:15 +02:00
daproclaima 976c955126 ️(frontend) update left nav panel
- the HTML semantic needed improvement:
the menu items were button wrapped in a li
wrapped in link.
- update related tests
2024-09-27 15:29:18 +02:00
Marie PUPO JEAMMET 2967f1bff2 🐛(dimail) fix mail notification upon mailbox creation
Link to the webmail was missing from notification email.
This fixes html for link to correctly appear.
2024-09-27 12:20:56 +02:00
Marie PUPO JEAMMET a15d548ac4 🐛(dimail) remove trailing slash to lighten dimail's calls/logs
Remove trailing slash on mailbox creation request
to remove 307 redirects
2024-09-26 20:53:25 +02:00
Marie PUPO JEAMMET 7c6b3307fa (dimail) fix logger response in unauthorized response
a test was broken because of logger not logging errors
2024-09-26 20:53:25 +02:00
Marie PUPO JEAMMET 5ded297df6 (mailbox) send new mailbox confirmation email
send mailbox information upon creating a new mailbox
2024-09-26 20:53:25 +02:00
daproclaima b69ce001c8 (frontend) allow group members filtering
- add title above the list of members
- allow members filtering  with an input
- updates translations and related tests
- add global useDebounce hook to prevent
spamming API with useless requests on
inputs sending requests on value change
- add component and e2e tests
2024-09-26 18:09:31 +02:00
daproclaima c0cd136618 ♻️(frontend) improve CardCreateTeam component
- remove link wrapping cancel button and
tabIndex=-1 to button
2024-09-26 17:20:58 +02:00
daproclaima ee5a785d43 🥅(frontend) improve add member form error handling
- translate known errors, including already existing
group error, and directly display the other ones
- add component tests
- update translations
- add parseAPIError, a reusable function to
catch errors on the whole frontend app

Closes issue #293

♻️(frontend) improve general error catching

- change parseAPIError to make it reusable on all
requests
- update components depending on it
2024-09-26 17:20:58 +02:00
daproclaima 3d7dfa019c ⚗️(frontend) show username on AccountDropDown
- show username instead of "My account"
- udpate translations and tests
2024-09-26 16:32:18 +02:00
renovate[bot] cc9303bc9c ⬆️(dependencies) update js dependencies 2024-09-24 20:37:13 +02:00
Jacques ROUSSEL 34341e6f74 🔒️(helm) fix secret sync precedence
When new secret is added to backend secret, it's not sync at the
beginning of argocd synchronisation and jobs are blocked. Theses new
annotations fix this issue.
2024-09-23 12:52:04 +02:00
Marie PUPO JEAMMET 01abc66e59 (dimail) allow la regie to request a token for another user
allow la regie to request a token for another dimail user,
to better track who created/modified which ressource.
2024-09-20 17:21:31 +02:00
Marie PUPO JEAMMET 55d7e846d8 ♻️(serializers) move dimail calls to serializers
we move all business logic from model to serializer.
all API calls (direct and from front) will keep on triggering
expected 3rd party calls while admin actions will uniquely trigger
modifications in our database.
2024-09-20 14:20:22 +02:00
Marie PUPO JEAMMET 232ea97d87 (dimail) populate dimail local database for dev use
this commit adds a script and 'make' command to populate dimail database
with basic objects: an admin account, a regie account, a domain
and an owner for this domain.
2024-09-19 15:10:35 +02:00
renovate[bot] a7afa68afa ⬆️(dependencies) update next to v14.2.10 [SECURITY] 2024-09-19 10:06:09 +02:00
renovate[bot] 932ffb3bcb ⬆️(dependencies) update js dependencies 2024-09-19 09:54:40 +02:00
Jacques ROUSSEL d0254ce963 🔧(helm) bump secret
Bump secret to use the email provisionning secret
2024-09-18 19:02:34 +02:00
renovate[bot] cd1dcf11d5 ⬆️(dependencies) update python dependencies 2024-09-17 19:01:23 +02:00
Marie PUPO JEAMMET 59468aaa12 🍱(dimail) embark dimail-api as container
Embark a dimail-api container, automatically fetched from
their repository, to ensure our "bridge" to dimail-api
is up-to-date when developing.
2024-09-17 18:34:39 +02:00
Sabrina Demagny dd8bd2a89b (backend) domain accesses list API
Add an endpoint to list all accesses created for a domain
Return all roles available to set for each access depending to
the authenticated user.
2024-09-17 17:13:46 +02:00
rouja cc86a3bd61 🔧(helm) add marie key to dev sops secret (#394)
Marie's key was accidentally removed in last commit.
Add her key and restore .sops.yaml file, to easily add keys
for local dev secrets decryption/encryption.
2024-09-11 16:59:22 +02:00
Marie PUPO JEAMMET 7f31a2b820 🔖(minor) release version 1.1.0
Update all version files and changelog for minor release.
2024-09-10 17:20:05 +02:00
Jacques ROUSSEL aaca8819b3 🔧(helm) fix mail provisioning env
I forgot to use the new variable on the deployment.
2024-09-10 16:50:14 +02:00
Sabrina Demagny 77cc64a6c7 (release) add python script to prepare release
Change versions, update changelog and create branch and commit
to submit release informations.
Give following instructions to do after.
2024-09-10 15:30:30 +02:00
Marie PUPO JEAMMET aaad48480a (dimail) fix tests still waiting for domain.secret
For unknown reasons, these tests were forgotten and are still
refering to this 'secret' field, removed in last commit.
2024-09-10 15:29:19 +02:00
Marie PUPO JEAMMET 00dafd4b15 ♻️(dimail) separate headers request from mailbox request
I want to separate headers request form mailbox request,
as we were previously catching the same errors twice.
It should be clearer now.
2024-09-10 15:29:19 +02:00
Jacques ROUSSEL 864702d0ee 🔧(helm) add secret for email provisionning
Add email provisionning secret to be abble to provision email
2024-09-09 18:18:00 +02:00
Marie PUPO JEAMMET 29904ef7b6 (tests) update tests to look for dimail secret in settings
Update back-end tests to match 'secret' field being moved to settings.
2024-09-09 18:18:00 +02:00
Marie PUPO JEAMMET ba30b1d3ee 🗃️(models) remove 'secret' field from mailbox model
We remove 'secret' field, as it won't be of use in interactions
between la Régie and dimail. Régie credentials will be stored and used
as project variable.
2024-09-09 18:18:00 +02:00
renovate[bot] 9503b073b6 ⬆️(dependencies) update python dependencies 2024-09-09 15:01:53 +02:00
daproclaima e4aed82ff2 🥅(frontend) improve error catching in forms
- rename CreateMailboxForm into ModalCreateMailbox,
and useCreateMailDomain into useAddMailDomain
- use useAPIError hook in ModalCreateMailbox.tsx and ModalAddMailDomain
- update translations and tests (include removal of e2e test able
to be asserted by component tests)
2024-09-09 12:43:29 +02:00
daproclaima 25898bbb64 🥅(frontend) handle api errors
- add hook to handle api errors.
- add related component tests
2024-09-09 12:43:29 +02:00
daproclaima b5d8e92d1e 🐛(frontend) fix mail domain creation form
- allow to submit form by pressing "Enter" key
- force focus on form when form is submited
but is invalid
- add error 500 handling
- update related e2e tests
2024-09-09 12:43:29 +02:00
Marie PUPO JEAMMET 237d64b4c5 ♻️(dimail) refacto to better handle 500 errors from dimail
simple refacto to catch all 500 errors, including when
asking for new token.
2024-09-09 12:24:48 +02:00
daproclaima 0d157d3f2b ️(frontend) improve keyboard navigation on modals
- create a temporary Modal component to apply it
a function tracking document update and set
modal elements we want to ignore a tabindex=-1.
- add component tests
- temporary fix. Better to apply them on
cunningham directly
2024-09-09 11:58:37 +02:00
daproclaima d291e55a9e 🐛(frontend) fix mail domain addition form
- remove link wrapping cancel button
2024-09-09 11:58:37 +02:00
daproclaima e7aebfe59e 🐛(frontend) keyboard navigation language picker
- add temporary fix to language picker to
ignore select on keyboard navigation. Needs
to be fixed directly in Cunningham Select
- update related e2e test
2024-09-09 11:58:37 +02:00
daproclaima bd6cd59df6 💬(frontend) change add mail domain text
- update translations and tests
2024-09-09 11:41:34 +02:00
daproclaima 874ce18134 💬(frontend) fix group member removal text
- update FR translation
2024-09-09 11:41:34 +02:00
Marie PUPO JEAMMET 4fe74733a5 📈(monitoring) configure sentry dsn
configure sentry dsn to monitor errors in sentry
2024-09-05 16:16:05 +02:00
Marie PUPO JEAMMET 4b47f80cab 🚨(tests) fix obsolete code warnings
- in docker compose, remove obsolete 'version' field
- in django, replace obsolete CheckConstraints 'check' field by 'condition'
2024-09-05 14:57:32 +02:00
Marie PUPO JEAMMET ba631fafb9 🐛(dimail) improve handling of dimail errors on failed mailbox creation
dimail is called twice when creating a mailbox (once for the token,
and once for the post on mailbox endpoint). we want to clarify
the status_codes and messages of each error to inform user and ease debug
2024-09-05 14:57:32 +02:00
renovate[bot] ce15e8a3ed ⬆️(dependencies) update python dependencies 2024-09-05 12:00:53 +02:00
Marie PUPO JEAMMET 55dc342a8b 🔨(demo) add domains creation to demo
Add domains creation to "make demo" rule. Update related test.
2024-09-03 16:11:23 +02:00
renovate[bot] 05c8f636dd ⬆️(dependencies) update js dependencies 2024-09-02 09:41:24 +02:00
Sabrina Demagny 675ba4b557 🔖(patch) release version 1.0.2
Update all version files and changelog for release patch.
2024-08-30 18:03:26 +02:00
Marie PUPO JEAMMET f5c7abcd20 🗃️(mailboxes) migrate local part field modification
"+" character was removed from valid character for mailbox creation
this commit adds migration file for database to be up-to-date
2024-08-30 14:35:58 +02:00
lebaudantoine 8c54e701c1 🔧(helm) configure resource server in staging
Allow desk-staging app to interact with AgentConnect integration env
to introspect a received access token.

Other environment (pre-prod and production) will be configured when
at least one interconnection with a service provider has been
validated.
2024-08-30 13:12:25 +02:00
Anthony LC ff2cbe3aed 🔥(CI) remove crowdin from CI workflow
Pushing to crowdin from the workflow has some side
effects, if 2 branches are pushing to crowdin it
can cause conflicts and delete translations on
Crowdin side.
Better to push to crowdin manually to keep good
control over the translations.
2024-08-30 10:42:43 +02:00
Anthony LC 7f3f25c61d ⬆️(i18n) i18next-parser to 9.0.2
i18next-parser had a compatibility issue with
a dependency (cheerio). The last version
fixed this issue, plus fixed another issue
about a configuration problem.
We can now remove it from the renovate ignore list.
2024-08-30 10:42:43 +02:00
Anthony LC bd36983e3c (frontend) add @typescript-eslint/parser
In order to work correctly
@typescript-eslint/eslint-plugin requires
@typescript-eslint/parser to be installed as well.
We fixed the linting issues related to the
upgrade.
2024-08-30 10:42:43 +02:00
renovate[bot] e8cc6a6f23 ⬆️(dependencies) update js dependencies 2024-08-30 10:42:43 +02:00
lebaudantoine 9adb2d4cf4 📝(backend) draft a minimal documentation on resource server
I've committed few of my notes for my future myself, helping me to
run locally the stack. These notes are raw, I'll be more than happy
to refine them when I got sufficient time. Please feel free to ping
me if you have any question.
2024-08-29 11:39:08 +02:00
lebaudantoine bb5058c478 🔊(backend) enable debug logs in development
Enhance developer experience, unmuting some debug information,
that weren't taken into account.
2024-08-29 11:39:08 +02:00
lebaudantoine 78818ba541 🩹(backend) enable resource server authentication if properly configured
Tests are missing, let's ship it, I'll open an issue.

Without such protection, the whole app would crash if the resource server is
not configured. The fallback backend would return an appropriate error to
the client if the resource server is improperly configured.
2024-08-29 11:39:08 +02:00
lebaudantoine f1a2b7c603 (backend) authenticate requests using an access token issued by AC
Overload mozilla-django-oidc class to support an authentication method
with the resource server backend.

This enables any route of the API to be called with an access token
issued by Agent Connect.
2024-08-29 11:39:08 +02:00
lebaudantoine 5634a7f390 (backend) add resource server backend
Why:

Many services in La Suite rely on Agent Connect to authenticate their users.
Delegating  authentication to Agent Connect is highly beneficial. With a central
party (Agent Connect)  handling user authentication, our services can seamlessly
communicate with each other.  Our backend must be able to receive and verify
access tokens issued by Agent Connect.

Additionally, it should ensure that the resource owner has granted permission
for our  data to the service provider transmitting the access token.

How:

Our backend needs to verify access tokens by introspecting them. This involves
requesting the Authorization Server to validate the access token received in
the authentication header. The Authorization Server validates the token's
integrity, provides authentication and authorization information about
the user currently logged into the service provider requesting data from
the resource server.

The data returned by the Authorization Server to the resource server
is encrypted and signed. To encrypt the introspection token, the Authorization
Server retrieves the resource server's public key from
the new ‘/jwks’ endpoint.

Encryption parameters, such as algorithm and encoding, are configured on
the resource server. Ensure that these parameters match between
the Authorization Server and the resource server.

The resource server verifies the token signature using the Authorization
Server's public key, exposed through its `/jwks` endpoint. Make sure
the signature algorithms match between both servers. Finally, introspection
token claims are verified to adhere to good  practices for handling JWTs,
including checks on issuer, audience, and expiration time.

The introspection token contains a subject (`sub`). The resource server uses
this subject to retrieve the requested database user, compatible
with both pairwise and public subjects.

Important:

Agent Connect does not follow RFC 7662 but uses a draft RFC that adds security
(signing/encryption) to the initial specification. Refer to the "References"
section for more information.

References:

The initial RFC describing token introspection is RFC 7662 "OAuth 2.0 Token
Introspection". However, this RFC specifies that the introspection
response is a plain JSON object.

In eGovernment applications, our resource server requires stronger assurance
that the Authorization Server issued the token introspection response.

France Connect's team implemented a stronger version of the spec, returning
a signed and encrypted token  introspection response. This version is still
a draft, available under:

"draft-ietf-oauth-jwt-introspection-response".
2024-08-29 11:39:08 +02:00
lebaudantoine 9c05167d80 (backend) introduce an authorization server client
In OAuth 2.0, the Authorization Server is equivalent to the OIDC provider.

The Authorization Server exposes endpoints for token introspection and JWKS.
I’ve created a client to easily interact with the Authorization Server,
while doing the token introspection in our resource server.

Token introspection will be explained in upcoming commits.

The current OIDC library used in the project doesn’t offer token introspection,
leading to redundancy in the code handling some OIDC/OAuth2 flows.

This overlap makes the code bulky. My goal is to quickly deliver a working
PoC for the resource server, with plans to refactor in the longer run.

Please feel free to provide feedback on the class design.
2024-08-29 11:39:08 +02:00
lebaudantoine 21371dbd1b (backend) add a '/jwks' endpoint
Introduce a new endpoint, /jwks, which returns a JSON Web Key Set (JWKS).
This set of public crypto keys will be used by external parties to encrypt
data intended for our backend. In the context of the resource server, this key
will be used by the authorization server to encrypt the introspection response.

The current implementation exposes a single public key, with the private key
configurable in the app settings. The private key is represented as a string.
For enhanced security, we might prefer to store this data in a .pem file
excluded from version control.

A few parameters for this key, such as its type and encoding, are configurable
in the settings.

A critique of the current design is its lack of extensibility.
If we decide to offer more than one encryption method, this view will require
refactoring.

Additionally, the current implementation is tightly coupled with joserfc.

This lays the foundation for further improvements.

Please note, this endpoint only public components of the key, there is no
chance for any secret leaking.
2024-08-29 11:39:08 +02:00
lebaudantoine b40aefc505 ✏️(backend) fix minor typo
Found and fixed a minor typo. Nit-picking!
2024-08-29 11:39:08 +02:00
lebaudantoine 591b3eedff 🏗️(backend) create a new python package for the resource server
Encapsulate all Resource Server (RS) sources in a dedicated python package.

Resource server belongs to the Oauth2 ecosystem, please find informations
here https://www.oauth.com/oauth2-servers/the-resource-server/
2024-08-29 11:39:08 +02:00
lebaudantoine 09cb7ff6f1 (backend) add a JOSE dependency
We will add a JWKs endpoint to the application
and manipulate signed/encrypted Json Web Token (JWT).

Project lacks tooling for JSON Object Signing and Encryption (JOSE)
manipulations. After a quick benchmark, 'joserfc' has been chosen
as the dependency to add.

joserfc is a Python library that provides a comprehensive
implementation of several essential JOSE standards.

Please find the benchmark:

- Cryptography: Although using only cryptography is feasible, its
  interface/API is not as user-friendly.

- pyjwt: While pyjwt is popular, it lacks support for JWK and JWE objects,
  which are essential for the requirements.

- python-jose: The latest release of python-jose was in 2021, and the
  project seems less active compared to other alternatives.

- Authlib: Authlib is the second most popular library after pyjwt and seems
  modern with an active community. However, the parts relevant to the use case
  were extracted into a relatively new package named joserfc.

- joserfc: Although joserfc has fewer stars compared to Authlib, it was
  extracted from Authlib, which has more than 4k stars, indicating a solid
  foundation.

While the low star count of joserfc might raise concerns about its stability, it
is believed to be worth considering its addition. Adding Authlib and refactoring
later, once they finish migrating to joserfc, is also a possibility
2024-08-29 11:39:08 +02:00
Marie PUPO JEAMMET 87966fa062 🐛(backend) fix dependencies conflicts
Upgrading Django to 5.1 created a severe issue, breaking
mail template-associated features.

The issue originated from the third party 'easy_thumbnail'.
Please refer to the issue #641 on Django's repo. This is
the suggested workaround by @Miketsukami.
2024-08-28 12:21:02 +02:00
renovate[bot] e4dd3395bb ⬆️(dependencies) update python dependencies 2024-08-28 12:21:02 +02:00
Marie PUPO JEAMMET 93681b0030 (mailboxes) remove '+' from valid special caracters
We previously accepted '+' as a special caracter during mailbox creation.
We now remove it, as this caracter has a very special meaning and it wouldn't
make sense to create a mail using it.
2024-08-28 11:17:19 +02:00
Marie PUPO JEAMMET e04a994d37 👽️(mailboxes) fix mailbox creation after dimail api improvement
Latest dimail modification lead to a bug in our app, preventing mailbox creation
from working properly. I swapped old dimail url to new one, mirrored dimail
modification and fixed tests and tada!
2024-08-28 11:17:19 +02:00
Sabrina Demagny ba46d7de54 (users) improve user display on admin users list
If user email exists, display it instead of sub to identify
users in admin view list.
2024-08-28 11:00:34 +02:00
Sabrina Demagny b79b4b1853 (domains) manage domain roles on user admin view
Allow to manage mail domain roles on user admin interface
2024-08-28 11:00:34 +02:00
Laurent Bossavit e3f8633931 (test) fix flaky search test
By making this email address invariant, we remove failures due to FactoryBoy's
random address being considered as a match to our test query
(and hence returning unexpected number of matches).
2024-08-28 10:47:19 +02:00
Anthony LC b84e8b89f7 🔧(helm) deactivate teams feature on productions env
Teams feature is not ready for production yet,
so we need to deactivate it on productions environment.
preprod should be a copy of production,
so we need to deactivate it on preprod too.
2024-08-21 15:13:12 +02:00
Anthony LC 32889b9e8c ♻️(frontend) replace env NEXT_PUBLIC_FEATURE_TEAM
NEXT_PUBLIC_FEATURE_TEAM is a buid-time env
variable, it is not easy to overload it per
environment.
We will use the config endpoint to get the
feature flag at runtime.
To do so, we are using the ConfigStore.
2024-08-21 15:13:12 +02:00
Anthony LC 75647fe289 (frontend) add config provider
Add a ConfigProvider to the frontend to provide
configuration to the app.
The configuration is loaded from the config
endpoint and stored in a zustand store.
2024-08-21 15:13:12 +02:00
Anthony LC 03bfef6061 (backend) add public endpoint /api/v1.0/config/
Add public endpoint /api/v1.0/config/ to
share some public configuration values
with the frontend.
2024-08-21 15:13:12 +02:00
daproclaima 85c789bb1a 🔖(patch) patch release to 1.0.1
- upgrade semver for all package.json, env.d files,
and pyproject.toml
- update CHANGELOG.md
2024-08-20 10:03:11 +02:00
daproclaima 049d8695be ️(frontend) updates links
- remove buttons inside links in panels and
teams index
- update component and e2e tests
2024-08-19 16:04:57 +02:00
daproclaima 49c238155c (frontend) user can add mail domain
- user can add an externally created mail domain
from UI and see the mail domain status on mail
domain page and left panel links.
- user can not create mailboxes to domain if mail
domain status is not equal to `enabled`
- update related tests and translations
2024-08-19 16:04:57 +02:00
daproclaima b79725acbe 💬(frontend) change text in mail domains list
- use more meaningful message
- update translations and e2e tests
2024-08-19 16:04:57 +02:00
Marie PUPO JEAMMET 439ddb9d4a 🔖(major) major release to 1.0.0
🎉! For changelog, see changelog.md
2024-08-09 15:21:42 +02:00
Sabrina Demagny a7a923e790 (mailboxes) manage bad secret sent to dimail API
- manage 403 returned by dimail API when mail domain secret is not valid
- improve some tests
- improve MailboxFactory to mock success for dimail API POST call
- override 403.html to return a nice failing error in django admin
- an error message is displayed on mailbox creation form of frontend
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET 5ed63fc091 (test) add test list mailboxes non existing domain
Test that API raises a 404 when trying to list mailboxes
of a domain that does not exist.
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET f55cb3a813 (mailboxes) add mail provisioning api integration
We want people to create new mailboxes in La Régie.
This commit adds integration with intermediary dimail-api,
which will in turn send our email creation request to Open-Xchange.
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET 2c82f38c59 🗃️(domains) add "secret" field to domains
add a "secret" field to domain model. This secret will be used as
password in mail provisioning API.
2024-08-09 13:37:20 +02:00
Sabrina Demagny 8963f0bb3d (mail) add status on domain create or retrieve API
to display status on frontend
2024-08-08 23:47:49 +02:00
Anthony LC 733a1d8861 🔥(frontend) remove temporarily team feature
We want to make a first realease, but the
team feature is not ready yet.
So we will hide it for now by hiding the menu.
We will still let the feature in dev environment.
2024-08-08 16:29:06 +02:00
daproclaima 7916f7d7d0 (frontend) show names in mailbox table
- show a concatenation of first and last names
for each row in mailbox table
- update related e2e tests
2024-08-08 13:53:07 +02:00
daproclaima 45dbdd6c4c (frontend) restrict mailbox creation
- update mailbox creation feature by introducing the use of
new mail domain ability field to hide or show
mailbox creation button
- update related e2e tests
2024-08-08 13:53:07 +02:00
daproclaima 14deca13f4 🚸(frontend) improve screen reading navigation
- add aria-hidden and empty alt attributes for screen readers
to ignore decorative svg and images.
- remove icon from input field used to name a group
- update translations
- update related e2e and components tests
2024-08-08 12:16:20 +02:00
daproclaima 72340db74c 💬(frontend) update texts
- change message showed in mailbox list when none exist
- change CTA button text for group management modals
- fix 404 page title
- update translations
- update related e2e tests
2024-08-08 12:16:20 +02:00
renovate[bot] d812197381 ⬆️(dependencies) update django to v5.0.8 [SECURITY] 2024-08-08 11:41:14 +02:00
Sabrina Demagny c00c59b301 (mail) add first_name and last_name for Mailbox
Mail provisioning API needs a full name to create a new mailbox.
2024-08-07 15:03:09 +02:00
Marie PUPO JEAMMET 402e73582c (tests) improve tests for mailbox api
Regroup mailbox-related tests
+ add test 404 when trying to retrive a domain that doesn't exist
+ use enabled domains on tests
2024-08-07 10:23:49 +02:00
Sabrina Demagny b637774179 (mail) manage mailboxes permissions
Manage create and list permissions for all roles.
2024-08-06 16:00:00 +02:00
Marie PUPO JEAMMET 87e7d3e0b1 🚚(swagger) move swagger under /api/
Swagger was under /v1.0/swagger.
I just wanna move it under /api/ where the rest of the API is.
2024-08-05 16:49:27 +02:00
Sabrina Demagny eba9fb2d09 🚸(models) use a viewer role for MailDomainAccess
Rename member role to viewer role for MailDomainAccess.
A viewer is only allowed to see list of mailboxes
created for a domain. It makes more sense to name it viewer.
2024-08-05 15:05:15 +02:00
daproclaima e03ecb2d77 ️(frontend) change panel text color
- change text color displayed when panel of teams or mail domains is empty
to create more contrast between text and background colors
2024-08-05 12:42:13 +02:00
daproclaima 7e03d33be0 🚸(frontend) improve keyboard navigation
- add css rules to highlight focused-visible navigable elements
- update drop down components to make it keyboard navigable
- add e2e keyboard navigation tests asserting it navigates through
all focusable elements from top to bottom on groups index view
when one group exists
2024-08-05 12:42:13 +02:00
renovate[bot] 779c7d1e0e ⬆️(dependencies) update python dependencies 2024-08-05 11:05:18 +02:00
Sabrina Demagny b1e1de0269 🧑‍💻(backend) do not allow to delete a domain
At the moment a domain cannot be deleted.
We will be able to delete only pending domains and
simply turn to disabled an enabled domain.
2024-08-02 17:43:16 +02:00
Sabrina Demagny 63dee08be5 🧑‍💻(backend) add missing test on domain
Check the mail domain status after creation;
2024-08-02 17:43:16 +02:00
Anthony LC 582e3f5a05 (github) fix e2e workflow
An update to ubuntu made the command "docker-compose" not
working anymore. This commit fixes the issue.
2024-08-02 17:23:02 +02:00
Marie PUPO JEAMMET df59bfd1ee ⚰️(dead) remove obsolete file
remove obsolete file ... i think ?
It seems setup.cfg has been replaced by pyproject.toml
2024-08-02 12:34:02 +02:00
Marie PUPO JEAMMET c26786a107 🩺(coverage) add config and make rule to compute coverage
Configure pytest-cov settings in pyproject.toml and add make rule
to compute test coverage.
2024-08-02 12:34:02 +02:00
Anthony LC ca179b8811 ⬇️(frontend) downgrade @typescript-eslint/eslint-plugin to 7.13.1
@typescript-eslint/eslint-plugin released the
version 8, but it is causing some issues
(@typescript-eslint/no-duplicate-enum-values).
We downgrade it to 7.13.1 in waiting for a fix.
2024-08-01 10:22:10 +02:00
Anthony LC b413d8a915 ⬇️(frontend) downgrading fetch-mock to 9.11.0
fetch-mock released the version 10, but it is
causing some issues with jest because of
esm support.
2024-08-01 10:22:10 +02:00
renovate[bot] a07be7ecd7 ⬆️(dependencies) update js dependencies 2024-08-01 10:22:10 +02:00
Sabrina Demagny ab54d5af8f (backend) allow to filter member on team access endpoint
Filter member by name...
2024-07-31 16:01:32 +02:00
renovate[bot] 958f48f9e8 ⬆️(dependencies) update python dependencies 2024-07-31 12:22:42 +02:00
Marie PUPO JEAMMET 94b0c27775 📝(readme) update admin credentials in README
README was not correctly updated after last major modif to the admin auth,
resulting in confusion for users trying to log-in with email instead of sub.
2024-07-31 11:41:33 +02:00
renovate[bot] 953eefcb66 ⬆️(dependencies) update python dependencies 2024-07-22 14:34:47 +02:00
renovate[bot] 4f173c65d5 ⬆️(dependencies) update sentry-sdk to v2.8.0 [SECURITY] 2024-07-19 15:49:47 +02:00
renovate[bot] 99d3de6833 ⬆️(dependencies) update requests to v2.32.2 [SECURITY] 2024-07-19 15:15:58 +02:00
daproclaima e0000ca89c 💄(frontend) change elements positions in mailbox creation form
- remove icon on top of mailbox creation form
- change some text positions
- fix fields error style on hover
- update form field names translations
- update related e2e tests
2024-07-16 15:26:59 +02:00
daproclaima 32e6602dda 🚸(frontend) improve mailbox creation validations and error handling
- replace known error causes returned by the API on unsuccessful mailbox
creation requests by meaningful interpolated message shown above the
form
- strengthen form validation rules to be identical as those of
the api endpoint to prevent emitting invalid requests
- designate which form fields are mandatory for accessiblity
- update texts for better ux writting, and their translations
- fix css style input errors
- update related e2e tests.
2024-07-16 15:26:59 +02:00
renovate[bot] cda4373544 ⬆️(dependencies) update django to v5.0.7 [SECURITY] 2024-07-12 19:23:55 +02:00
Sabrina Demagny 8d7614c512 (models) add TeamAccess models on admin view
Declare TeamAccessAdmin
2024-07-10 16:40:48 +02:00
Sabrina Demagny 955a3dd226 (models) add MailDomain status
Add some status to allow to create mailboxes only for an active domain
2024-07-10 16:17:42 +02:00
daproclaima d69861c148 🔥(frontend) remove use of phone number data for mailbox creation
Remove phone number field from mailbox creation form and requests
to comply with GPDR as it is not an information we need to
collect for creating a mailbox. Deletes translations about phone
number field in mailbox creation form. Updates related e2e tests.
2024-07-04 11:31:52 +02:00
Marie PUPO JEAMMET 66300aca66 🧑‍💻(models) improve user str representation
Improve user model str representation to display name or email
if provided. Otherwise, returns sub as last resort.
2024-07-03 17:16:22 +02:00
daproclaima 2598f3649a (e2e) check default lang attribut of html tag
Asserts that default lang attribute of html tag
matches with the language used by default in
browser for supported languages.
2024-07-02 09:19:34 +02:00
daproclaima d3589dfca1 (e2e) check lang attribute on html tag updates
Asserts that lang attribute of html tag updates according to
language chosen by user.
2024-07-02 09:19:34 +02:00
daproclaima dfb5394310 ️(frontend) update html lang on language change
Performs lang attribute update on html tag on every language change.
By default the attribute value uses the i18n.language value.
2024-07-02 09:19:34 +02:00
Anthony LC bea23cc6e9 💬(frontend) change app name to Regie
The app was named Equipes, it is now Régie.
2024-07-01 17:16:47 +02:00
Anthony LC 7c36f1e775 💬(frontend) change text logo Marianne
Change the text of the logo Marianne.
It was "Devise" and now it's "République Francaise".
2024-07-01 17:16:47 +02:00
Anthony LC 8dcd6d5bb8 💄(frontend) fix flickering on datagrid sorting member
The datagrid was flickering when sorting the member list.
This was due to the fact that the list was getting
empty and then filled again with the sorted list
causing the flickering.
2024-07-01 16:56:34 +02:00
Anthony LC 5c87bd74cd 🏷️(frontend) adapt ordering name
Some ordering names changed with a prefix "user__".
We adapt the ordering name in the
frontend to match the backend.
2024-07-01 16:56:34 +02:00
Anthony LC a400c12037 🚚(frontend) change teams feature architecture
Changes the teams feature architecture, centralizing
all the teams-related features inside a teams folder.
2024-07-01 15:18:18 +02:00
Anthony LC 4d1aafe0d9 🐛(helm) fix createsuperuser command
The createsuperuser command changes recently.
We update the command to reflect the changes.
2024-06-28 11:37:56 +02:00
Anthony LC 0e5cbb1fc8 🐛(app-desk) force redirect on mail-domains/[slug]
When we use the static mode, Next.js want that we
build our mail-domains pages at build time.
But we can't do that because users can create a
mail at runtime.

So we redirect thanks to ngnix when we see that
a mail page is not found.
2024-06-28 10:06:26 +02:00
Anthony LC 56df81ef84 🐛(app-desk) force redirect on teams/[id]
When we use the static mode, Next.js want that we
build our team pages at build time. But we can't do that
because users can create a team at runtime.

So we redirect thanks to ngnix when we see that
a team page is not found.
2024-06-28 10:06:26 +02:00
Anthony LC 5c10ed32d5 🔧(app-desk) trailingSlash options when build
Add trailingSlash options to next.config.js
to be able to reach directly a page without adding
".html" at the end.
2024-06-28 10:06:26 +02:00
renovate[bot] ed24beb0f6 ⬆️(dependencies) update djangorestframework to v3.15.2 [SECURITY] 2024-06-27 18:17:46 +02:00
renovate[bot] 928fed9ac7 ⬆️(dependencies) update python dependencies 2024-06-27 18:08:39 +02:00
Samuel Paccoud - DINUM 2ec292bb91 ♻️(models) remove multiple identities
Multiple identities were complicating this project's code.
We moved the management of multiple identities to our
OIDC provider.
2024-06-27 17:45:23 +02:00
Anthony LC 79330015e0 ✏️(frontend) change "mention légale" to "mentions légales"
Change "mention légale" to "mentions légales".
2024-06-27 14:02:44 +02:00
Anthony LC d05bc60f5e 🧑‍💻(i18n) run prettier on format-deploy
The command format-deploy import a new json
from crowdin, but it is not formatted. This
commit run prettier on the file after the import
to format it.
2024-06-26 17:26:41 +02:00
Anthony LC c7861c6ad4 🐛(i18n) fix key that contain colon
Keys that contains colon where not being
translated correctly. This was due to the
colon being used as a separator for the
key and the value. This was fixed by
replacing the colon with a different
character that is not used in the key
or the value.
2024-06-26 17:26:41 +02:00
Anthony LC 952bea369e 🌐(frontend) add french translations
- Add french translations for the footer
- Add french translations for the mail part
2024-06-26 17:26:41 +02:00
Anthony LC 980c6ee1a4 🚸(app-desk) footer legales pages
- Add legales pages.
- Add links to the footer for the new pages.

Legales pages are based from
https://lasuite.numerique.gouv.fr/
2024-06-26 17:26:41 +02:00
Anthony LC 51c4a29751 ♻️(app-desk) change header html structure
Change a bit the html structure of the header
component to be more aligned.
Logo linked to homepage.
2024-06-26 17:26:41 +02:00
Anthony LC 01d72466a6 (app-desk) add footer
Add footer to the desk app.
2024-06-26 17:26:41 +02:00
Marie PUPO JEAMMET 19c36eafde (tests) fix tests after adding slugs to domains
- slug readonly on admin
- fix test to expect slug in payload, when retrieving a domain
2024-06-25 12:37:34 +02:00
daproclaima 93d4abee58 (e2e) update mail domains tests
Updates tests to use mail domain slug instead of id.
2024-06-25 12:37:34 +02:00
Sabrina Demagny bb3403f10f (mailbox_manager) add slug to MailDomain serializer
add missing field to MailDomain serializer after commit b4bafb6
2024-06-25 12:37:34 +02:00
daproclaima 0da30eb2e1 🚧(app-regie) use mail domain slug in mail domains feature
Stop using mailDomain.id in frontend navigation and mail-domains/
requests. Instead, uses mailDomain slug.
2024-06-25 12:37:34 +02:00
Anthony LC f70604df72 🚚(helm) move secret to desk/templates
With the recent changes to the helm chart,
the secrets.yaml file was not found by
Tilt anymore. This commit moves the file
to the correct location.
2024-06-25 11:36:09 +02:00
Anthony LC e75c8d49b3 👷(CI) add production tag to deploy workflow
Add `production` tag to deploy workflow.
Every tag production will trigger
the deploy workflow to production environment.
2024-06-25 11:36:09 +02:00
Anthony LC 4823c8d9dc 👷(helm) prod configuration
Add the prod configuration to the helm chart.
2024-06-25 11:36:09 +02:00
Jacques ROUSSEL 6d0dbf0d13 💚(CI) upgrade submodule
- fix secrets for staging
2024-06-20 14:17:22 +02:00
Jacques ROUSSEL 4048855b1b 💚(CI) upgrade submodule
- upgrade submodule to use new preprod secret
2024-06-19 17:26:52 +02:00
Anthony LC e2a682cae4 ⬇️(frontend) downgrading fetch-mock to 9.11.0
fetch-mock relesed the version 10, but it is
causing some issues in the frontend tests for
the moment. The adoption is still low, the
documentation is not updated, so let's
downgrade it to 9.11.0 for now.
2024-06-19 15:41:20 +02:00
Anthony LC 366f10689b (frontend) add "@testing-library/dom
Recent update of @testing-library/react requires
@testing-library/dom to be installed as well.
2024-06-19 15:41:20 +02:00
renovate[bot] 922719b13e ⬆️(dependencies) update js dependencies 2024-06-19 15:41:20 +02:00
Anthony LC 3c481e75bb 👷(helm) command createsuperuser
We need a superuser in the Django
application, to have access to the admin part.
This commit create a superuser on the pods.
2024-06-19 13:34:15 +02:00
Anthony LC 9a7a8e4a34 🔥(helm) remove uneeded file
secrets.yaml was duplicated in the helm chart,
we can remove this one.
2024-06-18 15:40:33 +02:00
Anthony LC 905b673413 💚(CI) upgrade submodule
- Change submodule ref to get preprod secret
2024-06-18 15:40:33 +02:00
Anthony LC 21981c6478 💚(CI) remove trigger workflow on push tags
We were starting the workflow on push tags,
it is needed for the docker-hub workflow,
but the other workflows does not need to
be triggered on push tags.
2024-06-18 15:40:33 +02:00
Anthony LC e56c63676e 👷(CI) add deploy workflow
Add the deploy workflow, this workflow will deploy
the application to the selected tag.
2024-06-18 15:40:33 +02:00
Anthony LC 187005d441 👷(helm) preprod configuration
Add the preprod configuration to the helm chart.
2024-06-18 15:40:33 +02:00
Anthony LC 54b7a637fe 🔧(backend) activate https on oidc redirection
mozilla-django-oidc didn't add the `https`
prefix to the redirect_uri.
We set the option SECURE_PROXY_SSL_HEADER to
('HTTP_X_FORWARDED_PROTO', 'https') in the
settings.py file to force the https prefix.
2024-06-18 15:40:33 +02:00
renovate[bot] 35a897fa60 ⬆️(dependencies) update python dependencies 2024-06-16 23:55:07 +02:00
Sabrina Demagny b4bafb6efb (mailbox_manager) modify API to get maildomain
Access to maildomain by slug name
2024-06-13 15:10:04 +02:00
Jacques ROUSSEL 23778fda0d 💚(ci) improve submodule usage
- remove deplucate declaration
- simplify helmfile
- use symlink
2024-06-11 10:46:40 +02:00
daproclaima 0a8c488649 🧑‍💻(frontend) run automatically prettier on translations.json
The frontend translations.json could be validated by
the eslint scripts without running manually
prettier. This commit fixes this by running prettier
script automatically once the frontend translations
are extracted.
2024-06-10 12:38:19 +02:00
Anthony LC e6b5f32a61 🐛(i18n) comma in keys
Having a comma in the keys broke the parser.
This commit allows the comma in keys.
2024-06-10 12:38:19 +02:00
daproclaima 8af47283c8 💬(app-desk) update translations of teams feature
This commit is part of the replacement of "teams" by "groups" wording task.
It changes words in the "teams" feature and add more meaningful wording in
English and French, and removes the icon used in the title of the group
member deletion modal as it rather lets think we are deleting a group.
Updates of related e2e and rendering tests come along with these changes.
2024-06-10 12:38:19 +02:00
Jacques ROUSSEL 8a44718e6b 💚(ci) fix
- fix broken front push docker image
2024-06-07 17:09:55 +02:00
Jacques ROUSSEL 6e7f20eda9 💚(ci) remove secret from repository
- Remove *.enc.*
- Adapt helmfile
- Adapt CI
2024-06-07 16:30:14 +02:00
Sabrina Demagny b3779b5979 🧑‍💻(makefile) improve django migration commands
Add showmigrations command.
Allow to pass args to migrate and makemigrations commands.
2024-06-06 10:30:26 +02:00
Anthony LC 4b80b288f9 ♻️(mails) link email from current site
The link in the email was pointing on the
staging website. We now use a variable to
target the current site setup in the database.
2024-06-05 09:50:09 +02:00
Anthony LC fbec4af261 🧑‍💻(mail) make commands windows friendly
Make the commands windows friendly.
2024-06-05 09:50:09 +02:00
Anthony LC b8499a539e 🐛(frontend) fix duplicate call on getMe
Fix duplicate call on getMe.
The logout process was refactored recently,
the hook dependency is not necessary anymore and
was creating a duplicate call on getMe.
2024-06-04 11:52:36 +02:00
Anthony LC c7d1312f89 ♻️(frontend) frontend environment free
Until now, the front had to know at build time
the url of the backend and the webrtc server
to be able to communicate with them.
It is not optimal because it means that we need
multiple docker image (1 per environment) to have
the app working, it is not very flexible.

This commit will make the frontend "environment free"
by determining these urls at runtime.
2024-06-04 11:52:36 +02:00
Anthony LC 4636c611c6 🔧(helm) add namespace to the templates
The goal of adding a namespace in the templates
is to ensure that resources are deployed
in a specific, possibly isolated part of the Kubernetes cluster.
This helps in organizing resources, managing
permissions, and applying configurations or
limits appropriately within the cluster.
2024-06-04 10:52:17 +02:00
Anthony LC 211d89cae0 🔨(CI) add Tilt
Tilt is a tool for local Kubernetes development.
It makes it easy to see your changes as you
make them, and it rebuilds and redeploys
your app as you change it.
2024-06-04 10:52:17 +02:00
Anthony LC 915731e218 💚(ci) improve secrets for k8s deployment
Avoid secrets to be visible from running deployments
2024-06-04 10:52:17 +02:00
lebaudantoine c534048e97 🔧(compose) stop forcing platform for Keycloak PostgreSQL image
Forcing `platform: linux/amd64` for the PostgreSQL image causes compatibility
issues and performance degradation on Mac ARM chips (M1/M2). Removing the
platform specification allows Docker to select the appropriate architecture
automatically, ensuring better performance and compatibility.
2024-06-03 13:56:28 +02:00
renovate[bot] 5d1e2bd39d ⬆️(dependencies) update python dependencies 2024-06-03 09:49:51 +02:00
Jacques ROUSSEL 67d3e58c82 🐛(ci) improve docker-hub
Avoid to notify argocd for nothing
2024-05-31 17:08:59 +02:00
antoine lebaud e0739689e6 🚨(backend) handle new checks introduced in Pylint v3.2.0
Pylint 3.2.0 introduced a new check `possibly-used-before-assignment`, which
ensures variables are defined regardless of conditional statements.

Some if/else branches were missing defaults. These have been fixed.
2024-05-31 12:53:11 +02:00
renovate[bot] 04717fd629 ⬆️(dependencies) update python dependencies 2024-05-31 12:53:11 +02:00
Lebaud Antoine 087bbf74f6 🔧(helm) setup logout flow from Agent Connect
Add the relevant environment configurations to make sure the backend
in dev and staging environments log out the user from Agent Connect.
2024-05-31 12:14:58 +02:00
Lebaud Antoine 63a875bd5b ♻️(frontend) redirect the user agent to the logout endpoint
Recent updates in the backend views now requires the user agent to be
redirected to the logout endpoint.

The logout endpoint should initiate the logout flow with the OIDC provider,
by redirecting the user to the OIDC provider domain.

Thus, OIDC provider session cookie should be cleared.

E2E tests should be improved later on, when the CI and the development env
use Agent Connect integration environment. The current logout is not working
with the Keycloack configuration.
2024-05-31 12:14:58 +02:00
Lebaud Antoine 7a26f377e3 (backend) support Agent Connect Logout flow
The default Logout view provided by Mozilla Django OIDC is not suitable
for the Agent Connect Logout flow.

Previously, when a user was logging-out, only its Django session was ended.
However, its session in the OIDC provider was still active.

Agent Connect implements a 'session/end' endpoint, that allows services to
end user session when they logout.

Agent Connect logout triggers cannot work with the default views implemented
by the dependency Mozilla Django OIDC. In their implementation, they decided
to end Django Session before redirecting to the OIDC provider.

The Django session needs to be retained during the logout process.

An OIDC state is saved to the request session, pass to Agent Connect Logout
endpoint, and verified when the backend receives the Logout callback from Agent
Connect. It seems to follow OIDC specifications.

If for any reason, the Logout flow cannot be initiated with Agent Connect,
(missing ID token in cache, unauthenticated user, etc), the user is redirected
to the final URL, without interacting with Agent Connect.
2024-05-31 12:14:58 +02:00
Lebaud Antoine 05d9a09d63 🚚(backend) create a dedicated authentication package
Prepare adding advanced authentication features. Create a dedicated
authentication Python package within the core app.

This code organization will be more extensible.
2024-05-31 12:14:58 +02:00
daproclaima 735db606f6 🚚(app-desk) rename useMailDomainMailboxes into useMailboxes
Shortens the hook name and update its imports and exports.
2024-05-29 16:11:59 +02:00
daproclaima 2bf85539f1 (e2e) add mailbox creation tests
Checks user can create a mailbox for a mail domain
and that form fields are visually interactive
according to the form validation state.
2024-05-29 16:11:59 +02:00
daproclaima 6981ef17df (app-desk) create mailbox for a mail domain
Add form to create a mailbox for a mail domain. It sends a http
POST request mail-domains/<mail-domain-id>/mailboxes/ on form
submit. The form appears inside a modal.
Installs react-hook-form, zod, and @hookform/resolvers for form
manipulation and field validation.
2024-05-29 16:11:59 +02:00
daproclaima 37d32888f5 (app-desk) displays specific mail domain mailboxes
Fetches a mail domain by id and displays
its mailboxes as a list in a table.
Associated with e2e tests.
2024-05-24 12:10:44 +02:00
daproclaima d4e0f74d30 (app-desk) add /mail-domains/<id> page
Adds a page in charge of finding the mail domain
matching the id provided in url query params.
In the future the domain name will be used.
2024-05-24 12:10:44 +02:00
Anthony LC d2c7eaaa4b 🐛(app-desk) fix fetchPriority warning
The upgrade to react@18.3.1 has a compatibility
issue with next@14.2.3. It creates a error warning
about the fetchPriority prop. This commit fixes the
issue by downgrading react to 18.2.0 as it was
before the last upgrade.
2024-05-13 17:10:37 +02:00
Anthony LC 46aaf7351d ♻️(app-desk) adapt La Gaufre
Adapt La Gaufre with the new configuration.
2024-05-13 17:10:37 +02:00
daproclaima 76e9d58b6c (app-desk) add sorting to mail domains panel
Adds a button to sort items in the mail domains panel
by creation date. Also adds an e2e test.
2024-05-13 16:23:14 +02:00
daproclaima 9b198d0bab (app-desk) add panel for mail domains
Adds a panel based on teams' one. It fetches all mail-domains
the connected user has relationships with and displays them
as a list of links redirecting to
/mail-domains/<my-domain-name>. Updates e2e tests accordingly.
2024-05-13 16:23:14 +02:00
daproclaima 3f1b446e8e 🚚(app-desk) application mail renamed into mail-domains
Rename any folder or file containing mails into mail-domains.
Update all url redirection links accordingly.
2024-05-13 16:23:14 +02:00
Anthony LC 5049c9b732 ️(frontend) clean yarn.lock
Some compatibility issues with dependencies were
fixed by cleaning the yarn.lock.
2024-05-13 14:54:31 +02:00
renovate[bot] 7f2adb8d2f ⬆️(dependencies) update js dependencies 2024-05-13 14:54:31 +02:00
renovate[bot] b12992f125 ⬆️(dependencies) update python dependencies 2024-05-09 23:15:12 +02:00
Anthony LC 001673f973 💬(app-desk) change some texts
Change some texts on the team page.
2024-05-06 17:00:39 +02:00
Anthony LC 4b4bbc4c0a 🐛(app-desk) fix fetchPriority warning
The upgrade to react@18.3.1 has a compatibility
issue with next@14.2.3. It creates a error warning
about the fetchPriority prop. This commit fixes the
issue by downgrading react to 18.2.0 as it was
before the last upgrade.
2024-05-06 16:50:17 +02:00
Anthony LC b7b90d1bf3 💄(app-desk) keep highlighting menu when sub menu
When sub menu was open, the parent menu was not
highlighted.
This commit fixes this issue.
2024-05-06 12:09:17 +02:00
Anthony LC c599757d7a 🗑️(app-desk) clean the menu
- remove unused icons
- remove unused pages
- remove menu items
2024-05-06 12:09:17 +02:00
renovate[bot] a0992b6ba9 ⬆️(dependencies) update js dependencies 2024-05-06 10:27:25 +02:00
Anthony LC d88f6a5a51 ♻️(app-desk) replace classname spacings
Replace the classname spacings with the new
spacing system based on props.
2024-04-30 11:42:26 +02:00
Anthony LC a45408c93c 🎨(app-desk) add margin and padding to Box
Add margin and padding system to Box component.
It proposes the autocompletion.
It is bind with the Cunninghams spacing system.
2024-04-30 11:42:26 +02:00
Anthony LC c94888fe09 ⬇️(frontend) react 18.3.1 -> 18.2.0
Downgrade react to 18.2.0.
It seems to have a compatibility issue
with @openfun/cunningham-react.
2024-04-29 10:27:15 +02:00
Anthony LC 4551d20d67 🔥(eslint) remove uneeded yarn.lock file
The yarn.lock doesn't seem necessary for this
package, so we're removing it.
2024-04-29 10:27:15 +02:00
Anthony LC c7f257daa0 ⬇️(eslint-config-people) eslint 9.0.0 -> 8.57.0
Downgrade eslint to 8.57.0.
9.0.0 has breaking changes, the adoption
is still very low, better to wait.
Add it in the renovate.json file.
2024-04-29 10:27:15 +02:00
renovate[bot] 32e6996b68 ⬆️(dependencies) update js dependencies 2024-04-29 10:27:15 +02:00
Jacques ROUSSEL 8fbc4e936e 💚(ci) improve secrets for k8s deployment
Avoid secrets to be visible from running deployments
2024-04-23 22:19:25 +02:00
renovate[bot] cda59fecec ⬆️(dependencies) update python dependencies 2024-04-22 13:46:27 +02:00
Marie PUPO JEAMMET c5ba87e422 📝(doc) copy demo credentials to README.md
Copy demo admin credentials for newcomers to log in to django admin
without having to find it in makefile.
2024-04-19 18:45:50 +02:00
Marie PUPO JEAMMET df24c24da1 (api) add CRUD for mailbox manager MailDomain models
Add create,list,retrieve and delete actions for MailDomain model.
2024-04-19 18:45:50 +02:00
Marie PUPO JEAMMET ac81e86c88 🧑‍💻(admin) add mailbox-related models to django admin
Register MailDomain, MailDomainAccess and Mailbox to django admin.
2024-04-18 10:42:13 +02:00
Sabrina Demagny 082fb99bd5 (api) allow to list and create Mailboxes
Simply display all Mailboxes create for a MailDomain.
LDAP connection is not yet available, it will be implemented soon.
Read and create permissions will be refined soon too.
2024-04-17 16:51:54 +02:00
renovate[bot] 1704ba1707 ⬆️(dependencies) update gunicorn to v22 [SECURITY] 2024-04-17 11:23:11 +02:00
Marie PUPO JEAMMET cca6c77f00 🗃️(models) add MailDomain, MailDomainAccess and Mailbox models
Additional app and models to handle email addresses creation in Desk.
2024-04-16 15:47:33 +02:00
renovate[bot] a1f9cf0854 ⬆️(dependencies) update python dependencies 2024-04-16 10:27:16 +02:00
Lebaud Antoine 2f1805b721 🩹(backend) address linter flakiness on Email tests
Pylint was randomly failing due to a warning while unpacking emails.
The W0632 (Possible unbalanced tuple unpacking) was triggered.

Replace tuple unpacking by an explicitly accessing the first element of
the array using index.
2024-04-08 15:35:12 +02:00
renovate[bot] 711abcb49f ⬆️(dependencies) update python dependencies 2024-04-08 15:35:12 +02:00
Anthony LC e68370bfcd ⬇️(eslint-config-people) eslint 9.0.0 -> 8.57.0
Downgrade eslint to 8.57.0.
9.0.0 has breaking changes, the adoption
is still very low (1%), better to wait.
2024-04-08 15:18:17 +02:00
renovate[bot] ae1acd8840 ⬆️(dependencies) update js dependencies 2024-04-08 15:18:17 +02:00
Lebaud Antoine 54386fcdd3 🩹(backend) address test flakiness while sorting Team accesses
Previously, there was a difference between Django's `order_by`
behavior and Python's `sorted` function, leading to test failures
under specific conditions. For example, entries such as 'Jose Smith'
and 'Joseph Walker' were not consistently sorted in the same order
between the two methods.

To resolve this issue, we've ensured that sorting the expected
results in the TeamAccess tests are both case-insensitive and
space-insensitive. This adjustment fix tests flakiness
2024-04-08 15:07:58 +02:00
Anthony LC 45a3e7936d (app-desk) create mails feature
Create the archi to handle the mails feature.
It has a different layout than the other features,
we don't display the sidebar to keep the
user focused on the mail content.
2024-04-08 14:42:56 +02:00
Marie PUPO JEAMMET ebf58f42c9 (webhook) add webhook logic and synchronization utils
adding webhooks logic to send serialized team memberships data
to a designated serie of webhooks.
2024-04-05 16:06:09 +02:00
Samuel Paccoud - DINUM 7ea6342a01 ♻️(models) refactor user email fields
The email field on the user is renamed to "admin_email" for clarity. The
"email" and "name" fields of user's main identity are made available on
the user model so it is easier to access it.
2024-04-05 16:06:09 +02:00
Anthony LC 6d807113bc 🔧(sops) update secrets
Access to anthony's new key
2024-04-05 12:21:13 +02:00
Jacques ROUSSEL 5455c589ef 🔧(sops) update secrets
Decrypt and reencrypt secrets to grant access to anthony's new key
2024-04-05 09:48:19 +02:00
Lebaud Antoine 9ec7eddaed (frontend) add logout button
Rework the header based on latest Johann's design, which introduced a
dropdown menu to mange user account.

In this menu, you can find a logout button, which ends up the backend
session by calling the logout endpoint. Please that automatic redirection
when receiving the backend response were disabled. We handle it in our
custom hook, which reload the page.

Has the session cookie have been cleared, on reloading the page, a new
loggin flow is initiated, and the user is redirected to the OIDC provider.

Please note, the homepage design/organization is still under discussion, I
prefered to ship a first increment. The logout feature will be quite useful
in staging to play and test our UI.
2024-04-04 14:52:53 +02:00
Lebaud Antoine 7db2faa072 🍱(frontend) rename Desk to Equipes
Update all assets related to the previous naming.
I gave some asset a more generic name, so if the app's name chang again
we won't have to rename the logo.

Linked but not assets, meta title and description were updated.
Next.js favicon was replaced by the Equipes' one.
2024-04-04 14:52:53 +02:00
Anthony LC 6e0b329b09 🐛(app-desk) add next-env.d.ts
next-env.d.ts lot of types for the next.js.
next.js boilerplate don't version the
next-env.d.ts file, so it was being ignored by git.
This was causing the CI to fail with the ts linter.
2024-04-04 12:13:00 +02:00
Lebaud Antoine c7aae51d25 🩹(test) update MemberAction props
TeamId prop was refactored to a Team object.
Update MemberAction component's tests.
2024-04-04 12:13:00 +02:00
Anthony LC db40efb360 🚨(app-desk) improve linter
The linter was passing near the ts errors in
the tests, we improve the linter by adding
a ts checkup.
2024-04-04 12:13:00 +02:00
Anthony LC 3ddc519d9e (e2e) fix job flakinness
Fix a flaky jobs
- searched username could be hidden in the options
 depends the dummy data generated.
- remove first place assertion when create a new team,
  multiple workers make this assertion flaky
2024-04-04 12:13:00 +02:00
Lebaud Antoine e20960e3e1 💚(ci) update Github Actions using Node.js 16
Github Actions are transitioning from Node 16 to Node 20. Make sure we use
latest Github Actions versions to clean any deprecation warnings.

The migration is upcoming.
2024-04-04 10:33:20 +02:00
Anthony LC 1223732fa9 🐛(CI) improve caching
When we restored the frontend cache, we were restoring
old code as well, we don't want that, we want to only
restore the node_modules.
This commit fixes that.
We improve the build-front caching as well, to cache
only the desk build app.
2024-04-02 16:12:32 +02:00
Anthony LC e8180bc49b 💄(app-desk) retouch design grid members
The update of Cunningham seems to have lightly
broken the design of the grid members.
This commit fixes the design of the grid members.
2024-04-02 16:12:32 +02:00
Anthony LC ffe997a658 ️(frontend) clean yarn.lock
The yarn.lock file get full of garbage and old
dependencies after a while. This commit cleans it.
2024-04-02 16:12:32 +02:00
renovate[bot] d4c23ce5b9 ⬆️(dependencies) update js dependencies 2024-04-02 16:12:32 +02:00
Lebaud Antoine 5ed05b96a5 🩹(frontend) disable submission button while a request is pending
If any request is taking longer than expected, the user could interact with
the frontend, thinking the previous submission was not taken into account,
and would re-submit a request.

It happened to me while sending an invitation. Replaying request can either
lead to inconsistencies in db for the user, or to errors in requests' response.

I propose to disable all interactive button while submitting a request.
It's good enough for this actual sprint, these types of interactivity issues
could be improved later on.
2024-04-02 15:07:59 +02:00
Anthony LC df15b41a87 🚸(app-desk) cannot select user or email already selected
- Add the teamid to the useUsers query, to not get
  the users that are already in the team
- Add a check to not select a user or email
  that is already selected
2024-04-02 11:45:27 +02:00
Anthony LC 591045b0ec ️(app-desk) clean build pages
Prob:
Next.js transpiles all the files present in the
`pages` directory. But we don't want to transpile
the providers neither the Layout components.

Solution:
We export these components to a core folder.
2024-04-02 11:34:40 +02:00
Sabrina Demagny 775b32ff45 (backend) enhance search users to add in a team
Exclude from the result all users already members of the current team
2024-04-02 11:12:08 +02:00
renovate[bot] e9a628f816 ⬆️(dependencies) update python dependencies 2024-04-02 11:11:42 +02:00
Anthony LC bf1450cfa7 ️(app-desk) remove firefox from e2e tests
- Remove Firefox testing, Firefox browser seems unstable with
Playwright, most of the time the failing tests are the one
with Firefox, Firefox is only 3% of the browser.
- Improve some naming in the test creation to avoid
conflit name.
2024-04-02 10:54:04 +02:00
Anthony LC 480d8277cc ️(CI) persist the frontend between jobs
To improve the speed of the CI, we cache the frontend
install. It will even be reused between pull request
until the yarn.lock has a change.
We cache as well the desk build app, in another cache,
this cache persist only per workflow. It will increase the
speed if we have e2e flaky tests and that we have to relaunch
the e2e job.
2024-04-02 10:54:04 +02:00
Lebaud Antoine 97cec8901c 📱(frontend) enhance team info style to avoid layout shift
On small screens, padding and alignment were causing texts to render wrapped.
It might be a personal choice, but I decided to give more space to the text,
thus avoiding text to wrap and ending up in increasing team info's height.

This issue arises when closing / opening the panel menu.
2024-03-29 15:32:52 +01:00
Lebaud Antoine e8aba29a68 (frontend) make the team pannel closable
Team panel was quite wide, and took too much space on small screens.
Johann decided to make it closable. Thus, user that needs to navigate quickly
between their team can open it, use it and then close it.

This animation is a first draft, and should be improved later on. Also, some
accessibility issues might ariase, I have ignored them in this first draft.
2024-03-29 15:32:52 +01:00
Lebaud Antoine ebaa1360e7 🍱(frontend) update icon button 'add team' in teams panel
Johann recently changed the icon use by the 'add team' button. He preferred
having a square one to contrast more with the 'collapse menu' icon that will
be added later on.
2024-03-29 15:32:52 +01:00
Anthony LC 6d8e05b746 🚨(app-desk) remove warning next.js about css from Head
Importing the css from Head was causing a flickering
effect on the button, because of the time the css
load. Next.js was emitting as well a warning about
css being loaded from the Head component.
We moved the css import to the _document.tsx file
as recommended by the Next.js documentation.
2024-03-29 14:30:34 +01:00
Sabrina Demagny e7049632ab 📝(frontend) add missing info in the README
add missing info about how to run front and accesses
2024-03-29 10:05:19 +01:00
daproclaima a54bcbcb1e 💄(app-desk) integrate design 404 page
Introduce a first draft of 404 page based on Johann's design. Please
refer to the Figma file for more info. This page is tested through
tests e2e. It closes the issue #112
2024-03-27 17:26:54 +01:00
daproclaima 4af4c4de50 🙈(common) ignore .tools-version
add .tools-version to .gitignore as I use
asdf as a package and version manager.
2024-03-27 17:26:54 +01:00
Anthony LC 1c5499b2ab (app-desk) remove warning request not mocked
A request was not mocked in the test,
so the warning a warning was displayed.
This commit mocks the request to avoid the warning.
2024-03-27 14:31:49 +01:00
Anthony LC 91f755306b (app-desk) improve sorting teams test e2e
This tests was becoming very flaky because we create
teams in parallel with the other tests.
We use another approch, we checks the aria are
changing according to the sort, we check
as well the api request and that the response
is ok.
2024-03-27 14:31:49 +01:00
Anthony LC 224025c3fb ♻️(app-desk) add hook useWhoAmI
The hook useWhoAmI is a custom hook that gives informations
about the current member.
2024-03-27 14:31:49 +01:00
Anthony LC 724bbe550c (app-desk) modal delete member
We can now delete a member from a team.
We take care of usecases like:
- it is the last owner of the team (cannot delete)
- other owner of the team (cannot delete)
- role hierarchy
2024-03-27 14:31:49 +01:00
Anthony LC 016232ad2d (app-desk) add useDeleteTeamAccess react-query hook
Add the hook useDeleteTeamAccess, it will be used to
delete member from a team.
2024-03-27 14:31:49 +01:00
Lebaud Antoine 6de24d973b 🔇(helm) silence some Django system checks
Django logs some security warnings we can ignored when deploying over K8s.
Inspired by fun project, I added the Django setting SILENCED_SYSTEM_CHECKS,
and silenced the two that were logging a lot of warning.
2024-03-27 12:14:36 +01:00
Lebaud Antoine 04c107cfdb 🐛(helm) enable SSL when sending email
Email settings were wrongly configured. It led to unsent email and timeout
response from the backend server.

I forgot to enable the SSL while using the Email service from scalingo.
2024-03-27 12:14:36 +01:00
Lebaud Antoine cbfc67f010 🔒️(helmfile) generate Django secret key
Generate a proper Django secret key ready for production,
using the provided get_random_secret_key() function.

Store its value in a k8s secret. I generated two values one for
dev and one for staging.

Previous values were triggering security logs.
2024-03-27 12:14:36 +01:00
Anthony LC 0fe0175622 💬(app-desk) translate en plurals keys correctly
We added the english language in Crowdin.
We can now translate the plural keys in
the english language.
2024-03-27 07:15:42 +01:00
Anthony LC fab1329712 🌐(i18n) don't keep unused keys
The potential unused keys was the ones from other
PR, but we don't use crowdin with every PR, so we don't
need to keep the unused keys in the translation files.
2024-03-27 07:15:42 +01:00
Anthony LC f27b347d1c 💬(frontend) synch the translations with crowdin
We stopped the use Crowdin for every PR, so we need to
synch the translations here and there to be sure
all the translations are up to date.
2024-03-27 07:15:42 +01:00
Lebaud Antoine cc35757c9e 🚧(frontend) add applications menu PoC
Based on works from @manuhabitela, introduce a PoC of the future component.
ApplicationsMenu component is still under construction.

This code was committed for the Wednesday 26th demo, to showcase our future
works. This Next.js integration could be improved, and will for sure!
Don't blame me.
2024-03-26 22:49:57 +01:00
Lebaud Antoine a1065031ee (frontend) sort team's members
We are displaying all team's members in a datagrid. This propose to enable
sorting on columns, to easily find members.

Please note that few issues were faced when activating the sorting on the
Cunningham components. First, custom columns can not be sorted (yet), a PR
has been merged on Cunningham's side. We're waiting for the next release.

Second, when sorting data rows, if any of the column has some null values,
the datagrid sorting state becomes inconsistent. Thx @AntoLC for spotting the
issue. It's work in progress on Cunningham's side to fix the issue.

Finally, Cunningham export only the SortModel type, which is an array, and
doesn't export its items' type. I might have miss something but it feels weird
to redefine its items type.

Columns wiggle on sorting, because they data is set to undefined while fetching
the next batch. it's visually weird, but not a major pain.
Next release of Cunningham will allow us to set the column to a fixed size.
2024-03-26 22:15:18 +01:00
Jacques ROUSSEL 7c488a9807 🚀(helm) transform migrate job to Presync job
Apply db migration before syncing all the pods.
It avoids triggering errors when running the migrate job.
2024-03-26 17:45:53 +01:00
Jacques ROUSSEL 1c4efd523b 👷(argocd) notify argocd when new images are pushed
Add a new job in the CI, which notifies ArgoCD through a webhook that a new
docker image has been pushed to the Docker registry. Thus, ArgoCD can sync
and pull the latest image.

Thus, main will be automatically deployed to staging.
2024-03-26 17:01:15 +01:00
Lebaud Antoine 2345250c4f 🚑️(staging) fix 404 errors
Recent changes on the staging cluster created a regression.
The ingress className needs to be specified.
2024-03-26 16:39:48 +01:00
Anthony LC 6b2fb4169c 🛂(app-desk) add TeamActions component
TeamActions is a component that control the actions
that a member can performed on the team.
It contains a dropdown menu that contains the actions:
- Edit Team
- Remove Team
2024-03-26 16:28:51 +01:00
Anthony LC 73e58e274c (app-desk) modal delete team
We can now delete a team.
2024-03-26 16:28:51 +01:00
Anthony LC 55fbd661b0 (app-desk) add useRemoveTeam react-query hook
Add the hook useRemoveTeam, it will be used to
remove a team.
2024-03-26 16:28:51 +01:00
Anthony LC f591c95a92 🏷️(app-desk) move Role type to Team
We need the Role type in Team as well.
Better to move it to the highest level.
2024-03-26 16:28:51 +01:00
Anthony LC 25af872a2a 🚚(app-desk) create addMembers feature
All the code related to adding members has been moved
to the addMembers feature.
2024-03-25 18:08:44 +01:00
Anthony LC 832dae789e (app-desk) add new member to team
We integrate the endpoint to add a new member
to the team with the multi select seach user.
- If it is a unknown email, it will send an invitation,
- If it is a known user, it will add it to the team.
2024-03-25 18:08:44 +01:00
Anthony LC 904fae469d (app-desk) add useCreateTeamAccess react-query hook
Add the hook useCreateTeamAccess, it will be used to
add a member to a team.
2024-03-25 18:08:44 +01:00
Lebaud Antoine da081b9887 🔧(renovate) open pull request with a default noChangeLog label
Discussed with @sampaccoud, Renovate PR do not necessitate ChangeLog updates
Our CI system requires a 'noChangeLog' label or an updated ChangeLog. Currently,
we manually add the label for each Renovate PR.

To improve DX, we configured Renovate to apply the label automatically.
2024-03-25 17:59:26 +01:00
Anthony LC 0bb5c0c5c2 ⬇️(app-desk) compatibility issue stylelint@16.3.0
Compatibility issue with stylelint@16.3.0 and
stylelint-prettier@5.0.0.
An issue has been opened in the `stylelint` repo.
2024-03-25 13:00:23 +01:00
renovate[bot] a9bb556dfd ⬆️(dependencies) update js dependencies 2024-03-25 13:00:23 +01:00
renovate[bot] 32fa653c12 ⬆️(dependencies) update python dependencies 2024-03-25 08:54:42 +01:00
Anthony LC 7d9032b6ec 💚(app-desk) build template mail for e2e
The tests e2e were failing because the mail
template was not built.
We will use the job after the mail templates are build.
2024-03-22 17:26:32 +01:00
Anthony LC 897b68038f (app-desk) create invitation
Invite the selected members to the team.
To have a successful invitation:
- none user has this email
- an invitation is not pending for this email and
  this team
2024-03-22 17:26:32 +01:00
Anthony LC bb9edd21da (app-desk) add useCreateInvitation react-query hook
Add the hook useCreateInvitation, it will be used to
create an invitation.
2024-03-22 17:26:32 +01:00
Anthony LC bbf2695dee 🥅(app-desk) add data to APIError
We sometime need to get some data back when an
error occurs. We add a data prop to the APIError
class to allow us to do that.
2024-03-22 17:26:32 +01:00
Anthony LC 8b63807f38 ♻️(app-desk) create InputTeamName component
We created the InputTeamName component to use it
in all the places where we need to input the team name.
2024-03-22 14:53:40 +01:00
Anthony LC 88e38c4c7f 💄(app-desk) update ui
We update the theme of the app to match the design system:
- secondary button
- input error
- modal background
2024-03-22 14:53:40 +01:00
Anthony LC 8ea7b53286 (app-desk) modal update team
Integrate the modal and the logic to update a team.
2024-03-22 14:53:40 +01:00
Anthony LC 7347565f8d (app-desk) add useUpdateTeam react-query hook
Add the hook useUpdateTeam, it will be used to
update the name of a team.
2024-03-22 14:53:40 +01:00
Anthony LC 2d50920a48 ♻️(app-desk) create IconOptions component
Export from MemberAction component the icon options
to IconOptions component.
We will reuse this component in other places.
2024-03-22 14:53:40 +01:00
Anthony LC 36161972d7 ♻️(app-desk) keep team logic in teams feature
Part of the team logic was in the create team page,
we moved it to the CardCreateTeam component in
the teams feature.
It will be easier to maintain and reuse the logic.
2024-03-22 14:53:40 +01:00
Lebaud Antoine f9fde490e8 🚀(smtp) update mail server configurations in staging
Update staging configuration, so they can use the outscale mail
gateway as recommended by @rouja.
2024-03-22 13:42:22 +01:00
Lebaud Antoine 1b3869c1e9 🌐(backend) generate traductions
With the recent addition of mails' templates, Django traduction files
needed to be updated.

It seems that recents backend changes were not reflected into the
Django traduction file. Fixed them, and add traductions related to
the invitation email.

Last revision was made on 2024-01-01
2024-03-22 13:42:22 +01:00
Lebaud Antoine f6d5f737f4 💚(ci) download mails templates when testing back
build-mails job builds mails Django templates but was not persisting its
output. This steps was present in Joanie CI. It might have been removed,
when converting Circle CI worflows to Github Actions.

Artifacts are passed between build-mails and test-back jobs. test-back
job has now a dependency to  build-mails.
2024-03-22 13:42:22 +01:00
Lebaud Antoine 522914b47a (backend) email invitation to new users
When generating an Invitation object within the database, our intention
is to promptly notify the user via email. We send them an invitation
to join Desk.

This code is inspired by Joanie successful order flow.

Johann's design was missing a link to Desk, I simply added a button which
redirect to the staging url. This url is hardcoded, we should refactor it
when we will deploy Desk in pre-prod or prod environments.

Johann's design relied on Marianne font. I implemented a simpler version,
which uses a google font. That's not important for MVP.

Look and feel of this first invitation template is enough to make our PoC
functionnal, which is the more important.
2024-03-22 13:42:22 +01:00
Lebaud Antoine 1919dce3a9 🧑‍💻(views) render email's template
THis feature is inspired by Joanie. Add two new urls to render Emails
HTML and Text templates.

Developpers can render the email template they are working on. When necessary,
run make mails-build, and reload `_debug__/mail/hello_html`, it will re-render
the updated email template.

Also, I have copy/pasted one template extra tags from Joanie, which loads
bas64 string from static images. This code is necessary to render the dummy
template `hello.html`.
2024-03-22 13:42:22 +01:00
Lebaud Antoine 0141aa220f 🎨(models) extract invitation converter in a proper method
Improved code readability, by extracting this well-scoped unit of
logic in a dedicated method. Also, rename active_invitations to match
'valid' vocabulary used elsewhere in the doc. If no valid invitation
exists, early return to avoid nesting.
2024-03-22 13:31:24 +01:00
Anthony LC 159f112713 (app-desk) add role option to modal add members
When adding members to a team, the user can now
select the role of the members.
Only admin and owner can add new members to a team.
2024-03-22 11:13:24 +01:00
Anthony LC faf699544b ♻️(app-desk) create ChooseRole component
We extract the ChooseRole component from the ModalRole
component to make it reusable.
2024-03-22 11:13:24 +01:00
Anthony LC b8427d865f (app-desk) integrate multiselect search users
Integrate multiselect search users in the
modal add members.
We are using react-select to implement the
multiselect search users. We are using this
library in waiting for Cunningham to implement
the multiselect asynch component.
2024-03-22 11:13:24 +01:00
Anthony LC a48dbde0ea 🧐(CI) add dummy data to test-e2e job
To search some users we need to have some
dummy data in the database.
This commit adds dummy data to the database
like users, teams, and identities.
2024-03-22 11:13:24 +01:00
Anthony LC e9848bd199 (app-desk) add useUsers react-query hook
Add the hook useUsers, it will be used to
search users by name or email.
2024-03-22 11:13:24 +01:00
Anthony LC 1ad6ef8f96 🧑‍💻(frontend) remove CI control on traduction frontend
The CI was controlling if the traduction was made
in every PR. It makes the workflow quite grueling
when we have to change the literal, plus the synch
is complicating when we have multiple PR opened.

We remove the CI control on the traduction, we
will do dedicated PR to update the traduction.

We will add the CI control on the traduction in
the future, before a release by example.
2024-03-22 09:49:14 +01:00
Lebaud Antoine 97752e1d5f 🩹(factory) handle email uniqueness
When generating a batch of users using Faker, there's a possibility of
generating multiple users with the same email address. This breaches
the uniqueness constraint set on the email field, leading to flaky
tests that may fail when random behavior coincides unfavorably.

Implemented a method inspired by Identity's model to ensure unique
email addresses when creating user batches with Faker.
Updated relevant tests for improved stability.
2024-03-22 08:28:30 +01:00
Lebaud Antoine 99cee241f9 (api) support TeamAccess ordering on user-based fields
Important ordering fields for TeamAccess depend on user's
identities data. User and identities has a one-to-many relationship,
which forced us to prefetch the user-related data when listing
team's accesses.

Prefetch get data from the database using two SQL queries, and
join data in Python. User's data were not available in the first
SQL query.

Without annotating the query set with user main identities data,
we could not use default OrderingFilter backend code, which order_by()
the queryset.
2024-03-22 08:28:30 +01:00
Lebaud Antoine 6de0d013c3 (api) support TeamAccess ordering on their role
Enhance list capabilities, by adding the OrderingFilter as filter backend,
to the TeamAccess viewset.

API response can be ordered by TeamAccess role. More supported ordering
fields will be supported later on.
2024-03-22 08:28:30 +01:00
Lebaud Antoine 1de743e18a (pagination) add few tests on page's size
We created a custom Pagination class, were max_page_size is overriden.
I decided to add bare minimum tests to make sure we can set the maximum
page size using the 'page_size' query parameter.
2024-03-22 08:28:30 +01:00
Lebaud Antoine 756867da19 🔥(pagination) remove unused ordering field
Our Pagination class inherits from the PageNumberPagination Django class.
However, this base class as not ordering attribute. Thus, setting a
default value wont have any effect on the code.

Why did we end up passing a value to this non-existing attribute? Becasue
we copy/pasted some code sources from Joanie, and Joanie also has this
attribute set to a default value.

If you take a look at DRF pagination style documentation, the only three
attributes they set on the child class are 'page_size', 'max_page_size'
'page_size_query_param'. 'ordering' is not mentionned in the attributes
you may override. However, the CursorPagination class offers the latter
attribute, which may explain why we did end up setting this non-existing
attribute in Joanie.
2024-03-22 08:28:30 +01:00
Lebaud Antoine d15adb4421 🐛(helm) fix wrongly named ingress
Admin ingress has been partially renamed to ingressAdmin.
I forgot to update helmfile values. Fixed them.
2024-03-21 17:51:09 +01:00
Marie PUPO JEAMMET 340ddf8b1a 🐛(dependencies) modify expected details on 404 responses
djangorestframework released version 3.15.0, which includes modifications of
details upon returning 404 errors (see related issue
https://github.com/encode/django-rest-framework/pull/8051).

This commit changes the expected details of 404 responses in our tests,
to match DRF 3.15.0.
2024-03-21 15:46:42 +01:00
renovate[bot] 2d0fb0ef70 ⬆️(dependencies) update python dependencies 2024-03-21 15:46:42 +01:00
Marie PUPO JEAMMET 7ef67037c3 (backend) convert invitations to accesses
Convert related invitations to accesses upon creating a new identity.
2024-03-21 12:14:10 +01:00
Anthony LC f1124f6c09 🚸(app-desk) close modal role on click outside
The modal role will be closed when the user
clicks outside the modal.
The design does not have a close button, we removed it.
2024-03-21 11:13:17 +01:00
Anthony LC 2f8801f7eb (app-desk) add modal for adding members to a team
Create the button to open the modal.
Add a modal for adding members to a team.
This modal will open thanks to a dedicated page.
2024-03-21 11:13:17 +01:00
Anthony LC 4a141736ff 🎨(app-desk) add feature members
The feature teams is getting big, we extracted codes
related to members to a new feature members.
2024-03-21 11:13:17 +01:00
Lebaud Antoine bdddbb84a5 📝(helm) update chart's README
Run the ./generate-readme.sh script to keep the README file
up to date with the values.yaml.
2024-03-21 10:49:58 +01:00
Lebaud Antoine de4551ab30 🚀(helm) support Django Admin pages in ingress paths
Based on @rouja reco, I added a dedicated ingress to serve Django Admin
pages and Django statics. The admin route will be secured by the oauth proxy.

I simply copy/pasted the first ingress template, and adapted it.
2024-03-21 10:49:58 +01:00
Lebaud Antoine e8a241adbc 🔧(helm) enable liveness and readiness probes on backend deployment
Enable the probes to track liveness and readiness of any backend pods.
Helm values were updated to enable the relevant configuration.
2024-03-21 10:49:58 +01:00
Lebaud Antoine b3b1343796 🚀(helm) add a Redis cache service
This commit is working in progress. I have added an extra chart to take
benefits of the Redis operator developed by Indie hoster.

When using the dev environment, I used bitnami redis chart to deploy
a Redis service with authentication disable.
2024-03-21 10:49:58 +01:00
Lebaud Antoine d49cc11ef1 🩹(helm) rename mismatching environment variable
CSRF trusted origins are set using an environment variable. The env
value was wrongly name to CORS_ALLOWED_ORIGINS, which doesn't exist
in our Django configurations. I fixed this minor issue.
2024-03-21 10:49:58 +01:00
Lebaud Antoine 28adf987f7 🔐(helm) add OIDC secrets for dev environment
Set OIDC secrets for the dev environment. Please note that we use different
secrets between dev and staging. Why? Benoit created two client id, thus we
could easily tests Agent Connect feature from the local host and the staging
one.

The local host is desk.127.0.0.1.nip.io. If this value change at any time,
please consider asking Benoit to update the host value linked to the dev
client id.
2024-03-21 10:49:58 +01:00
Jacques ROUSSEL c6b8e47b29 🚀(helm) prepare staging deployment
Thx @rouja for your help on deploying Desk. This commit slightly modifies
helm charts and helmfile to prepare the initial project deployment in a
staging environment.

@rouja updates:
- added secrets files for dev and staging environments (dev's one is empty)
- disable ingress by default, to avoid any security issue
- added an extra chart to benefit from Indie hoster Postgres operator

Thx to this commit we deployed a first draft version figured out
that the Django session were broken. We are using a cache session engine,
and wrongly configure cache backend to local memory. Thus, Django server
is not able to resolve the session, and enters in an infinite loop to
log-in the user.
2024-03-21 10:49:58 +01:00
Lebaud Antoine a8a001e1e4 🚀(helm) build a minimalistic dev Helmfile
Please note that this Helmfile is uncomplete, it lacks services as
redis, celery, mail ... which are declared in the Docker Compose file
but not yet used in development and production images.

Thus, to run the Desk Helm chart, we only add a postgres database to run the
Django backend server, and apply migrations.

For now, this Helmfile is quite hard to test in dev environment, because the
frontend redirects automatically to the SSO login page. We cannot really
assess if backend and frontend are working properly. We might adjust some
configurations after the first deployment in stagging.

(We are a bit in rush, to respect the current sprint deadline.)

Development values points https://desk.127.0.0.1.nip.io URL. Please note that
the frontend image for now has been built with this URL for the backend address.
Meaning that we either need to rebuild and publish a frontend image with the
staging URL when deploying the project, or enhance our frontend image, to pass
the backend URL at runtime.
2024-03-21 10:49:58 +01:00
Lebaud Antoine bbd8e1b48d 🚀(helm) write desk Helm chart
First, thanks a LOT @rouja for your help along the way.
This commit propose a first draft of Helm chart to prepare deployment.
It follows Plane's Helm Chart, hosted on the shared team repo,
please https://github.com/numerique-gouv/helm-charts, PR #11

It offers advanced templating function under _helpers.tpl, an auto-generated
README file when running ./generate-readme.sh, and a clear files structure.

The chart itself is quite simple. We have two deployments, one for the
frontend and one for the backend. Both need a dedicated service, which are
exposed using a common ingress. Frontend is accessible from the / path and
backend's from /api path.

Please note, we added a backend job to migrate the database when deploying
backend's pods. This job should be auto-cleaning itself 100s after it completes
to avoid any error when syncing helm.

values.yaml file is quite pristine, all common env variables will be set
in helmfile configuration.

Deploying frontend static files through kubernetes is temporary, we plan to
either remplace it by an external CDN or use minio to host static output in
a S3 bucket within the cluster.
2024-03-21 10:49:58 +01:00
Anthony LC f21966cca9 🌐(app-desk) order translations asc
When we pull the translations from crowdin we
get lot of git diff noise with the json file.
We order the keys in the json file to make the
diffs more readable.
2024-03-20 14:23:42 +01:00
Lebaud Antoine e4a6b33366 🐛(docker) switch CMD form from Shell to Exec
`backend-development` and `backend-production` CMD syntaxes were
using a Shell Form. Shell form prevented Unix signals from reaching
our container correctly, such as SIGTERM. Also, the shell process
ends up being the PID 1, instead of our Python scripts.

Docker recommends to use the exec form whenever possible.
2024-03-20 09:31:19 +01:00
Lebaud Antoine 44b5999df8 🔧(backend) configure RedisCache in production settings
In development, sessions are saved in local memory. It's working well,
however it doesn't adapt to a kubernetized setup. Several pods need
to access the current sessions, which need to be stored in a single
source of truth.

With a local memory cache, pods cannot read session saved in other pods.
We end up returning 401 errors, because we cannot authenticate the user.

I preferred setting up a proper cache than storing sessions in database,
because in the long run it would be a performance bottleneck. Cache will
decrease data access latency when reading current sessions.

I added a Redis cache backend to the production settings. Sessions would
be persisted to Redis. In K8s, a Redis operator will make sure the cached
data are not lost.

Two new dependencies were added, redis and django-redis.

I followed the installation guide of django-redis dependency. These
setting were tested deploying the app to a local K8s cluster.
2024-03-19 16:57:27 +01:00
Anthony LC f503120b3c 📌(frontend) pin @types/react-dom globally
Compatibility issues with `@types/react-dom`.
Force the usage of the same version of
`@types/react-dom` across all packages and
dependencies.
2024-03-18 14:07:17 +01:00
renovate[bot] 079968b532 ⬆️(dependencies) update js dependencies 2024-03-18 14:07:17 +01:00
Lebaud Antoine 8e76a0ee79 🔧(frontend) update production value for the API_URL env var
For now, the env variable should point to the only deployed environment,
staging. It'll allow @rouja deploying for the first time our project.
2024-03-15 16:32:58 +01:00
Lebaud Antoine a2ff33663b 🚚(docker) make images naming consistent
It was quite confusing having development, production and
frontend images' names in the same Docker file. New comers
to the project would have some difficuluties when
differentiating frontend from backend images.

Try to make these naming more explicit and consistent.
Thanks @rouja for your recommendation.
2024-03-15 16:32:58 +01:00
Lebaud Antoine 78459df962 🐛(docker) build Docker images with an unprivileged user
This is a major issue. Docker Images were built and published with a
root user in the CI.

if a user manages to break out of the application running as root in the
container, he may gain root user access on host. In addition, configuring
container to user unprivileged is the best way yo prevent privilege
escalation attacks.

We mitigated this issue by creating a new environment variable DOCKER_USER.
DOCKER_USER is set with id -u and id -g outputs. Then, it is passed as a
build-args when running docker/build-push-action steps.
2024-03-15 16:32:58 +01:00
Lebaud Antoine 4579e668b6 ️(docker) add frontend dependencies to .dockerignore
Ignore frontend dependencies when coping frontend sources to build
the frontend Docker image. It would improve a bit performances locally,
when building the frontend image.
2024-03-15 16:32:58 +01:00
Lebaud Antoine 6ee39a01af 🎨(env) add missing newline at EOF
Found wrongly formatted files, fixed them.
2024-03-15 16:32:58 +01:00
Lebaud Antoine 3378d4b892 👷(frontend) push frontend image to DockerHub
Build and push the frontend image to DockerHub. Backend an Frontend
images will be stored in separate repos: people-backend and people-frontend.

It will be cleaner than managing all images in a single repo and creating
tags to discriminate frontend and backend images.

CI code is not factorized between jobs. Frontend and backend jobs could be
a bit factorized. Hovewer it might be a bit premature, and I prefer having
them decoupled for now. @rouja suggested to introduce a custom github actions
to avoid maintaining the same logic accross different repo.

Please not as the images are built from the same Dockerfile, it's important
to precise the right target.
2024-03-15 16:32:58 +01:00
Lebaud Antoine c40f656622 ⬆️(project) upgrade mail-builder Node Image
Updated to Node Image version 20 to align with the frontend image. It will
save us having two different Node versions in the same docker file, and
should not impact mail-builder.
2024-03-15 16:32:58 +01:00
Lebaud Antoine 1a3b396230 (frontend) introduce frontend Docker Image
To facilitate deployment on Kubernetes, we've introduced a Docker image for the
frontend. The Next.js project is built, and its static output is served using an
Nginx reverse proxy.

Since DevOps lacks a certified cold storage solution (e.g., S3) for serving
static files, we've decided to containerize the frontend as a quick workaround
for deploying staging environments.

Please note this Docker Image is WIP. One of the main issue still not resolved
concerns environment variables, which are only available when building the
Docker Image. Thus, having different environment variables values between
environment (dev, pre-prod, prod) will require us to build several frontend
images, and tag them with the appropriate target environment.

The `.env.production` values are not the final ones. For now, they were set to
dev values. It allows us to test the frontend image with the development setup.

Important: The frontend image is built-on top of an unprivileged Nginx image,
which exposes by default port 8080 instead of 80 for classic Nginx image.
You can find more info https://github.com/nginxinc/docker-nginx-unprivileged.

The Docker Compose Nginx service is used to proxy OIDC requests to keycloak,
in order to share the same host when initiating an OIDC flow, from outside and
inside docker virtual network.

All Nginx configurations related to serve frontend static build were moved to a
newly created conf file under src/frontend/apps/desk. When starting the frontend
image, we desire to start the minimum Nignx config required to serve frontend
statics.
2024-03-15 16:32:58 +01:00
Samuel Paccoud - DINUM 759c06a289 🧑‍💻(demo) improve distribution in number of identities per user
The current implementation of our product demo via the make command lacks
user identity for a significant portion of generated users, limiting the
realism of the showcased scenarios. As it stands, users created by the make
command lack complete information, such as full names and email addresses,
because they don't have any identity.

I tried to come up with the simplest solution:
We now generate a very small portion of our users with 0 identities. The
probability for users to have only 1 identity is the highest but they
can have up to 4 with decreasing probabilities. I removed the possibility
to set a maximum number of identities as it doesn't bring any value.

3% percent of the identities created will have no email and 3% no name.

Fixes https://github.com/numerique-gouv/people/issues/90
2024-03-14 19:39:22 +01:00
Anthony LC 97d9714a0d 🐛(app-desk) close dropDown when click outside
When we were clicking outside the dropdown,
the dropdown was not closing.
This commit fixes that.
2024-03-14 09:14:25 +01:00
Anthony LC c9e4d47d9d ️(frontend) clean yarn.lock
The yarn.lock file get full of garbage and old
dependencies after a while. This commit cleans it.
2024-03-13 11:31:50 +01:00
Anthony LC b30bb6ce2f ♻️(app-desk) improve useCunninghamTheme
Some tokens were not available from the hook.

We only had the tokens of the currentTheme available
but actually the theme is an augmentation of the
default theme, so we should use the default theme
tokens as a base and then override them with the
currentTheme tokens.
It is what this commit does.
2024-03-13 11:31:50 +01:00
Anthony LC 8ae7b4e8e9 ♻️(app-desk) cunningham theme more dsfr
Mockup doesn't seem totally synch with DSFR design,
this commit will adapt buttons and input in a more
DSFR way.
2024-03-13 11:31:50 +01:00
Anthony LC 8b014e289a (app-desk) component BoxButton
We often need unstyled button to wrap around some content,
we were using Cunningham's button for this purpose,
but it is not the best choice as lot of style is applied
to their buttons.
This component is a simple wrapper around the button
element with all the Box functionalities. Usefull
for wrapping icons by example.
2024-03-13 11:31:50 +01:00
Lebaud Antoine 7d65de1938 (backend) search user on her email and name
Compute Trigram similarity on user's name, and sum it up
with existing one based on user's email.

This approach is inspired by Contact search feature, which
computes a Trigram similarity score on first name and last
name, to sum up their scores.

With a similarity score influenced by both email and name,
API results would reflect both email and name user's attributes.

As we sum up similarities, I increased the similarity threshold.
Its value is empirical, and was finetuned to avoid breaking
existing tests. Please note, the updated value is closer to the
threshold used to search contacts.

Email or Name can be None. Summing two similarity scores with
one of them None, results in a None total score. To mitigate
this issue, I added a default empty string value, to replace
None values. Thus, the similarity score on this default empty
string value is equal to 0 and not to None anymore.
2024-03-11 20:23:05 +01:00
Lebaud Antoine b2d68df646 (backend) mock identities' name when searching a user
When testing user search, we generated few identities
with mocked emails.

Name attribute was introduced on Identity model. Currently
names are freely and randomly generated by the factory.

To make this mocked data more realist, mock also identities'
names to match their email.

It should not break existing tests, and will make them more
predictable when introducing advanced search features.
2024-03-11 20:23:05 +01:00
renovate[bot] 4f9f49ac9a ⬆️(dependencies) update js dependencies 2024-03-11 12:55:01 +01:00
renovate[bot] 421ef899da ⬆️(dependencies) update python dependencies 2024-03-11 12:25:23 +01:00
Lebaud Antoine b416c57bbe 🩹(frontend) fix layout overflow in Team info
Few minor layout issues were fixed.

First display label and dates inline, so they wrap nicely
when screen's size decreases. It also fixes the text overflow
when the screen's size is tiny.

Then, align screen with the Figma design, where items are
justified on the left of the Team info component.
2024-03-11 12:17:17 +01:00
Marie PUPO JEAMMET fa88f70cee 🐛(admin) prevent updating of invitations
Invitations cannot be updated for now. To reflect api behaviour,
we disable update in django admin as well.
2024-03-11 11:39:02 +01:00
Marie PUPO JEAMMET b2956e42d3 🛂(abilities) fix anonymous and unrelated users accessing resources
The function computing abilities return "True" for method get,
even if role of request user was None.
2024-03-11 11:39:02 +01:00
Marie PUPO JEAMMET 18971a10e0 🚨(tests) fix back-end tests warnings
Fixes a warnings in back-end tests suite:
- post_generation hooks save
- ordering for invitation and user viewsets
2024-03-11 11:39:02 +01:00
Marie PUPO JEAMMET 62758763df (api) add invitations CRUD
Nest invitation router below team router and add create endpoints for
authenticated administrators/owners to invite new members to their team,
list valid and expired invitations or delete invite altogether.

Update will not be handled for now. Delete and recreate if needed.
2024-03-11 11:39:02 +01:00
Anthony LC a15e46a2f9 🌐(app-desk) translate role in member grid
The roles in the member grid were not being translated.
This commit adds the translation for
the roles in the member grid.
2024-03-08 16:46:07 +01:00
Anthony LC e15c7cb2f4 (app-desk) integrate modal to update roles
Integrate the design and functionality
for updating a member's role.
Managed use cases:
 - when the user is an admin
 - when the user is the last owner
 - when the user want to update other orner
2024-03-08 15:55:26 +01:00
Anthony LC 0648c2e8d3 (app-desk) integrate grid member action
Integrate the action button dropdown in
the member grid. For the moment it will be
used to update the role of a member.
Manage use cases:
 - Does not display when member's role
 - Does not display when member is an admin
   that wants to update owner role.
2024-03-08 15:55:26 +01:00
Anthony LC b0d3f73ba2 🏷️(app-desk) update interfaces business logic
Update interfaces of User / Team / Access
to get what is needed for the frontend.
2024-03-08 15:55:26 +01:00
Anthony LC 9be973a776 (app-desk) add useUpdateTeamAccess react-query hook
Add the hook useUpdateTeamAccess, it will be used to
change the role of a member.
2024-03-08 15:55:26 +01:00
Anthony LC 150258b5a4 ⬆️(app-desk) upgrade Cunningham design system
The last version of the Cunningham design system
has some new features that we need in this
feature.
2024-03-08 15:55:26 +01:00
Anthony LC b41fd1ab69 (app-desk) component DropButton
Button that opens a dropdown menu when clicked.
It will manage all the state related to
the dropdown menu.
Can be used controlled or uncontrolled.
2024-03-08 15:55:26 +01:00
Lebaud Antoine b5ce19a28e 📝(backend) clarify how team accesses are queried
Break copy/pasted comment from Joanie in several inline
comments, that are more specific and easy to read.

Hopefully, it will help future myself understanding this
queryset and explaining it.
2024-03-07 19:55:53 +01:00
Lebaud Antoine 163f987132 🐛(backend) fix team accesses abilities
To compute accesses's abilities, we need to determine
which is the user's role in the team.

We opted for a subquery, which retrieves the user's role
within the team and annotate queryset's results.

The current subquery was broken, and retrieved other
users than the request's user. It led to compute accesses'
abilities based on a randomly picked user.
2024-03-07 19:55:53 +01:00
Lebaud Antoine e9482a985f (backend) enhance tests when listing team accesses
Abilities on team accesses are computed based on request user role.
Thus, members' roles in relation with user's role matters a lot, to
ensure the abilities were correctly computed.

Complexified the test that lists team accesses while being authenticated.
More members are added to the team with privileged roles. The user
is added last to the less with the less privileged role, "member".

Order matters, because when computing the sub query to determine
user's role within the team, code use the first result value to set the
role to compute abilities.
2024-03-07 19:55:53 +01:00
Lebaud Antoine 43d802a73b 🎨(backend) early return in User factory
Avoid unnecessary nesting when code can early return.
Also, rename "item" to a more explicit name "user_entry".

it's very nit-picking, sorry.
2024-03-07 11:42:34 +01:00
Lebaud Antoine b4e4940fd7 🚨(backend) update Ruff config to suppress deprecation warning
When running make ruff-check, a warning informs the user that
some config are deprecated, and gives her the step to migrate.

This warning appears after Ruff released its v0.2.0.
Fix it, by keeping our pyproject.toml up to date.
2024-03-07 11:31:31 +01:00
Lebaud Antoine 5ec0dcf206 🚨(backend) follow Ruff 2024.2 style introduced in v0.3.0
We recently updated Ruff from 0.2.2 to v0.3, which introduced
Ruff 2024.2 style. This new style updated Ruff formatter's behavior,
making our make lint command fails.

Ruff 2024.2 style add a blank line after the module docstring.
Please take a look at Ruff ChangeLog to get more info.
2024-03-07 11:31:31 +01:00
renovate[bot] dad81c8d73 ⬆️(dependencies) update python dependencies 2024-03-07 11:31:31 +01:00
Anthony LC b010a7b5a7 🤡(app-desk) remove mock endpoint teams accesses
The endpoint teams/accesses is ready.
We remove the mock and the related libraries
and use the real endpoint.
2024-03-04 17:52:52 +01:00
Anthony LC e16f51ca20 (app-desk) integrate member list design
Integrate the member list design in the team page
based on the mockup.
2024-03-04 15:49:50 +01:00
Anthony LC 9d30bc88f1 🤡(app-desk) mock endpoint teams/:teamId/accesses/
We intercept the request to the endpoint teams/:teamId/accesses/
and return a json with dummy accesses of the team.
2024-03-04 15:49:50 +01:00
Anthony LC 1da978e121 (app-desk) add useTeamAccesses react-query hook
Add the hook useTeamAccesses, it queries the accesses
if a team. It is paginated.
2024-03-04 15:49:50 +01:00
Anthony LC 3bf8965209 🚚(app-desk) rename and group team Panel
- add folder Panel
- rename PanelTeams to TeamList
- rename PanelTeam to TeamItem
2024-03-04 15:49:50 +01:00
renovate[bot] 5b9d2cccc5 ⬆️(dependencies) update js dependencies 2024-03-04 15:16:15 +01:00
Marie PUPO JEAMMET 81243cfc9a (api) return user id, name and email on /team/<id>/accesses/
Add serializers to return basic user info when listing /team/<id>/accesses/
endpoint. This will allow front-end to retrieve members info without having
to query API for each user.id.
2024-03-03 23:00:05 +01:00
Marie PUPO JEAMMET 70b1b996df 🏗️(tests) separate team accesses tests by action
Small commit to separate team accesses tests into diferent files.
2024-03-03 23:00:05 +01:00
renovate[bot] 29d274ab7c ⬆️(dependencies) update python dependencies 2024-02-28 14:21:49 +01:00
Anthony LC f17771fc9b (app-desk) fix error warning jest test logout
We had a error warning in the jest test logout with fetchApi,
window.location.replace had to be mocked to avoid the error.
2024-02-26 16:31:02 +01:00
Anthony LC 65e78cde68 ⬇️(app-desk) downgrade @openfun/cunningham-react
Downgrade @openfun/cunningham-react to 2.4.0, because of a
compatibility problem with Jest.

We add this package with this version to the ignore list
in renovate.json, when we will have a new compatible version, we will
remove it from the ignore list.
2024-02-26 16:31:02 +01:00
Anthony LC 33288ab225 ⬇️(app-desk) downgrade @types/react-dom
Downgrade @types/react-dom to 18.2.18.
The lastest version seems to have lot of compatibility
issues with other packages:
- @openfun/cunningham-react
- @tanstack/react-query-devtools
- next

We add this package with this version to the ignore list
in renovate.json, when we will have a new compatible version, we will
remove it from the ignore list.
2024-02-26 16:31:02 +01:00
renovate[bot] a3c0069697 ⬆️(dependencies) update js dependencies 2024-02-26 16:31:02 +01:00
Anthony LC b307b373bb (app-desk) add luxon to display date
Add luxon to display date in the team description.
The date are internationalized and formatted as the
mockup requested.
2024-02-25 20:48:51 +01:00
Anthony LC f21740e5e5 👔(backend) add read fields to teams api
Some fields are missing for the frontend.
Add read fields to teams api:
- created_at
- updated_at
2024-02-25 20:48:51 +01:00
Anthony LC 035a7a1fcc 🏷️(app-desk) rename type TeamResponse to Team
Rename type TeamResponse to Team, the components
using this type don't need to know that the data
is coming from the API.
2024-02-25 20:48:51 +01:00
Anthony LC 3f7e5c88bc (app-desk) change backend settings for e2e tests
When we run e2e tests with the CI, we are doing lot of
calls to the backend in a short amount of time. This can
lead to a rate limit particulary on the "user/me" endpoint.
To avoid this, we will use different backend settings
for the e2e tests.
2024-02-25 20:31:27 +01:00
Anthony LC 51064ec236 🥅(app-desk) better error management
We don't know how the error body returned by the
api will be, so we handle it in a more generic way.
2024-02-25 20:31:27 +01:00
Anthony LC 195e738c3c 🚸(app-desk) add 404 page
- Add a 404 page.
- Redirect to 404 page when a team is not found.
2024-02-25 20:31:27 +01:00
Samuel Paccoud - DINUM 54497c1261 🔒️(settings) remove default value for setting OIDC_RP_CLIENT_SECRET
Secret settings should not contain any default value as we risk shipping
them to production. The default value can be set via an environment variable
in the `env.d/development/common` file: OIDC_RP_CLIENT_SECRET
2024-02-23 17:15:46 +01:00
Anthony LC 8d7c545d1a 🗃️(backend) add name field to identity
We need a name for the user when we display the members in the
frontend. This commit adds the name column to the identity model.
We sync the Keycloak user with the identity model when the user
logs in to fill and udpate the name automatically.
2024-02-23 17:15:46 +01:00
Anthony LC 8cbfb38cc4 🚚(app-desk) alias home with teams url path
In order the keep the url path consistent and correctly
structured, the homepage is aliased with the teams page.
2024-02-22 16:20:39 +01:00
Anthony LC 4bd8095975 🐛(i18n) dot in key was not added
The parser was not adding the dot in the key to the
json file sent to crowdin. Some translations were
not being translated correctly.
2024-02-22 14:28:04 +01:00
Anthony LC fc8dc24ba2 (app-desk) add team info component
Add the team info component to the team page.
This component shows some informations about the team:
  - name
  - amount of members
  - date created
  - date updated
2024-02-22 14:28:04 +01:00
Anthony LC 95219a33b3 💄(app-desk) change the text color
The text color from the mockup is not blue but
a dark grey. This commit changes the base color of
the Text component to match the mockup.
2024-02-22 14:28:04 +01:00
Lebaud Antoine 26fbe9fbe7 ✏️(project) fix minor typos
Found typos and fixed them.
2024-02-22 11:59:36 +01:00
Lebaud Antoine 4cacfd3a45 ♻️(frontend) switch to Authorization Code flow
Instead of interacting with Keycloak, the frontend navigate to the
/authenticate endpoint, which starts the Authorization code flow.

When the flow is done, the backend redirect back to the SPA,
passing a session cookie and a csrf cookie.

Done:
- Query GET user/me to determine if user is authenticated yet
- Remove Keycloak js dependency, as all the OIDC logic is handled by the backend
- Store user's data instead of the JWT token
2024-02-22 11:59:36 +01:00
Lebaud Antoine 38c4d33791 (backend) support Authorization code flow
Integrate 'mozilla-django-oidc' dependency, to support
Authorization Code flow, which is required by Agent Connect.

Thus, we provide a secure back channel OIDC flow, and return
to the client only a session cookie.

Done:
- Replace JWT authentication by Session based authentication in DRF
- Update Django settings to make OIDC configurations easily editable
- Add 'mozilla-django-oidc' routes to our router
- Implement a custom Django Authentication class to adapt
'mozilla-django-oidc' to our needs

'mozilla-django-oidc' routes added are:
- /authenticate
- /callback (the redirect_uri called back by the Idp)
- /logout
2024-02-22 11:59:36 +01:00
Lebaud Antoine ec28c28d47 (backend) drop JWT authentication in API tests
Force login to bypass authorization checks when necessary.

Note: Generating a session cookie through OIDC flow
is not supported while testing our API.
2024-02-22 11:59:36 +01:00
Lebaud Antoine 927d0e5a22 🔧(project) proxy Keycloak with nginx
Backend and Frontend send requests to Keycloak through Nginx.

Thus, all requests from frontend and backend shared a same host
when received by Keycloak.

Otherwise, the flow is initiated from http://localhost:8080. When the Backend
calls token endpoint from Keycloak container at http://keycloak:8080,
the JWT token issuer and sender are mismatching.
2024-02-22 11:59:36 +01:00
Lebaud Antoine 699854e76b 🔧(project) configure standard OIDC flow in Keycloak
Enforce Authorization Code flow, and disable Implicit flow.

Done:
- Rename client people-front to people
- Add a client secret shared with the backend
- Add allowed redirect uris
- Disable implicit flow and enable Authorization Code flow without PCKE
- Sign userinfo endpoint to return application/jwt content
2024-02-22 11:59:36 +01:00
Marie PUPO JEAMMET 63e059a4e6 🔥(backend) remove users systematic return of profile_contact
Custom UserManaged returned profile_contact field when returning users.
While this may be useful later, we'd currently rather have it return users.
2024-02-21 17:49:19 +01:00
Anthony LC 5113eb013b 💄(app-desk) highlight team selected
Highlight the selected team in the team list.
2024-02-20 16:25:31 +01:00
Anthony LC 45d05873e2 🔥(app-desk) remove FAQ part in header
The FAQ part in the header is not displayed in
the mockup anymore, we remove it.
2024-02-20 16:25:31 +01:00
Anthony LC 1f3ab759d7 ⬇️(app-desk) downgrade @types/react-dom
Downgrade @types/react-dom to 18.2.18.
The lastest version seems to have lot of compatibility
issues with other packages:
- @openfun/cunningham-react
- @tanstack/react-query-devtools
- next
2024-02-19 16:58:23 +01:00
renovate[bot] 8b5f5bf092 ⬆️(dependencies) update js dependencies 2024-02-19 16:58:23 +01:00
Anthony LC cd2efbe40d ️(frontend) add stale cache
We add a stale cache to reduce the number of requests to
the server.
This will help to improve the performance of the application.
2024-02-19 15:22:09 +01:00
Anthony LC 8b0c20dbdc (app-desk) add team page
- add the team page, you can access to
the team page with the id of the team.
- Add link to the panel team to access to the
team page.
2024-02-19 15:22:09 +01:00
Anthony LC d0562029e8 (app-desk) integrate team endpoint
Integrate team endpoint with react-query.
It will be used to get the team on the team page.
2024-02-19 15:22:09 +01:00
Anthony LC 77efb1a89c 💄(app-desk) do not count the owner in item list icon
In every team, the owner is always included in it,
so we shouldn't count the owner when we display the icon
to say if a team has members.
2024-02-19 15:04:53 +01:00
Anthony LC 4566e132e1 💄(app-desk) integrate the create team design
- Integrate the create team design based from the
mockup
- Manage the different states of the create team
2024-02-19 15:04:53 +01:00
Anthony LC f818715a45 🚚(app-desk) change next router to pages
2 routers exists in Next.js, "app" router and "page" router.
The "app" router has a bug introduced in Next.js 13.4.14, which is
not fixed yet. For the moment we cannot use dynamic routes with
"app" router with an SPA. As advised by the Next.js team, we
migrated to the "pages" router.
2024-02-19 15:04:53 +01:00
renovate[bot] 7d90092020 ⬆️(dependencies) update python dependencies 2024-02-19 10:08:28 +01:00
Lebaud Antoine 469903c9eb ✏️(project) fix minor typos
Found typos, fixed them.
2024-02-16 16:03:52 +01:00
Lebaud Antoine 1f1253ab21 🔧(backend) activate container liveness probes
Enabled Dockerflow Django app by activating liveness probes. The previously
unavailable routes such as `__heartbeat__` and `__lbheartbeat__` are now
accessible. New endpoints include:
* GET /__version__
* GET /__heartbeat__
* GET /__lbheartbeat__
2024-02-16 15:16:30 +01:00
Lebaud Antoine 6620932371 🐛(project) run production image locally with docker-compose
The local deployment of the Production image through docker-compose was
failing due to issues in the Django configurations, influenced by Joanie.

The bug stemmed from a dependency on a development-specific package
(drf-spectacular-sidecar) while attempting to run the application in
production mode.

Changes Made:
- Introduced new Django settings for local demo environments.
- Uncommented the nginx configuration to address the production image
  deployment issues.
2024-02-16 15:16:30 +01:00
Marie PUPO JEAMMET 8e537d962c 🗃️(database) create invitation model
Create invitation model, factory and related tests to prepare back-end
for invitation endpoints. We chose to use a separate dedicated model
for separation of concerns, see
https://github.com/numerique-gouv/people/issues/25
2024-02-15 19:42:40 +01:00
Lebaud Antoine 6080af961a 🩹(backend) fix Django Admin UserAdmin page
*Broken Identity string representation
Resolving a format error in the Identity string representation caused by
potential None values in the email field. This issue was discovered when
attempting to access the User details page in the Django Admin

*Broken User creation form
The replacement of the User's username with an email led to errors in
the UserAdmin class. The base class used the 'username' field in the
'add_fieldsets' attribute. This problem was discovered while attempting to
create a new user in the Django Admin.
2024-02-15 15:54:07 +01:00
Anthony LC aba376702f 🐛(app-desk) fix circular dependency problem
Some circular dependency problems started to appear with Jest.
This commit fixes the problem by removing the feature index
file and moving the exports to the respective feature.
2024-02-15 09:56:07 +01:00
Anthony LC 1e38174c1b ️(e2e) add workers to playwright with CI
We have added workers to playwright to run tests in parallel,
this will help us to run tests faster.
The tests run on a commun database, so to keep the tests
stable between browsers, we created 3 different
users to run the tests, it will avoid to have commun data
stepping on each other.
2024-02-15 09:56:07 +01:00
Anthony LC 0ab9f16cb3 🌐(app-desk) improve message missing translations
- Improve the message when a translation is missing in the app,
now it will show the key of the missing translation.
- It also show the translation that are in crowdin but not in the
app.

- Add missing translations
2024-02-15 09:56:07 +01:00
Anthony LC c71463a385 🚚(app-desk) create route teams/create
- Create the routes teams/create.
- Add link to the buttons routing to this page
- Adapt design
2024-02-15 09:56:07 +01:00
Anthony LC 47ffa60a94 ️(app-desk) add infinite scrool to teams list
If we have a big list of teams, we need to add infinite
scroll to avoid loading all the teams at once.
2024-02-15 09:56:07 +01:00
Anthony LC fafffd2391 (app-desk) add interaction to sort button
In the team panel, we have the possibility to sort the teams.
This commit adds the interaction to the button.
2024-02-15 09:56:07 +01:00
Anthony LC 36e2dc2378 ♻️(backend) api teams list ordering
Give the possibility to order the teams list by
creation date.
By default the list is ordered by
creation date descending.
2024-02-15 09:56:07 +01:00
Anthony LC 4f0465fd32 (app-desk) integrate teams panel design
- Integrate teams panel design based from the mockup.
- List teams from the API.
2024-02-15 09:56:07 +01:00
Anthony LC 148ea81aa9 🎨(app-desk) box default direction column
Change the default direction of the Box component to column
to have a behavior closer to a normal div.
2024-02-15 09:56:07 +01:00
595 changed files with 53755 additions and 16740 deletions
+3
View File
@@ -31,3 +31,6 @@ db.sqlite3
.mypy_cache
.pylint.d
.pytest_cache
# Frontend dependencies
node_modules
+2 -2
View File
@@ -9,9 +9,9 @@ We primarily use GitHub as an issue tracker. If however you're encountering an i
---
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/people).
Please make sure you have read our [main Readme](https://github.com/suitenumerique/people).
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/people/issues).
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/people/issues).
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
+35
View File
@@ -0,0 +1,35 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_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}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_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}" $ARGOCD_PRODUCTION_WEBHOOK_URL
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
+94 -9
View File
@@ -1,4 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -11,34 +12,118 @@ on:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
jobs:
build-and-push:
trivy-scan:
runs-on: ubuntu-latest
steps:
-
name: Checkout
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people
images: |
lasuite/people-backend
lasuite/people-frontend
-
name: Load sops secrets
uses: rouja/actions-sops@main
name: Run trivy scan (backend)
uses: numerique-gouv/action-trivy-cache@main
with:
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/people-backend:${{ github.sha }}'
-
name: Run trivy scan (frontend)
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target frontend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-backend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
with:
name: "version.json"
json: '{"source":"${{github.repository}}", "version":"${{github.ref_name}}", "commit":"${{github.sha}}", "build": "NA"}'
dir: 'src/backend'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-frontend
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
with:
name: "version.json"
json: '{"source":"${{github.repository}}", "version":"${{github.ref_name}}", "commit":"${{github.sha}}", "build": "NA"}'
dir: 'src/frontend/apps/desk'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend
- build-and-push-backend
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 }}
+145 -121
View File
@@ -4,8 +4,6 @@ on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- '*'
@@ -16,9 +14,9 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
@@ -39,9 +37,9 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 0
- name: Check that the CHANGELOG has been modified in the current branch
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
@@ -49,7 +47,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -58,11 +56,9 @@ jobs:
exit 1
fi
test-front:
install-front:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/frontend/
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -71,89 +67,170 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Install dependencies
run: yarn install --frozen-lockfile
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
test-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Test App
run: yarn app:test
- name: Test Translations
run: yarn i18n:test
run: cd src/frontend/ && yarn app:test
lint-front:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/frontend/
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Check linting
run: yarn lint
run: cd src/frontend/ && yarn lint
test-e2e:
runs-on: ubuntu-latest
needs: [build-mails, build-front]
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Download mails' templates
uses: actions/download-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
- name: Build and Start Docker Servers
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make run
- name: Apply DRH migrations
- name: Apply DRF migrations
run: |
make migrate
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Setup Dimail DB
run: |
make dimail-setup-db
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- uses: actions/upload-artifact@v3
- name: Save logs
if: always()
run: docker compose logs > src/frontend/apps/e2e/report/logs.txt
- name: Save Dimail logs
if: always()
run: docker compose logs dimail > src/frontend/apps/e2e/report/dimail-logs.txt
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
name: playwright-report-${{ matrix.shardIndex }}
path: src/frontend/apps/e2e/report/
retention-days: 7
tests-e2e-feedback:
runs-on: ubuntu-latest
needs: [test-e2e]
if: always()
steps:
- name: All tests ok
if: ${{ !(contains(needs.*.result, 'failure')) }}
run: exit 0
- name: Some tests failed
if: ${{ contains(needs.*.result, 'failure') }}
run: exit 1
build-mails:
runs-on: ubuntu-latest
defaults:
@@ -161,7 +238,7 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
@@ -172,6 +249,11 @@ jobs:
run: yarn install --frozen-lockfile
- name: Build mails
run: yarn build
- name: Persist mails' templates
uses: actions/upload-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
lint-back:
runs-on: ubuntu-latest
@@ -180,9 +262,9 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install development dependencies
@@ -196,6 +278,7 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: build-mails
defaults:
run:
working-directory: src/backend
@@ -216,7 +299,7 @@ jobs:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: people.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
DJANGO_JWT_PRIVATE_SIGNING_KEY: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
DB_HOST: localhost
DB_NAME: people
DB_USER: dinum
@@ -225,13 +308,18 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Download mails' templates
uses: actions/download-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install development dependencies
@@ -244,67 +332,3 @@ jobs:
run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
i18n-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: '3.10'
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
- name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Download sources from Crowdin to stay synchronized
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin download sources -c /app/crowdin/config.yml
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml
+35
View File
@@ -0,0 +1,35 @@
name: Release Chart
run-name: Release Chart
on:
push:
branches:
- 'main'
paths:
- ./src/helm/desk/**
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cleanup
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
with:
charts_dir: ./src/helm
linting: on
token: ${{ secrets.GITHUB_TOKEN }}
-20
View File
@@ -1,20 +0,0 @@
SOPS_PRIVATE=ENC[AES256_GCM,data:uMsnbpi1FFxO430iptp2axlH/souCPCJ/afCqh/kIDWs8xWHu0xJ2o6PlNOgb4l1gHF8sy4eSHFOW5HPKVbZ9gafRIL0JYHJr3A=,iv:IgaHDad7IuW2wFoxGALLDCs+UiSd3rEYwGNSX38wUfI=,tag:zs4wn4hDm5fghfI/7iH6CQ==,type:str]
CROWDIN_API_TOKEN=ENC[AES256_GCM,data:tZRGFs792GjKYGit+oNubCwPbjWarhlgcyQ7oStO8K3nao0lEzLrm3+ARaOEXkzEkKSal2N1c+vlYIjRyzV1X7WZ7nZQWBiihEJgAfF3NGM=,iv:hohak/1niu5kV/W4XA0KEE3UB0BuNCDxbRxb0O+Aocs=,tag:Ssi0HyEhCKb9+WFyp3/yBQ==,type:str]
CROWDIN_BASE_PATH=ENC[AES256_GCM,data:m44KrW5xJDE=,iv:bSyKrKQ0Z1yzrNaejAUyD+n1Z9z+ci92GoyS5KiiFKI=,tag:dDtpbn+6LkPwsg+qgMNWvA==,type:str]
CROWDIN_PROJECT_ID=ENC[AES256_GCM,data:Gonh6ps7,iv:yUqPty+R060m6CYrDf7na2f6h0aRpIhRCXZoJW4ThJg=,tag:ewupoenajsc9o0Aj0r/niw==,type:str]
DOCKER_HUB_PASSWORD=ENC[AES256_GCM,data:CBjUvS/UQcqdEv8=,iv:zJKGyyRnq+zxhKkYS5h3zw/J1320zrAqpiObcwiHWsA=,tag:OoQNe+4K63KXTWNsvjRZNQ==,type:str]
DOCKER_HUB_USER=ENC[AES256_GCM,data:ivs7oeBBdA==,iv:cKAQJ071XhcRuFyeHhZkscvvz7sHrKIPJg5pt76hCXg=,tag:2VRmp1ULWvVH8NHcBifBGA==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIbkIyRi9jTGEzL2RZcGFS\nVkhuN2xMN1FBcm04eGJQUnpvQVRpdmFmaFF3CjdoNVhSM0lrb0lTSXAyUytSKzhG\nNERYaC80OThmUUxCSmt2U2Q0Mno3b0kKLS0tIFRjQzYyOEcwTnRScGhzaGxwaGFL\namxEUXNaeVhWUjYwbzR2Z1Uzb3JsTlEK6tyhbRmcUP4Aql49DPkrYb5tbwvK2EdA\ntvyPQo4pPit+pzgqsVgW+O8Wo4/rYLlITfuVRrOfHEaH3wmf6hziSw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIL2NwVjVtVmd1ZE15WmFC\nVGl5cnU1OHVUcXAxWmZCQXBJbXBlcXFUN0RzClpOVXBTek9wbVB6M2s5TWlFbUo3\nelZrT3dsK3p6cEJGTGJoSTRvaEh4NW8KLS0tIGlsL29oSEthU28xV0RERFIxTnZR\nSSs3YjdUT0NyTEtlbjlmZXVOaTd3SzgK5jFJGREfJ/HVytWsCKWFsqM5JoaFSnhv\n538KzzldzcbtWfnY+bQ6A2EBjETwOzCTuQB8axAMj0URXPI+qelKIQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_1__map_recipient=age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzOWtwSytMNGRmSHNQN0Zz\nV1RTWktXT1c5VjYvNTg2V0x1U3JSTFA1dmtvCnc4RHRodjVxSzcrZXJycCtJMGNy\nZ3ZwbTFDTUc4TTMxbm8rNXQ1ME90Q1UKLS0tIERPUmg1dSt5OWQwaXV0Y2FiYWdt\nVzlhcFNvUS9Nd3A5ZTYwT2lLUU5WVU0KGHSm1BQ3voKs98WiXNLO6hlqoiQmi1/F\n2RCBkE4/gOVyAmJAjOFizaF0Dhd7Ba4KS5l5QylFHloL8XtyixEhog==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmYlB6cEZKK3NLbm5Ob0hi\nS3M0OFoyTHhVYVA1WmcvSHc2MUErQ2JyaUh3Cnp1NlYvU1BPUnVON29UZWUvWmEz\nOW85N013VUJ3Q1ZZUmMvTWdYclBZTUkKLS0tIG1KODdkazhTUHRPMXZXQnJFNXJY\nOFlSNjZCbkpxcm9tN1dFTkZ4K1lETFUKhiPwKEG71OlTcK/Ul1GKGayLy025vAmo\nNgQUhbqXf7KmqDAmrQNzXTsLsOpRi33l66jFSGkTsFtiXNlmjFljKg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_3__map_recipient=age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJUzI2eDB0MU41YWoxcjAv\nOU8yMlowb3IybCtzcFBBTEVlUzdBczZkQlFNCk5tbnhLR2kweGNFZmx1OFgwaWZU\nTzRKZTBZamJjRFNYOUJlYmxTUVg4S2MKLS0tIDNGdlVVc0hLK1BPTUJVc0tPb1lK\ndVVWVi9GRkxwYXR1cXMrdnlsejJBeGsKKmVWvIMrBYH+UrDMkZPxN8KWnCgA6WK2\nbexMYr2AIF2QMbhck7XW2NuvAwvwjbJMfcd+cp9boe+EcC4YjdJZlw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_4__map_recipient=age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
sops_lastmodified=2024-02-05T17:45:08Z
sops_mac=ENC[AES256_GCM,data:WMsYYvGId0UlRhiw5C4TTpP/7oIIgWteGRtX7Nlo3qQwBc1LSUyN+7kblkXWD+nNG51B0xfl9E5QdXO3Ao2WskQZZEJqqkmt6wSw/ScSPcH56FoIqBhoGsPKyJOZkK0zojnCJQLniTDlISG0UiiJ4KHh8FJDWYbzAy2zFEm3Et8=,iv:zB2hyxnKiA3flsMQ7XFOT/fR9hamd6YWo3BNwce4oWM=,tag:uZ2Ia8sR5R8fmURtJ+PojA==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.8.1
+2 -6
View File
@@ -42,7 +42,6 @@ env.bak/
venv.bak/
env.d/development/*
!env.d/development/*.dist
env.d/terraform
# npm
node_modules
@@ -59,13 +58,9 @@ src/frontend/tsclient
# Logs
*.log
# Terraform
.terraform
*.tfstate
*.tfstate.backup
# Test & lint
.coverage
coverage.json
.pylint.d
.pytest_cache
db.sqlite3
@@ -79,3 +74,4 @@ db.sqlite3
.vscode/
*.iml
.devcontainer/
.tool-versions
+3
View File
@@ -0,0 +1,3 @@
[submodule "secrets"]
path = secrets
url = ../secrets
+10 -12
View File
@@ -1,13 +1,11 @@
creation_rules:
# Here we have
# - Jacques key-id: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
# - github-repo key-id: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
# - Anthony Le-Courric key-id: age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv
# - Antoine Lebaud key-id: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
# - Marie Pupo Jeammet key-id: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
- age:
age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x,
age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7,
age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv,
age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3,
age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
+308
View File
@@ -7,3 +7,311 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.12.0] - 2025-02-18
### Added
- ✨(domains) allow user to re-run all fetch domain data from dimail
- ✨(domains) display DNS config expected for domain with required actions
- ✨(domains) check status after creation
- ✨(domains) display required actions to do on domain
- ✨(plugin) add CommuneCreation plugin with domain provisioning #658
- ✨(frontend) display action required status on domain
- ✨(domains) store last health check details on MailDomain
- ✨(domains) add support email field on domain
## [1.11.0] - 2025-02-07
### Added
- ✨(api) add count mailboxes to MailDomain serializer
- ✨(dimail) manage 'action required' status for MailDomain
- ✨(domains) add action required status on MailDomain
- ✨(dimail) send pending mailboxes upon domain activation
### Fixed
- ✨(auth) fix empty names from ProConnect #687
- 🚑️(teams) do not display add button when disallowed #676
- 🚑️(plugins) fix name from SIRET specific case #674
- 🐛(api) restrict mailbox sync to enabled domains
## [1.10.1] - 2025-01-27
### Added
- ✨(dimail) management command to fetch domain status
### Changed
- ✨(scripts) adapts release script after moving the deployment part
### Fixed
- 🐛(dimail) fix imported mailboxes should be enabled instead of pending #659
- ⚡️(api) add missing cache for stats endpoint
## [1.10.0] - 2025-01-21
### Added
- ✨(api) create stats endpoint
- ✨(teams) add Team dependencies #560
- ✨(organization) add admin action for plugin #640
- ✨(anct) fetch and display organization names of communes #583
- ✨(frontend) display email if no username #562
- 🧑‍💻(oidc) add ability to pull registration ID (e.g. SIRET) from OIDC #577
### Fixed
- 🐛(frontend) improve e2e tests avoiding race condition from mocks #641
- 🐛(backend) fix flaky test with search contact #605
- 🐛(backend) fix flaky test with team access #646
- 🧑‍💻(dimail) remove 'NoneType: None' log in debug mode
- 🐛(frontend) fix flaky e2e test #636
- 🐛(frontend) fix disable mailbox button display #631
- 🐛(backend) fix dimail call despite mailbox creation failure on our side
- 🧑‍💻(user) fix the User.language infinite migration #611
## [1.9.1] - 2024-12-18
## [1.9.0] - 2024-12-17
### Fixed
- 🐛(backend) fix manage roles on domain admin view
### Added
- ✨(backend) add admin action to check domain health
- ✨(dimail) check domain health
- ✨(frontend) disable mailbox and allow to create pending mailbox
- ✨(organizations) add siret to name conversion #584
- 💄(frontend) redirect home according to abilities #588
- ✨(maildomain_access) add API endpoint to search users #508
## [1.8.0] - 2024-12-12
### Added
- ✨(contacts) add "abilities" to API endpoint data #585
- ✨(contacts) allow filter list API with email
- ✨(contacts) return profile contact from same organization
- ✨(dimail) automate allows requests to dimail
- ✨(contacts) add notes & force full_name #565
### Changed
- ♻️(contacts) move user profile to contact #572
- ♻️(contacts) split api test module in actions #573
### Fixed
- 🐛(backend) fix manage roles on domain admin view
- 🐛(mailbox) fix activate mailbox feature
- 🔧(helm) fix the configuration environment #579
## [1.7.1] - 2024-11-28
## [1.7.0] - 2024-11-28
### Added
- ✨(mailbox) allow to activate mailbox
- ✨(mailbox) allow to disable mailbox
- ✨(backend) add ServiceProvider #522
- 💄(admin) allow header color customization #552
- ✨(organization) add API endpoints #551
### Fixed
- 🐛(admin) add organization on user #555
## [1.6.1] - 2024-11-22
### Fixed
- 🩹(mailbox) fix status of current mailboxes
- 🚑️(backend) fix claim contains non-user field #548
## [1.6.0] - 2024-11-20
### Removed
- 🔥(teams) remove search by trigram for team access and contact
### Added
- ✨(domains) allow creation of "pending" mailboxes
- ✨(teams) allow team management for team admins/owners #509
### Fixed
- 🔊(backend) update logger config to info #542
## [1.5.0] - 2024-11-14
### Removed
- ⬆️(dependencies) remove unneeded dependencies
- 🔥(teams) remove pagination of teams listing
- 🔥(teams) remove search users by trigram
- 🗃️(teams) remove `slug` field
### Added
- ✨(dimail) send domain creation requests to dimail #454
- ✨(dimail) synchronize mailboxes from dimail to our db #453
- ✨(ci) add security scan #429
- ✨(teams) register contacts on admin views
### Fixed
- 🐛(mail) fix display button on outlook
- 💚(ci) improve E2E tests #492
- 🔧(sentry) restore default integrations
- 🔇(backend) remove Sentry duplicated warning/errors
- 👷(ci) add sharding e2e tests #467
- 🐛(dimail) fix unexpected status_code for proper debug #454
## [1.4.1] - 2024-10-23
### Fixed
- 🚑️(frontend) fix MailDomainsLayout
## [1.4.0] - 2024-10-23
### Added
- ✨(frontend) add tabs inside #466
### Fixed
- ✏️(mail) fix typo into mailbox creation email
- 🐛(sentry) fix duplicated sentry errors #479
- 🐛(script) improve and fix release script
## [1.3.1] - 2024-10-18
## [1.3.0] - 2024-10-18
### Added
- ✨(api) add RELEASE version on config endpoint #459
- ✨(backend) manage roles on domain admin view
- ✨(frontend) show version number in footer #369
- 👔(backend) add Organization model
### Changed
- 🛂(backend) match email if no existing user matches the sub
### Fixed
- 💄(mail) improve mailbox creation email #462
- 🐛(frontend) fix update accesses form #448
- 🛂(backend) do not duplicate user when disabled
## [1.2.1] - 2024-10-03
### Fixed
- 🔧(mail) use new scaleway email gateway #435
## [1.2.0] - 2024-09-30
### Added
- ✨(ci) add helmfile linter and fix argocd sync #424
- ✨(domains) add endpoint to list and retrieve domain accesses #404
- 🍱(dev) embark dimail-api as container #366
- ✨(dimail) allow la regie to request a token for another user #416
- ✨(frontend) show username on AccountDropDown #412
- 🥅(frontend) improve add & update group forms error handling #387
- ✨(frontend) allow group members filtering #363
- ✨(mailbox) send new mailbox confirmation email #397
- ✨(domains) domain accesses update API #423
- ✨(backend) domain accesses create API #428
- 🥅(frontend) catch new errors on mailbox creation #392
- ✨(api) domain accesses delete API #433
- ✨(frontend) add mail domain access management #413
### Fixed
- ♿️(frontend) fix left nav panel #396
- 🔧(backend) fix configuration to avoid different ssl warning #432
### Changed
- ♻️(serializers) move business logic to serializers #414
## [1.1.0] - 2024-09-10
### Added
- 📈(monitoring) configure sentry monitoring #378
- 🥅(frontend) improve api error handling #355
### Changed
- 🗃️(models) move dimail 'secret' to settings #372
### Fixed
- 🐛(dimail) improve handling of dimail errors on failed mailbox creation #377
- 💬(frontend) fix group member removal text #382
- 💬(frontend) fix add mail domain text #382
- 🐛(frontend) fix keyboard navigation #379
- 🐛(frontend) fix add mail domain form submission #355
## [1.0.2] - 2024-08-30
### Added
- 🔧Runtime config for the frontend (#345)
- 🔧(helm) configure resource server in staging
### Fixed
- 👽️(mailboxes) fix mailbox creation after dimail api improvement (#360)
## [1.0.1] - 2024-08-19
### Fixed
- ✨(frontend) user can add mail domains
## [1.0.0] - 2024-08-09
### Added
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/suitenumerique/people/compare/v1.12.0...main
[1.12.0]: https://github.com/suitenumerique/people/releases/v1.12.0
[1.11.0]: https://github.com/suitenumerique/people/releases/v1.11.0
[1.10.1]: https://github.com/suitenumerique/people/releases/v1.10.1
[1.10.0]: https://github.com/suitenumerique/people/releases/v1.10.0
[1.9.1]: https://github.com/suitenumerique/people/releases/v1.9.1
[1.9.0]: https://github.com/suitenumerique/people/releases/v1.9.0
[1.8.0]: https://github.com/suitenumerique/people/releases/v1.8.0
[1.7.1]: https://github.com/suitenumerique/people/releases/v1.7.1
[1.7.0]: https://github.com/suitenumerique/people/releases/v1.7.0
[1.6.1]: https://github.com/suitenumerique/people/releases/v1.6.1
[1.6.0]: https://github.com/suitenumerique/people/releases/v1.6.0
[1.5.0]: https://github.com/suitenumerique/people/releases/v1.5.0
[1.4.1]: https://github.com/suitenumerique/people/releases/v1.4.1
[1.4.0]: https://github.com/suitenumerique/people/releases/v1.4.0
[1.3.1]: https://github.com/suitenumerique/people/releases/v1.3.1
[1.3.0]: https://github.com/suitenumerique/people/releases/v1.3.0
[1.2.1]: https://github.com/suitenumerique/people/releases/v1.2.1
[1.2.0]: https://github.com/suitenumerique/people/releases/v1.2.0
[1.1.0]: https://github.com/suitenumerique/people/releases/v1.1.0
[1.0.2]: https://github.com/suitenumerique/people/releases/v1.0.2
[1.0.1]: https://github.com/suitenumerique/people/releases/v1.0.1
[1.0.0]: https://github.com/suitenumerique/people/releases/v1.0.0
+78 -31
View File
@@ -1,18 +1,72 @@
# Django People
# ---- base image to inherit from ----
FROM python:3.10-slim-bullseye as base
FROM python:3.12.6-alpine3.20 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
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.26-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
FROM base AS back-builder
WORKDIR /builder
@@ -24,7 +78,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:18 as mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
@@ -35,15 +89,13 @@ RUN yarn install --frozen-lockfile && \
# ---- static link collector ----
FROM base as link-collector
FROM base AS link-collector
ARG PEOPLE_STATIC_ROOT=/data/static
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -62,21 +114,18 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${PEOPLE_STATIC_ROOT}
# ---- Core application image ----
FROM base as core
FROM base AS core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
shared-mime-info
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -100,15 +149,13 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core as development
FROM core AS backend-development
# Switch back to the root user to install development dependencies
USER root:root
# Install psql
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
RUN apk add postgresql-client
# Uninstall people and re-install it in editable mode along with development
# dependencies
@@ -125,10 +172,10 @@ ENV DB_HOST=postgresql \
DB_PORT=5432
# Run django development server
CMD python manage.py runserver 0.0.0.0:8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core as production
FROM core AS backend-production
ARG PEOPLE_STATIC_ROOT=/data/static
@@ -147,4 +194,4 @@ COPY --from=link-collector ${PEOPLE_STATIC_ROOT} ${PEOPLE_STATIC_ROOT}
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
# The default command runs gunicorn WSGI server in people's main module
CMD gunicorn -c /usr/local/etc/gunicorn/people.py people.wsgi:application
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/people.py", "people.wsgi:application"]
+93 -4
View File
@@ -72,6 +72,7 @@ data/static:
create-env-files: ## Copy the dist env files to env files
create-env-files: \
env.d/development/common \
env.d/development/france \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql
@@ -88,6 +89,7 @@ bootstrap: \
back-i18n-compile \
mails-install \
mails-build \
dimail-setup-db \
install-front-desk
.PHONY: bootstrap
@@ -109,6 +111,7 @@ run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
@@ -129,6 +132,7 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \
@@ -166,24 +170,35 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
test-coverage: ## compute, display and save test coverage
bin/pytest --cov=. --cov-report json .
.PHONY: test-coverage
makemigrations: ## run django makemigrations for the people project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations
@$(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
@$(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
superuser: ## Create an admin superuser with password "admin"
@echo "$(BOLD)Creating a Django superuser$(RESET)"
@$(MANAGE) createsuperuser --email admin@example.com --password admin
$(MANAGE) createsuperuser --username admin --password admin
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@@ -204,15 +219,19 @@ dbshell: ## connect to database shell
docker compose exec app-dev python manage.py dbshell
.PHONY: dbshell
resetdb: FLUSH_ARGS ?=
resetdb: ## flush database and create a superuser "admin"
@echo "$(BOLD)Flush database$(RESET)"
@$(MANAGE) flush
@$(MANAGE) flush $(FLUSH_ARGS)
@${MAKE} superuser
.PHONY: resetdb
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
env.d/development/france:
cp -n env.d/development/france.dist env.d/development/france
env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql
@@ -260,6 +279,17 @@ i18n-generate-and-upload: \
crowdin-upload
.PHONY: i18n-generate-and-upload
# -- INTEROPERABILTY
# -- Dimail configuration
recreate-dimail-container:
@$(COMPOSE) up --force-recreate -d dimail
.PHONY: recreate-dimail-container
dimail-setup-db:
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
@$(MANAGE) setup_dimail_db
.PHONY: dimail-setup-db
# -- Mail generator
@@ -322,3 +352,62 @@ frontend-i18n-generate: \
frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
cd $(PATH_FRONT) && yarn i18n:deploy
.PHONY: frontend-i18n-compile
# -- K8S
start-kind: ## Create the kubernetes cluster
./bin/start-kind.sh
.PHONY: start-kind
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: build-k8s-cluster
tilt-up: ## start tilt - k8s local development
tilt up -f ./bin/Tiltfile
.PHONY: tilt-up
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
release: ## helper for release and deployment
python scripts/release.py
.PHONY: release
install-secret: ## install the kubernetes secrets from Vaultwarden
if kubectl -n desk get secrets bitwarden-cli-desk; then \
echo "Secret already present"; \
else \
echo "Please provide the following information:"; \
read -p "Enter your vaultwarden email login: " LOGIN; \
read -p "Enter your vaultwarden password: " PASSWORD; \
read -p "Enter your vaultwarden server url: " URL; \
echo "\nCreate vaultwarden secret"; \
echo "apiVersion: v1" > /tmp/secret.yaml; \
echo "kind: Secret" >> /tmp/secret.yaml; \
echo "metadata:" >> /tmp/secret.yaml; \
echo " name: bitwarden-cli-desk" >> /tmp/secret.yaml; \
echo " namespace: desk" >> /tmp/secret.yaml; \
echo "type: Opaque" >> /tmp/secret.yaml; \
echo "stringData:" >> /tmp/secret.yaml; \
echo " BW_HOST: $$URL" >> /tmp/secret.yaml; \
echo " BW_PASSWORD: $$PASSWORD" >> /tmp/secret.yaml; \
echo " BW_USERNAME: $$LOGIN" >> /tmp/secret.yaml; \
kubectl -n desk apply -f /tmp/secret.yaml;\
rm -f /tmp/secret.yaml; \
fi; \
if kubectl get ns external-secrets; then \
echo "External secret already deployed"; \
else \
helm repo add external-secrets https://charts.external-secrets.io; \
helm upgrade --install external-secrets \
external-secrets/external-secrets \
-n external-secrets \
--create-namespace \
--set installCRDs=true; \
fi
.PHONY: build-k8s-cluster
fetch-domain-status:
@$(MANAGE) fetch_domain_status
.PHONY: fetch-domain-status
+38 -13
View File
@@ -1,12 +1,12 @@
# People
People is an application to handle users and teams.
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
This project is as of yet **not ready for production**. Expect breaking changes.
People is built on top of [Django Rest
It is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/).
All interoperabilities will be described in `docs/interoperability`.
## Getting started
### Prerequisite
@@ -25,7 +25,7 @@ $ docker compose -v
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
### Project bootstrap
### Bootstrap project
The easiest way to start working on the project is to use GNU Make:
@@ -38,7 +38,7 @@ 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.
Your Docker services should now be up and running 🎉
Your Docker services should now be up and running! 🎉
Note that if you need to run them afterward, you can use the eponym Make rule:
@@ -46,13 +46,7 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
$ make run
```
### Adding content
You can create a basic demo site by running:
$ make demo
Finally, you can check all available Make rules using:
You can check all available Make rules using:
```bash
$ make help
@@ -69,6 +63,37 @@ You first need to create a superuser account:
$ make superuser
```
You can then login with sub `admin` and password `admin`.
### Adding demo content
You can create a basic demo site by running:
```bash
$ make demo
```
### Setting dimail database
To ease local development when working on interoperability between people and dimail, we embark dimail-api in a container running in "fake" mode.
To populate dimail local database with users/domains/permissions needed for basic development:
- log in with "people" user
- run `make dimail-setup-db`
### Run frontend
Run the front with:
```bash
$ make run-front-desk
```
Then access [http://localhost:3000](http://localhost:3000) with :
user: people
password: people
## Contributing
This project is intended to be community-driven, so please, do not hesitate to
+74
View File
@@ -0,0 +1,74 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('desk')
docker_build(
'localhost:5001/people-backend:latest',
context='..',
dockerfile='../Dockerfile',
only=['./src/backend', './src/mail', './docker'],
target = 'backend-production',
live_update=[
sync('../src/backend', '/app'),
run(
'pip install -r /app/requirements.txt',
trigger=['./api/requirements.txt']
)
]
)
docker_build(
'localhost:5001/people-frontend:latest',
context='..',
dockerfile='../Dockerfile',
build_args={'ENV': 'dev'},
only=['./src/frontend', './src/mail', './docker'],
target = 'frontend-builder-dev',
live_update=[
sync('../src/frontend', '/builder'),
]
)
# helmfile in docker mount the current working directory and the helmfile.yaml
# requires the keycloak config in another directory
k8s_yaml(local('cd .. && helmfile -n desk -e ${DEV_ENV:-dev} template --file ./src/helm/helmfile.yaml'))
migration = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py makemigrations
'''
cmd_button('Make migration',
argv=['sh', '-c', migration],
resource='desk-backend',
icon_name='developer_board',
text='Run makemigration',
)
pod_migrate = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py migrate --no-input
'''
cmd_button('Migrate db',
argv=['sh', '-c', pod_migrate],
resource='desk-backend',
icon_name='developer_board',
text='Run database migration',
)
# Command to created domain/users/access from people to dimail
populate_dimail_from_people = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py setup_dimail_db --populate-from-people
'''
cmd_button('Populate dimail from people',
argv=['sh', '-c', populate_dimail_from_people],
resource='desk-backend',
icon_name='developer_board',
text='Populate dimail from people',
)
-64
View File
@@ -5,7 +5,6 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="people"
@@ -92,66 +91,3 @@ function _dc_exec() {
function _django_manage() {
_dc_run "app-dev" python manage.py "$@"
}
# _set_openstack_project: select an OpenStack project from the openrc files defined in the
# terraform directory.
#
# usage: _set_openstack_project
#
# If necessary the script will prompt the user to choose a project from those available
function _set_openstack_project() {
declare prompt
declare -a projects
declare -i default=1
declare -i choice=0
declare -i n_projects
# List projects by looking in the "./env.d/terraform" directory
# and store them in an array
read -r -a projects <<< "$(
find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d |
sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' |
xargs
)"
nb_projects=${#projects[@]}
if [[ ${nb_projects} -le 0 ]]; then
echo "There are no OpenStack projects defined..." >&2
echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \
"of your project and copy your \"openrc.sh\" file into it." >&2
exit 10
fi
if [[ ${nb_projects} -gt 1 ]]; then
prompt="Select an OpenStack project to target:\\n"
for (( i=0; i<nb_projects; i++ )); do
prompt+="[$((i+1))] ${projects[$i]}"
if [[ $((i+1)) -eq ${default} ]]; then
prompt+=" (default)"
fi
prompt+="\\n"
done
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
prompt+="Your choice: "
read -r -p "$(echo -e "${prompt}")" choice
if [[ ${choice} -gt nb_projects ]]; then
(>&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})")
exit 11
fi
if [[ ${choice} -le 0 ]]; then
choice=${default}
fi
fi
project=${projects[$((choice-1))]}
# Check that the openrc.sh file exists for this project
if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then
(>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.")
exit 12
fi
echo "${project}"
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -o errexit
CURRENT_DIR=$(pwd)
NAMESPACE=${1:-desk}
SECRET_NAME=${2:-bitwarden-cli-desk}
TEMP_SECRET_FILE=$(mktemp)
cleanup() {
rm -f "${TEMP_SECRET_FILE}"
}
trap cleanup EXIT
# Check if kubectl is available
check_prerequisites() {
if ! command -v kubectl &> /dev/null; then
echo "Error: kubectl is not installed or not in PATH"
exit 1
fi
}
# Check if secret already exists
check_secret_exists() {
kubectl -n "${NAMESPACE}" get secrets "${SECRET_NAME}" &> /dev/null
}
# Collect user input securely
get_user_input() {
echo "Please provide the following information:"
read -p "Enter your Vaultwarden email login: " LOGIN
read -s -p "Enter your Vaultwarden password: " PASSWORD
echo
read -p "Enter your Vaultwarden server url: " URL
}
# Create and apply the secret
create_secret() {
cat > "${TEMP_SECRET_FILE}" << EOF
apiVersion: v1
kind: Secret
metadata:
name: ${SECRET_NAME}
namespace: ${NAMESPACE}
type: Opaque
stringData:
BW_HOST: ${URL}
BW_PASSWORD: ${PASSWORD}
BW_USERNAME: ${LOGIN}
EOF
kubectl -n "${NAMESPACE}" apply -f "${TEMP_SECRET_FILE}"
}
# Install external-secrets using Helm
install_external_secrets() {
if ! kubectl get ns external-secrets &>/dev/null; then
echo "Installing external-secrets…"
helm repo add external-secrets https://charts.external-secrets.io
helm upgrade --install external-secrets \
external-secrets/external-secrets \
-n external-secrets \
--create-namespace \
--set installCRDs=true
else
echo "External secrets already deployed"
fi
}
main() {
check_prerequisites
if check_secret_exists; then
echo "Secret '${SECRET_NAME}' already present in namespace '${NAMESPACE}'"
exit 0
fi
echo -e ${TEMP_SECRET_FILE}
get_user_input
echo -e "\nCreating Vaultwarden secret…"
create_secret
install_external_secrets
echo "Secret installation completed successfully"
}
main "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- desk
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
project=$(_set_openstack_project)
echo "Using \"${project}\" project..."
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
# Run Terraform commands in the Hashicorp docker container via docker compose
# shellcheck disable=SC2068
DOCKER_USER="$(id -u):$(id -g)" \
PROJECT="${project}" \
docker compose run --rm \
-e OS_AUTH_URL \
-e OS_IDENTITY_API_VERSION \
-e OS_USER_DOMAIN_NAME \
-e OS_PROJECT_DOMAIN_NAME \
-e OS_TENANT_ID \
-e OS_TENANT_NAME \
-e OS_USERNAME \
-e OS_PASSWORD \
-e OS_REGION_NAME \
terraform-state "$@"
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
project=$(_set_openstack_project)
echo "Using \"${project}\" project..."
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
# Run Terraform commands in the Hashicorp docker container via docker compose
# shellcheck disable=SC2068
DOCKER_USER="$(id -u):$(id -g)" \
PROJECT="${project}" \
docker compose run --rm \
-e OS_AUTH_URL \
-e OS_IDENTITY_API_VERSION \
-e OS_USER_DOMAIN_NAME \
-e OS_PROJECT_DOMAIN_NAME \
-e OS_TENANT_ID \
-e OS_TENANT_NAME \
-e OS_USERNAME \
-e OS_PASSWORD \
-e OS_REGION_NAME \
-e TF_VAR_user_name \
terraform "$@"
+31 -34
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
postgresql:
image: postgres:16
@@ -11,24 +9,25 @@ services:
redis:
image: redis:5
mailcatcher:
image: sj26/mailcatcher:latest
maildev:
image: maildev/maildev:latest
ports:
- "1081:1080"
app-dev:
build:
context: .
target: development
target: backend-development
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:development
image: people:backend-development
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/france
- env.d/development/postgresql
ports:
- "8071:8000"
@@ -37,13 +36,14 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- dimail
- postgresql
- mailcatcher
- maildev
- redis
celery-dev:
user: ${DOCKER_USER:-1000}
image: people:development
image: people:backend-development
command: ["celery", "-A", "people.celery_app", "worker", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
@@ -60,13 +60,13 @@ services:
app:
build:
context: .
target: production
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:production
image: people:backend-production
environment:
- DJANGO_CONFIGURATION=ContinuousIntegration
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
@@ -78,10 +78,10 @@ services:
celery:
user: ${DOCKER_USER:-1000}
image: people:production
image: people:backend-production
command: ["celery", "-A", "people.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=ContinuousIntegration
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
@@ -91,14 +91,12 @@ services:
nginx:
image: nginx:1.25
ports:
- "8082:8082"
- "8088:8088"
- "8083:8083"
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
- ./src/frontend/apps/desk/out:/home/desk
- ./data/media:/data/media:ro
depends_on:
- app
- keycloak
dockerize:
image: jwilder/dockerize
@@ -120,25 +118,8 @@ services:
volumes:
- ".:/app"
terraform-state:
image: hashicorp/terraform:1.6
environment:
- TF_WORKSPACE=${PROJECT:-} # avoid env conflict in local state
user: ${DOCKER_USER:-1000}
working_dir: /app
volumes:
- ./src/terraform/create_state_bucket:/app
terraform:
image: hashicorp/terraform:1.6
user: ${DOCKER_USER:-1000}
working_dir: /app
volumes:
- ./src/terraform:/app
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:
@@ -152,6 +133,11 @@ services:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname-url=http://localhost:8083
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
@@ -161,7 +147,18 @@ services:
KC_DB_PASSWORD: pass
KC_DB_USERNAME: people
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "8080:8080"
depends_on:
- kc_postgresql
dimail:
entrypoint: /opt/dimail-api/start-dev.sh
image: registry.mim-libre.fr/dimail/dimail-api:v0.1.1
pull_policy: always
environment:
DIMAIL_MODE: FAKE
DIMAIL_JWT_SECRET: fake_jwt_secret
ports:
- "8001:8000"
+340 -20
View File
@@ -46,6 +46,9 @@
"users": [
{
"username": "people",
"email": "people@people.world",
"firstName": "John",
"lastName": "Doe",
"enabled": true,
"credentials": [
{
@@ -56,12 +59,270 @@
"realmRoles": ["user"]
},
{
"username": "user-e2e",
"username": "e2e.marie",
"email": "marie.varzy@gmail.com",
"firstName": "Marie",
"lastName": "Delamairie",
"enabled": true,
"attributes": {
"siret": "21510339100011"
},
"credentials": [
{
"type": "password",
"value": "password-e2e.marie"
}
],
"realmRoles": ["user"]
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e"
"value": "password-e2e-chromium"
}
],
"realmRoles": ["user"]
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e-webkit"
}
],
"realmRoles": ["user"]
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e-firefox"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-member",
"email": "e2e.team-member@example.com",
"firstName": "E2E",
"lastName": "Group Member",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-member"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-administrator",
"email": "e2e.team-administrator@example.com",
"firstName": "E2E",
"lastName": "Group Administrator",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-administrator"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-owner",
"email": "e2e.team-owner@example.com",
"firstName": "E2E",
"lastName": "Group Owner",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-owner"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.mail-member",
"email": "e2e.mail-member@example.com",
"firstName": "E2E",
"lastName": "Mailbox Member",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.mail-member"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.mail-administrator",
"email": "e2e.mail-administrator@example.com",
"firstName": "E2E",
"lastName": "Mailbox Administrator",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.mail-administrator"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.mail-owner",
"email": "e2e.mail-owner@example.com",
"firstName": "E2E",
"lastName": "Mailbox Owner",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.mail-owner"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-member-mail-member",
"email": "e2e.team-member-mail-member@example.com",
"firstName": "E2E",
"lastName": "Group Member Mailbox Member",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-member-mail-member"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-member-mail-administrator",
"email": "e2e.team-member-mail-administrator@example.com",
"firstName": "E2E",
"lastName": "Group Member Mailbox Administrator",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-member-mail-administrator"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-member-mail-owner",
"email": "e2e.team-member-mail-owner@example.com",
"firstName": "E2E",
"lastName": "Group Member Mailbox Owner",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-member-mail-owner"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-administrator-mail-member",
"email": "e2e.team-administrator-mail-member@example.com",
"firstName": "E2E",
"lastName": "Group Administrator Mailbox Member",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-administrator-mail-member"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-administrator-mail-administrator",
"email": "e2e.team-administrator-mail-administrator@example.com",
"firstName": "E2E",
"lastName": "Group Administrator Mailbox Administrator",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-administrator-mail-administrator"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-administrator-mail-owner",
"email": "e2e.team-administrator-mail-owner@example.com",
"firstName": "E2E",
"lastName": "Group Administrator Mailbox Owner",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-administrator-mail-owner"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-owner-mail-member",
"email": "e2e.team-owner-mail-member@example.com",
"firstName": "E2E",
"lastName": "Group Owner Mailbox Member",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-owner-mail-member"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-owner-mail-administrator",
"email": "e2e.team-owner-mail-administrator@example.com",
"firstName": "E2E",
"lastName": "Group Owner Mailbox Administrator",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-owner-mail-administrator"
}
],
"realmRoles": ["user"]
},
{
"username": "e2e.team-owner-mail-owner",
"email": "e2e.team-owner-mail-owner@example.com",
"firstName": "E2E",
"lastName": "Mailbox Owner",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password-e2e.team-owner-mail-owner"
}
],
"realmRoles": ["user"]
@@ -313,7 +574,6 @@
],
"security-admin-console": [],
"admin-cli": [],
"people-front": [],
"account-console": [],
"broker": [
{
@@ -326,6 +586,7 @@
"attributes": {}
}
],
"people": [],
"account": [
{
"id": "63b1a4e1-a594-4571-99c3-7c5c3efd61ce",
@@ -451,9 +712,17 @@
"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false,
"webAuthnPolicyPasswordlessAcceptableAaguids": [],
"scopeMappings": [
{
"clientScope": "siret",
"roles": [
"user"
]
},
{
"clientScope": "offline_access",
"roles": ["offline_access"]
"roles": [
"offline_access"
]
}
],
"clientScopeMappings": {
@@ -580,7 +849,9 @@
"publicClient": true,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@@ -618,7 +889,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@@ -638,7 +911,7 @@
},
{
"id": "869481d0-5774-4e64-bc30-fedc7c58958f",
"clientId": "people-front",
"clientId": "people",
"name": "",
"description": "",
"rootUrl": "",
@@ -648,9 +921,10 @@
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
"redirectUris": [
"",
"http://localhost:8070/*",
"http://localhost:8071/*",
"http://localhost:3200/*",
"http://localhost:8088/*",
"http://localhost:3000/*"
@@ -666,18 +940,29 @@
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": true,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"access.token.lifespan": "-1",
"client.secret.creation.time": "1707820779",
"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",
"post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*",
"client_credentials.use_refresh_token": "false",
"acr.loa.map": "{}",
"require.pushed.authorization.requests": "false",
"display.on.consent.screen": "false",
"oauth2.device.authorization.grant.enabled": "false",
"backchannel.logout.revoke.offline.tokens": "false"
"client.session.idle.timeout": "-1",
"token.response.type.bearer.lower-case": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
@@ -687,6 +972,7 @@
"acr",
"roles",
"profile",
"siret",
"email"
],
"optionalClientScopes": [
@@ -716,7 +1002,9 @@
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {},
"attributes": {
"post.logout.redirect.uris": "+"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
@@ -845,6 +1133,35 @@
}
]
},
{
"id": "eb220fbb-02ac-4105-95a3-727954f6565d",
"name": "siret",
"description": "siret",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "false",
"gui.order": ""
},
"protocolMappers": [
{
"id": "333a4e89-9363-4c36-b56f-79c6b019c6c6",
"name": "siret",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"aggregate.attrs": "false",
"userinfo.token.claim": "true",
"multivalued": "false",
"user.attribute": "siret",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "siret"
}
}
]
},
{
"id": "af52ccc3-4ecb-49b4-9a67-5d4172f16070",
"name": "role_list",
@@ -887,7 +1204,8 @@
"consentRequired": false,
"config": {
"id.token.claim": "true",
"access.token.claim": "true"
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
@@ -1057,7 +1375,7 @@
},
{
"id": "79712bcf-b7f7-4ca3-b97c-418f48fded9b",
"name": "given name",
"name": "first name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
@@ -1066,7 +1384,7 @@
"user.attribute": "firstName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "given_name",
"claim.name": "first_name",
"jsonType.label": "String"
}
},
@@ -1087,7 +1405,7 @@
},
{
"id": "7f741e96-41fe-4021-bbfd-506e7eb94e69",
"name": "family name",
"name": "last name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
@@ -1096,7 +1414,7 @@
"user.attribute": "lastName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "family_name",
"claim.name": "last_name",
"jsonType.label": "String"
}
},
@@ -1207,6 +1525,7 @@
"consentRequired": false,
"config": {
"multivalued": "true",
"userinfo.token.claim": "true",
"user.attribute": "foo",
"id.token.claim": "true",
"access.token.claim": "true",
@@ -1309,7 +1628,8 @@
"email",
"roles",
"web-origins",
"acr"
"acr",
"siret"
],
"defaultOptionalClientScopes": [
"offline_access",
+7 -28
View File
@@ -1,34 +1,13 @@
server {
listen 8082;
server {
listen 8083;
server_name localhost;
charset utf-8;
location /media {
alias /data/media;
location / {
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# location / {
# proxy_pass http://app:8000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# }
}
server {
listen 8088;
server_name localhost;
root /home/desk;
location / {
try_files $uri index.html $uri/ =404;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}
+45
View File
@@ -0,0 +1,45 @@
# dimail
## 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.
API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.fr/docs#/).
## Architectural links of dimail
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.
### 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.
### 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 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
+131
View File
@@ -0,0 +1,131 @@
# Local development with Kubernetes
We use tilt to provide a local development environment for Kubernetes.
Tilt is a tool that helps you develop applications for Kubernetes.
It watches your files for changes, rebuilds your containers, and restarts your pods.
It's like having a conversation with your cluster.
## Prerequisites
This guide assumes you have the following tools installed:
- [Docker](https://docs.docker.com/get-docker/)
- [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/)
- [Kind](https://kind.sigs.k8s.io/docs/user/quick-start/)
* [mkcert](https://github.com/FiloSottile/mkcert)
- [ctlptl](https://github.com/tilt-dev/ctlptl)
- [Tilt](https://docs.tilt.dev/install.html)
* [helm](https://helm.sh/docs/intro/install/)
* [helmfile](https://github.com/helmfile/helmfile)
* [secrets](https://github.com/jkroepke/helm-secrets/wiki/Installation)
* [sops](https://github.com/getsops/sops)
### SOPS configuration
**Generate a SOPS key**
For this specific step you need to have the `age-keygen` tool installed.
See https://github.com/FiloSottile/age.
Then generate a key:
```bash
age-keygen -o my-age.key
```
**Install the SOPS key**
Read the SOPS documentation on how to install the key in your environment.
https://github.com/getsops/sops?tab=readme-ov-file#22encrypting-using-age
On Ubuntu it's like:
```bash
mkdir -p ~/.config/sops/age/
cp my-age.key ~/.config/sops/age/keys.txt
chmod 400 ~/.config/sops/age/keys.txt
```
**Add the SOPS key to the repository**
Update the [.sops.yaml](../.sops.yaml) file with the **public** key id you generated.
### Helmfile in Docker
If you use helmfile in Docker, you may need an additional configuration to make
it work with you age key.
You need to mount `-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/"`
```bash
#!/bin/sh
docker run --rm --net=host \
-v "${HOME}/.kube:/root/.kube" \
-v "${HOME}/.config/helm:/root/.config/helm" \
-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/" \
-v "${HOME}/.minikube:/${HOME}/.minikube" \
-v "${PWD}:/wd" \
-e KUBECONFIG=/root/.kube/config \
--workdir /wd ghcr.io/helmfile/helmfile:v0.150.0 helmfile "$@"
```
## Getting started
### Create the kubernetes cluster
Run the following command to create a kubernetes cluster using kind:
```bash
./bin/start-kind.sh
```
**or** run the equivalent using the makefile
```bash
make start-kind
```
### [Optional] Install the secret
You don't need to do this if you are running the stack with keycloak.
```bash
make install-external-secrets
```
### Deploy the application
```bash
# Pro Connect environment
tilt up -f ./bin/Tiltfile
# Standalone environment with keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
```
**or** run the equivalent using the makefile
```bash
# Pro Connect environment
make tilt-up
# Standalone environment with keycloak
make tilt-up-keycloak
```
That's it! You should now have a local development environment for Kubernetes.
You can access the application at https://desk.127.0.0.1.nip.io
## Management
To manage the cluster, you can use k9s.
## Next steps
- Add dimail to the local development environment
- Add a reset demo `cmd_button` to Tilt
+103 -73
View File
@@ -6,16 +6,56 @@ interact efficiently.
Let's see how we could interact with Django's shell to recreate David's environment in the app.
## Base contacts from the organization records
## Profile contact
David Bowman is an exemplary employee at Space Odyssey Corporation. His email is
`david.bowman@spaceodyssey.com` and he is registered in the organization's records via a base
contact as follows:
The system identifies Dave through the `sub` associated with his OIDC session.
The OIDC should also provide his email address.
Note: as some Identity Providers do not provide a constant `sub`, the system should be able to
fallback to the email address to find it the user is already in the database.
When Dave logs in for the first time, the system checks if he is already in the database. If not
a new user is created for him.
Users needs to be linked to an Organization (see [organizations.md](./organizations.md)).
```python
david_base_contact = Contact.objects.create(
space_odyssey, _created = Organization.objects.get_or_create(...)
david_user = User.objects.create(
organization=space_odyssey,
language="en-us",
timezone="America/Los_Angeles",
name="David Bowman",
sub="1234567890",
email="david.bowman@spaceodyssey.com",
)
```
The system then creates a profile contact for Dave, based on the information provided by the OIDC:
```python
david_profile_contact = Contact.objects.create(
owner=david_user,
user=david_user, # this means it's the user's profile contact
full_name="David Bowman",
short_name="David",
data={
"emails": [
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
],
},
)
```
**Future feature:**
Dave will be prompted to confirm the details of the base contact stored in the database
and fill the details for a better profile.
His profile contact will be updated with the new information.
```python
Contact.objects.filter(user=david_user, owner=david_user).update(
short_name="Dave",
data={
"emails": [
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
@@ -48,91 +88,81 @@ david_base_contact = Contact.objects.create(
)
```
When David logs-in to the People application for the first time using the corporation's OIDC
Single Sign-On service. A user is created for him on the fly by the system, together with an
identity record representing the OIDC session:
David's profile contact is available to all users of his company/organization.
```python
david_user = User.objects.create(
language="en-us",
timezone="America/Los_Angeles",
)
david_identity = Identity.objects.create(
"user": david_user,
"sub": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"email" : "david.bowman@spaceodyssey.com",
"is_main": True,
)
```
## Profile contact
The system identifies Dave through the email associated with his OIDC session and prompts him to
confirm the details of the base contact stored in the database.
When David confirms, giving an alternative short name that he prefers, a contact override is
created on top of the organization's base contact. This new contact is marked as David's profile
on the user:
```python
david_contact = Contact.objects.create(
base=david_base_contact,
owner=david_user,
full_name="David Bowman",
short_name="Dave",
data={}
)
david_user.profile_contact = david_contact
david_user.save()
```
If Dave had not had any existing contact in the organization's records, the profile contact would
have been created independently, without any connection to a base contact:
```python
david_contact = Contact.objects.create(
base=None,
owner=david_user,
full_name="David Bowman",
short_name="Dave",
data={}
)
```
Now, Dave feels like sharing his mobile phone number with his colleagues. He can do this
by editing his contact in the application:
```python
contact.data["phones"] = [
{"type": "Mobile", "value": "(123) 456-7890"},
]
contact.save()
```
## Contact override
During a Space conference he attended, Dave met Dr Ryan Stone, a medical engineer who gave him
her professional email address. Ryan is already present in the system but her email is missing.
her personal email address (because she will quit the company she works for). Ryan is already
present in the system.
Dave can add it to his private version of the contact:
```python
ryan_base_contact = Contact.objects.create(
full_name="Ryan Stone",
data={}
ryan_user = User.objects.create(
organization=space_odyssey,
language="en-us",
timezone="America/Los_Angeles",
name="Ryan Stone",
sub="0987654321",
email="ryan.stone@spaceodyssey.com",
)
ryan_contact = Contact.objects.create(
base=ryan_base_contact,
owner=david_user,
ryan_profile_contact = Contact.objects.create(
user=ryan_user,
owner=ryan_user,
full_name="Ryan Stone",
short_name="Dr Ryan",
data={
"emails": [
{"type": "Work", "value": "ryan.stone@hubblestation.com"},
],
}
},
)
david_ryan_contact = Contact.objects.create(
override=ryan_profile_contact,
owner=david_user,
full_name="Ryan Stone",
short_name="Dr Ryan",
data={
"emails": [
{"type": "Home", "value": "ryan@stone.com"},
],
},
)
```
## Personal contacts
Dave met a lot of people during the last Opensource Experience event. He can add them to his personal contacts:
```python
julie_personal_contact = Contact.objects.create(
owner=david_user,
full_name="Julie Powell",
short_name="Julie",
data={
"emails": [
{"type": "Work", "value": "julie.powell@example.com"},
],
"phones": [
{"type": "Work", "value": "(123) 456-7890"},
],
},
)
```
These contacts are only available to Dave.
## Contacts from shared directories
This is a future feature that will allow Dave to access contacts from shared directories.
Shared directories will provide contacts for all users from several organizations.
## Team Collaboration
Dave wants to form a team with Ryan and other colleagues to work together better on using the organization's digital tools for their projects.
+41
View File
@@ -0,0 +1,41 @@
# Organization model
## Purpose
The initial `Organization` model allows to regroup `User` and `Team` under a same object.
While the purpose was purely technical in the first place, few features emerged afterward.
## Link with the `User` model
Each user must have a "default" organization.
When a user logs in:
- The backend tries to get an existing organization from a "registration ID" or from the user's mail domain
- If the organization does not exist, it is created using the "registration ID" and fallbacks on the user's mail domain
- The user organization is set to this organization.
**Note:** While deploying the new code, we allow the already existing user to not have one.
## Link with the `Team` model
Each team must have an organization. This organization is not defined by the user, but automatically set
using the user's organization.
The team's organization does not restrict the users who can be added to the team.
There is currently no feature relying on the team organization.
**Note:** While deploying the new code, we allow the teams to not have one.
## Organization-related features
### Organization UI
A new interface can be created to allow users to see all members of an organization.
This could be used along with the contacts.
+164
View File
@@ -0,0 +1,164 @@
## Resource Server
For detailed information, please refer to the [OAuth 2.0 Resource Server documentation](https://www.oauth.com/oauth2-servers/the-resource-server/) and review the relevant commits that implement the resource server backend.
---
#### Overview
A resource server is a crucial component in an OAuth 2.0 ecosystem. It is responsible for accepting access tokens issued by the authorization server (in this case, Agent Connect), introspecting and validating those tokens, and then returning the requested protected resources to the client.
By implementing a resource server, we can securely share data between different services within La Suite. This ensures that only clients with the appropriate scopes and permissions can access specific resources, thereby enhancing security and maintaining proper access control across services.
---
#### Disclaimer
- Currently compatible only with Agent Connect.
- The development setup requires simplification, with dependencies on Agent Connect ideally mocked.
- Terminology aligns with the specification: what is referred to as a "resource server" is known as a "data provider" in Agent Connect.
- This documentation is WIP.
---
## Running Locally
#### Prerequisites
- **Agent Connect Stack**: Ensure the local Agent Connect stack is running. A solid understanding of its configuration and operation is recommended (advanced level).
- **People Stack**: Make sure the People stack is up and running.
- **Ngrok**: Install and set up Ngrok for secure tunneling.
---
### Update People's configurations
#### Environment variables
Agent Connect includes two pre-configured mocked data providers in its default stack (`bdd-fca-low`).
Use the client ID and client secret from one of these data providers. **Note:** Make sure to retrieve the decrypted secret, as it is stored encrypted in the database. You can find these values in the `dp.js` fixture file, where they are exposed in a comment.
Configure your environment with the following values from Agent Connect:
```
OIDC_RS_CLIENT_ID=<your-client-id-from-ac>
OIDC_RS_CLIENT_SECRET=<your-decrypted-client-secret-from-ac>
# In development, the resource server use insecure settings
OIDC_VERIFY_SSL=False
# Update the endpoints as follows
OIDC_OP_JWKS_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/jwks
OIDC_OP_INTROSPECTION_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/checktoken
OIDC_OP_URL=https://core-fca-low.docker.dev-franceconnect.fr/api/v2
```
#### Docker Network Configuration
To enable communication between the Docker networks for People and Agent Connect, update your docker-compose configuration. This setup is required because the Authorization Server and Resource Server will exchange requests over a back-channel, necessitating their accessibility to each other.
1. **Create a Network**: Define a new network to bridge the two Docker networks.
```yaml
networks:
authorization_server:
external: true
driver: bridge
name: "${DESK_NETWORK:-fc_public}"
```
2. **Update Network for `app-dev`**: Ensure your `app-dev` service is connected to both the default network and the new `authorization_server` network.
```yaml
app-dev:
...
networks:
- default
- authorization_server
```
#### Ngrok
To expose your local resource server through an HTTP tunnel, use Ngrok. This is necessary because, in the Agent Connect development stack, the resource server needs to be accessible to a user agent via a publicly accessible URL.
```
$ ngrok http 8071
```
---
### Update AgentConnect's configurations
Modify the AgentConnect configuration to include the local resource server settings.
**Update `fsa1-low` Environment File**:
Add your Ngrok URL and configure the `DATA_APIS` list with the appropriate values.
```env
App_DATA_APIS=[{"name":"Data Provider 1","url":"https://your-ngrok-url/api/v1.0/any-path","secret":"***"}, ...]
```
**Update Fixture in `dp.js`**:
Adjust the configuration for the data provider to match your local setup. Ensure that the `client_id`, `client_secret`, and other parameters are correctly set and aligned with your environment. This can be configured through environment variables.
```javascript
const dps = [
// Data Provider Configuration
{
uid: "6f21b751-ed06-48b6-a59c-36e1300a368a",
title: "Mock Data Provider - 1",
active: true,
slug: "DESK",
client_id: "***",
client_secret: "***",
// client_secret decrypted : ****
jwks_uri: "https://your-ngrok-url/api/v1.0/jwks", // Update this line
checktoken_signed_response_alg: "ES256",
checktoken_encrypted_response_alg: "RSA-OAEP",
checktoken_encrypted_response_enc: "A256GCM",
},
];
```
**Note**: Ensure that the `jwks_uri` and other cryptographic parameters (e.g., `checktoken_signed_response_alg`, `checktoken_encrypted_response_alg`, and `checktoken_encrypted_response_enc`) match your actual setup and are configured via environment variables where necessary.
---
### Usage
This section is a work in progress. Please note the following important points:
#### User `sub` Matching
Ensure that the `sub` (subject) field for users in AgentConnect matches the corresponding value in the People database. To synchronize this, you can run `make demo`, then edit the user's `sub` field to match the value returned by AgentConnect. For this, you'll need to update the editable field in Django Admin, specifically in `admin.py`. Adjust the `get_readonly_fields` method as follows:
```python
def get_readonly_fields(self, request, obj=None):
"""The 'sub' field should only be editable during creation, not for updates."""
if obj:
return self.readonly_fields
return self.readonly_fields + ["sub"] # update this line adding 'sub'
```
#### Scope for `groups`
Ensure that the `groups` scope is requested from the service provider during authentication with AgentConnect.
#### Resource Server Requests
By default, the `fsa1-low` environment calls the resource server using a POST request.
#### Testing
Most of the testing has been done using the `/users/me` endpoint. Update the `api/viewset.py` configuration to allow both GET and POST methods for this endpoint:
```python
@decorators.action(
detail=False,
methods=["get", "post"], # update this line adding 'post'
url_name="me",
url_path="me",
)
```
+28
View File
@@ -0,0 +1,28 @@
# ServiceProvider model
## Purpose
The `ServiceProvider` model represents a ... service provider, also known as "tools using some data from this project".
## Link with the `Organization` model
An organization can be linked to several service providers.
The goal here, is to allow users of an organization to have access, to a service provider or not.
The first implementation does not have any feature related, but the first feature will probably be
to list applications visible in the "all application menu" (aka "la gaufre").
## Link with the `Team` model
A team can be linked to several service providers.
This is used as a filter when a service provider calls the resource server, only the teams linked to
this service provider are returned. This is mandatory for data segregation: we don't want all service
providers to be able to list all data regarding other service providers.
## Limitations
There is currently no way to provision all the service providers automatically. So when a service provider
creates a team via the resource server, we create the `ServiceProvider` on the fly, without any understandable name.
This will need to be improved later.
+51
View File
@@ -0,0 +1,51 @@
# E2E tests
## Run E2E tests
``` bash
# you need the dockers to be up and running
make bootstrap
# you will need to have few accounts in the database
make demo FLUSH_ARGS='--no-input'
# run the tests
cd src/frontend/apps/e2e
yarn test:ui --workers=1
```
A new browser window will open and you will be able to run the tests.
## Available accounts
The `make demo` command creates the following accounts:
- `e2e.team-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
this account only belong to a team with the specified role.
- `e2e.mail-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
this account only have a mailbox with the specified role access.
- `e2e.team-<team_role>-mail-<domain_role>@example.com` with a combination of roles as for the
previous accounts.
For each account, the password is `password-e2e.<role>`, for instance `password-e2e.team-member`.
In the E2E tests you can use these accounts to benefit from there accesses,
using the `keyCloakSignIn(page, browserName, <account_name>)`. The account name is the
username without the prefix `e2e.`.
``` typescript jsx
await keyCloakSignIn(page, browserName, 'mail-owner');
```
The `keyCloakSignIn` function will sign in the user on Keycloak using the proper username and password.
.. note::
This only works because the OIDC setting is set to fallback on user email.
## Add a new account
In case you need to add a new account for specific tests you need:
- to create a new user with the same format in the backend database:
update `[create_demo.py](../src/backend/demo/management/commands/create_demo.py)`
- to create a new account in Keycloak: update [realm.json](../docker/auth/realm.json)
- if the keycloak was running locally, you need to destroy its database and
restart the database and the keycloak containers.
+50 -6
View File
@@ -7,17 +7,61 @@ DJANGO_SUPERUSER_PASSWORD=admin
# Python
PYTHONPATH=/app
#JWT
DJANGO_JWT_PRIVATE_SIGNING_KEY=ThisIsAnExampleKeyForDevPurposeOnly
# People settings
# Mail
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_HOST="maildev"
DJANGO_EMAIL_PORT=1025
# Backend url
PEOPLE_BASE_URL="http://localhost:8072"
# Keycloak
SIMPLE_JWT_JWK_URL="http://keycloak:8080/realms/people/protocol/openid-connect/certs"
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/people/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/userinfo
OIDC_RP_CLIENT_ID=people
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
LOGIN_REDIRECT_URL=http://localhost:3000
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
OIDC_RS_CLIENT_ID=people
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RS_PRIVATE_KEY_STR="-----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-----
"
+3
View File
@@ -0,0 +1,3 @@
# For the CI job test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
+1
View File
@@ -0,0 +1 @@
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation
+1 -1
View File
@@ -8,4 +8,4 @@ DB_HOST=kc_postgresql
DB_NAME=keycloak
DB_USER=people
DB_PASSWORD=pass
DB_PORT=5433
DB_PORT=5433
+1 -1
View File
@@ -8,4 +8,4 @@ DB_HOST=postgresql
DB_NAME=people
DB_USER=dinum
DB_PASSWORD=pass
DB_PORT=5432
DB_PORT=5432
+2 -1
View File
@@ -1,6 +1,7 @@
{
"extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"enabled": false,
@@ -12,7 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["node", "node-fetch", "i18next-parser"]
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
}
]
}
+2 -2
View File
@@ -1,10 +1,10 @@
#!/bin/bash
#!/usr/bin/env bash
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
cat <<'EOF' >$PRE_COMMIT_FILE
#!/bin/bash
#!/usr/bin/env bash
# directories containing potential secrets
DIRS="."
+161
View File
@@ -0,0 +1,161 @@
# pylint: disable=line-too-long
import datetime
import os
import re
import sys
from utils import run_command
RELEASE_KINDS = {'p': 'patch', 'm': 'minor', 'mj': 'major'}
def update_files(version):
"""Update all files needed with new release version"""
# pyproject.toml
sys.stdout.write("Update pyproject.toml...\n")
path = "src/backend/pyproject.toml"
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if line.startswith("version = "):
lines[index] = re.sub(r'\"(.*?)\"', f'"{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
# frontend package.json
sys.stdout.write("Update package.json...\n")
files_to_modify = []
filename = "package.json"
for root, _dir, files in os.walk("src/frontend"):
if filename in files and "node_modules" not in root and ".next" not in root:
files_to_modify.append(os.path.join(root, filename))
for path in files_to_modify:
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "version" in line:
lines[index] = re.sub(r'"version": \"(.*?)\"', f'"version": "{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
return
def update_helm_files(path):
sys.stdout.write("Update helm files...\n")
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "tag:" in line:
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
def update_changelog(path, version):
"""Update changelog file with release info
"""
sys.stdout.write("Update CHANGELOG...\n")
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "## [Unreleased]" in line:
today = datetime.date.today()
lines.insert(index + 1, f"\n## [{version}] - {today}\n")
if line.startswith("[unreleased]"):
last_version = lines[index + 1].split("]")[0][1:]
new_unreleased_line = line.replace(last_version, version)
new_release_line = lines[index + 1].replace(last_version, version)
lines[index] = new_unreleased_line
lines.insert(index + 1, new_release_line)
break
with open(path, 'w+') as file:
file.writelines(lines)
def deployment_part(version, kind):
""" Update helm file of preprod on deployment repository numerique-gouv/lasuite-deploiement
"""
# Move to lasuite-deploiement repository
try:
os.chdir('../lasuite-deploiement')
except FileNotFoundError:
sys.stdout.write("\033[1;31m[Error] You must have a clone of https://github.com/numerique-gouv/lasuite-deploiement\x1b[0m")
sys.stdout.write("\033[0;32mPlease clone it and re-run script with option --only-deployment-part\x1b[0m")
sys.stdout.write(" >>> python scripts/release.py --only-deployment-part \x1b[0m")
else:
run_command("git checkout main", shell=True)
run_command("git pull", shell=True)
deployment_branch = f"regie/{version}/preprod"
run_command(f"git checkout -b {deployment_branch}", shell=True)
run_command("git pull --rebase origin main", shell=True)
path = f"manifests/regie/env.d/preprod/values.desk.yaml.gotmpl"
update_helm_files(path)
run_command(f"git add {path}", shell=True)
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}"""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"""\033[0;32m
### DEPLOYMENT ###
NEXT COMMAND on lasuite-deploiement repository:
>> git push origin {deployment_branch}
Continue ? (y,n)
\x1b[0m""")
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:
--> Please submit PR {deployment_branch} and merge code to main
\x1b[0m""")
def project_part(version, kind):
"""Manage only la regie part with update of CHANGELOG, version files and tag"""
sys.stdout.write('Let\'s go to create branch to release\n')
branch_to_release = f"release/{version}"
run_command(f"git checkout -b {branch_to_release}", shell=True)
run_command("git pull --rebase origin main", shell=True)
update_changelog("CHANGELOG.md", version)
update_files(version)
run_command("git add CHANGELOG.md", shell=True)
run_command("git add src/", shell=True)
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"""\033[0;32m
### RELEASE ###
NEXT COMMAND on current repository:
>> git push origin {branch_to_release}
Continue ? (y,n)
\x1b[0m""")
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
--> Then do:
>> git checkout main
>> git pull
>> git tag v{version}
>> git push origin v{version}
--> Please check and wait for the docker image v{version} to be published here:
- https://hub.docker.com/r/lasuite/people-backend/tags
- https://hub.docker.com/r/lasuite/people-frontend/tags
--> Now please generate release on github interface for the current tag v{version}
\x1b[0m""")
if __name__ == "__main__":
version, kind = None, None
while not version:
version = input("Enter your release version:")
while kind not in RELEASE_KINDS:
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
if "--only-deployment-part" not in sys.argv:
project_part(version, kind)
deployment_part(version, kind)
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
git submodule update --init --recursive
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
+14
View File
@@ -0,0 +1,14 @@
import subprocess
import sys
def run_command(cmd, msg=None, shell=False, cwd='.', stdout=None):
if msg is None:
msg = f"# Running: {cmd}"
if stdout is not None:
stdout.write(msg + '\n')
if stdout != sys.stdout:
sys.stdout(msg)
subprocess.check_call(cmd, shell=shell, cwd=cwd, stdout=stdout, stderr=stdout)
if stdout is not None:
stdout.flush()
Submodule
+1
Submodule secrets added at b7ab5f1411
+2 -1
View File
@@ -363,7 +363,8 @@ name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# Ignore: private stuff and `Params` class from FactoryBoy
no-docstring-rgx=(^_|^Params$)
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
+1
View File
@@ -0,0 +1 @@
"""Root module."""
+1
View File
@@ -0,0 +1 @@
"""People custom admin site."""
+9
View File
@@ -0,0 +1,9 @@
"""Custom Django admin site application configuration."""
from django.contrib.admin.apps import AdminConfig
class PeopleAdminConfig(AdminConfig):
"""Declare our custom Django admin site."""
default_site = "admin.sites.PeopleAdminSite"
+15
View File
@@ -0,0 +1,15 @@
"""Custom Django admin site for the People app."""
from django.conf import settings
from django.contrib import admin
class PeopleAdminSite(admin.AdminSite):
"""People custom admin site."""
def each_context(self, request):
"""Add custom context to the admin site."""
return super().each_context(request) | {
"ADMIN_HEADER_BACKGROUND": settings.ADMIN_HEADER_BACKGROUND,
"ADMIN_HEADER_COLOR": settings.ADMIN_HEADER_COLOR,
}
@@ -0,0 +1,13 @@
{% extends "admin/base_site.html" %}
{% block extrastyle %}{{ block.super }}
<style>
html[data-theme="light"], :root {
{% if ADMIN_HEADER_BACKGROUND %}--header-bg: {{ ADMIN_HEADER_BACKGROUND }};{% endif %}
{% if ADMIN_HEADER_COLOR %}--header-color: {{ ADMIN_HEADER_COLOR }};{% endif %}
}
ul.messagelist li.info {
background-color: #EEEEEE;
}
</style>
{% endblock %}
+1
View File
@@ -0,0 +1 @@
"""Core root module."""
+211 -46
View File
@@ -1,46 +1,19 @@
"""Admin classes and registrations for People's core app."""
from django import forms
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from mailbox_manager.admin import MailDomainAccessInline
from . import models
class IdentityFormSet(forms.BaseInlineFormSet):
"""
Make the "is_main" field readonly when it is True so that declaring another identity
works in the admin.
"""
def add_fields(self, form, index):
"""Disable the "is_main" field when it is set to True"""
super().add_fields(form, index)
is_main_value = form.instance.is_main if form.instance else False
form.fields["is_main"].disabled = is_main_value
class IdentityInline(admin.TabularInline):
"""Inline admin class for user identities."""
fields = (
"sub",
"email",
"is_main",
"created_at",
"updated_at",
)
formset = IdentityFormSet
model = models.Identity
extra = 0
readonly_fields = ("email", "created_at", "sub", "updated_at")
def has_add_permission(self, request, obj):
"""
Identities are automatically created on successful OIDC logins.
Disable creating identities via the admin.
"""
return False
from .plugins.loader import (
get_organization_plugins,
organization_plugins_run_after_create,
)
class TeamAccessInline(admin.TabularInline):
@@ -52,21 +25,42 @@ class TeamAccessInline(admin.TabularInline):
readonly_fields = ("created_at", "updated_at")
class OrganizationAccessInline(admin.TabularInline):
"""Inline admin class for organization accesses."""
autocomplete_fields = ["user", "organization"]
extra = 0
model = models.OrganizationAccess
readonly_fields = ("created_at", "updated_at")
class TeamWebhookInline(admin.TabularInline):
"""Inline admin class for team webhooks."""
extra = 0
autocomplete_fields = ["team"]
model = models.TeamWebhook
readonly_fields = ("created_at", "updated_at")
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
autocomplete_fields = ["organization"]
fieldsets = (
(
None,
{
"fields": (
"id",
"sub",
"password",
"organization",
)
},
),
(_("Personal info"), {"fields": ("email", "language", "timezone")}),
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
(
_("Permissions"),
{
@@ -82,9 +76,19 @@ class UserAdmin(auth_admin.UserAdmin):
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
)
inlines = (IdentityInline, TeamAccessInline)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("sub", "email", "password1", "password2"),
},
),
)
inlines = (TeamAccessInline, MailDomainAccessInline, OrganizationAccessInline)
list_display = (
"email",
"get_user",
"organization",
"created_at",
"updated_at",
"is_active",
@@ -94,19 +98,180 @@ class UserAdmin(auth_admin.UserAdmin):
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "created_at", "updated_at")
search_fields = ("id", "email", "identities__sub", "identities__email")
readonly_fields = ["id", "created_at", "updated_at"]
search_fields = ("id", "email", "sub", "name")
def get_readonly_fields(self, request, obj=None):
"""The sub should only be editable for a create, not for updates."""
if obj:
return self.readonly_fields + ["sub"]
return self.readonly_fields
def get_user(self, obj):
"""Provide a nice display for user"""
return (
obj.name if obj.name else (obj.email if obj.email else f"[sub] {obj.sub}")
)
get_user.short_description = _("User")
class TeamServiceProviderInline(admin.TabularInline):
"""Inline admin class for service providers."""
can_delete = False
model = models.Team.service_providers.through
extra = 0
@admin.register(models.Team)
class TeamAdmin(admin.ModelAdmin):
class TeamAdmin(TreeAdmin):
"""Team admin interface declaration."""
inlines = (TeamAccessInline,)
form = movenodeform_factory(models.Team)
inlines = (TeamAccessInline, TeamWebhookInline, TeamServiceProviderInline)
exclude = ("service_providers",) # Handled by the inline
list_display = (
"name",
"slug",
"created_at",
"updated_at",
)
search_fields = ("name",)
readonly_fields = ("path", "depth", "numchild")
@admin.register(models.TeamAccess)
class TeamAccessAdmin(admin.ModelAdmin):
"""Team access admin interface declaration."""
list_display = (
"user",
"team",
"role",
"created_at",
"updated_at",
)
@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
"""Admin interface to handle invitations."""
fields = (
"email",
"team",
"role",
"created_at",
"issuer",
)
readonly_fields = (
"created_at",
"is_expired",
"issuer",
)
list_display = (
"email",
"team",
"created_at",
"is_expired",
)
def get_readonly_fields(self, request, obj=None):
"""Mark all fields read only, i.e. disable update."""
if obj:
return self.fields
return self.readonly_fields
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Custom edit form. Remove 'save' buttons."""
extra_context = extra_context or {}
extra_context["show_save_and_continue"] = False
extra_context["show_save"] = False
extra_context["show_save_and_add_another"] = False
return super().change_view(request, object_id, extra_context=extra_context)
def save_model(self, request, obj, form, change):
"""Fill in current logged-in user as issuer."""
obj.issuer = request.user
obj.save()
@admin.register(models.Contact)
class ContactAdmin(admin.ModelAdmin):
"""Contact admin interface declaration."""
list_display = (
"full_name",
"owner",
"override",
)
class OrganizationServiceProviderInline(admin.TabularInline):
"""Inline admin class for service providers."""
can_delete = False
model = models.Organization.service_providers.through
extra = 0
@admin.action(description=_("Run post creation plugins"), permissions=["change"])
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)
messages.success(
request,
_("Post creation plugins have been run for the selected organizations."),
)
@admin.register(models.Organization)
class OrganizationAdmin(admin.ModelAdmin):
"""Admin interface for organizations."""
actions = [run_post_creation_plugins]
list_display = (
"name",
"created_at",
"updated_at",
)
search_fields = ("name",)
inlines = (OrganizationAccessInline, OrganizationServiceProviderInline)
exclude = ("service_providers",) # Handled by the inline
def get_actions(self, request):
"""Adapt actions list to the context."""
actions = super().get_actions(request)
if not get_organization_plugins():
actions.pop("run_post_creation_plugins", None)
return actions
@admin.register(models.OrganizationAccess)
class OrganizationAccessAdmin(admin.ModelAdmin):
"""Organization access admin interface declaration."""
autocomplete_fields = ("user", "organization")
list_display = (
"user",
"organization",
"role",
"created_at",
"updated_at",
)
@admin.register(models.ServiceProvider)
class ServiceProviderAdmin(admin.ModelAdmin):
"""Admin interface for service providers."""
list_display = (
"name",
"audience_id",
"created_at",
"updated_at",
)
search_fields = ("name", "audience_id")
readonly_fields = ("created_at", "updated_at")
+3
View File
@@ -1,4 +1,5 @@
"""People core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -22,6 +23,8 @@ def exception_handler(exc, context):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages
else:
detail = ""
exc = drf_exceptions.ValidationError(detail=detail)
+1
View File
@@ -0,0 +1 @@
"""People core client API endpoints"""
+347
View File
@@ -0,0 +1,347 @@
"""Client serializers for the People core app."""
from rest_framework import exceptions, serializers
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models
from core.models import ServiceProvider
class ContactSerializer(serializers.ModelSerializer):
"""Serialize contacts."""
abilities = serializers.SerializerMethodField()
class Meta:
model = models.Contact
fields = [
"id",
"abilities",
"override",
"data",
"full_name",
"notes",
"owner",
"short_name",
]
read_only_fields = ["id", "owner", "abilities"]
extra_kwargs = {
"override": {"required": False},
}
def update(self, instance, validated_data):
"""Make "override" field readonly but only for update/patch."""
validated_data.pop("override", None)
return super().update(instance, validated_data)
def get_abilities(self, contact) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return contact.get_abilities(request.user)
return {}
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
A ModelSerializer that takes an additional `fields` argument that
controls which fields should be displayed.
"""
def __init__(self, *args, **kwargs):
"""Pass arguments to superclass except 'fields', then drop fields not listed therein."""
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop("fields", None)
# Instantiate the superclass normally
super().__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
class OrganizationSerializer(serializers.ModelSerializer):
"""Serialize organizations."""
abilities = serializers.SerializerMethodField()
class Meta:
model = models.Organization
fields = ["id", "name", "registration_id_list", "domain_list", "abilities"]
read_only_fields = ["id", "registration_id_list", "domain_list"]
def get_abilities(self, organization) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return organization.get_abilities(request.user)
return {}
class UserOrganizationSerializer(serializers.ModelSerializer):
"""Serialize organizations for users."""
class Meta:
model = models.Organization
fields = ["id", "name", "registration_id_list"]
read_only_fields = ["id", "name", "registration_id_list"]
class UserSerializer(DynamicFieldsModelSerializer):
"""Serialize users."""
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
email = serializers.ReadOnlyField()
name = serializers.ReadOnlyField()
organization = UserOrganizationSerializer(read_only=True)
class Meta:
model = models.User
fields = [
"id",
"email",
"language",
"name",
"organization",
"timezone",
"is_device",
"is_staff",
]
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
class UserMeSerializer(UserSerializer):
"""
Serialize the current user.
Same as the `UserSerializer` but with abilities.
"""
abilities = serializers.SerializerMethodField()
organization = UserOrganizationSerializer(read_only=True)
class Meta:
model = models.User
fields = [
"email",
"id",
"is_device",
"is_staff",
"language",
"name",
"organization",
"timezone",
# added fields
"abilities",
]
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
def get_abilities(self, user: models.User) -> dict:
"""Return abilities of the logged-in user on the instance."""
if user != self.context["request"].user: # Should not happen
raise RuntimeError(
"UserMeSerializer.get_abilities: user is not the same as the request user",
)
return user.get_abilities()
class TeamAccessSerializer(serializers.ModelSerializer):
"""Serialize team accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.TeamAccess
fields = ["id", "user", "role", "abilities"]
read_only_fields = ["id", "abilities"]
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_abilities(self, access) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return access.get_abilities(request.user)
return {}
def validate(self, attrs):
"""
Check access rights specific to writing (create/update)
"""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this team."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
team_id = self.context["team_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a team ID in kwargs to create a new team access."
) from exc
if not models.TeamAccess.objects.filter(
team=team_id,
user=user,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this team."
)
if (
role == models.RoleChoices.OWNER
and not models.TeamAccess.objects.filter(
team=team_id,
user=user,
role=models.RoleChoices.OWNER,
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a team can assign other users as owners."
)
attrs["team_id"] = self.context["team_id"]
return attrs
class TeamAccessReadOnlySerializer(TeamAccessSerializer):
"""Serialize team accesses for list and retrieve actions."""
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
class Meta:
model = models.TeamAccess
fields = [
"id",
"user",
"role",
"abilities",
]
read_only_fields = [
"id",
"user",
"role",
"abilities",
]
class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
abilities = serializers.SerializerMethodField(read_only=True)
service_providers = serializers.PrimaryKeyRelatedField(
queryset=ServiceProvider.objects.all(), many=True, required=False
)
class Meta:
model = models.Team
fields = [
"id",
"abilities",
"accesses",
"created_at",
"depth",
"name",
"numchild",
"path",
"service_providers",
"updated_at",
]
read_only_fields = [
"id",
"abilities",
"accesses",
"created_at",
"depth",
"numchild",
"path",
"updated_at",
]
def create(self, validated_data):
"""Create a new team with organization enforcement."""
# When called as a resource server, we enforce the team service provider
if sp_audience := self.context.get("from_service_provider_audience", None):
service_provider, _created = models.ServiceProvider.objects.get_or_create(
audience_id=sp_audience
)
validated_data["service_providers"] = [service_provider]
# Note: this is not the purpose of this API to check the user has an organization
return super().create(
validated_data=validated_data
| {"organization_id": self.context["request"].user.organization_id}
)
def get_abilities(self, team) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return team.get_abilities(request.user)
return {}
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
class Meta:
model = models.Invitation
fields = ["id", "created_at", "email", "team", "role", "issuer", "is_expired"]
read_only_fields = ["id", "created_at", "team", "issuer", "is_expired"]
def validate(self, attrs):
"""Validate and restrict invitation to new user based on email."""
request = self.context.get("request")
user = getattr(request, "user", None)
try:
team_id = self.context["team_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a team ID in kwargs to create a new team invitation."
) from exc
if not models.TeamAccess.objects.filter(
team=team_id,
user=user,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage invitation for this team."
)
attrs["team_id"] = team_id
attrs["issuer"] = user
return attrs
class ServiceProviderSerializer(serializers.ModelSerializer):
"""Serialize service providers."""
class Meta:
model = models.ServiceProvider
fields = ["id", "audience_id", "name"]
read_only_fields = ["id", "audience_id"]
+662
View File
@@ -0,0 +1,662 @@
"""API endpoints"""
import datetime
import operator
from functools import reduce
from django.conf import settings
from django.db.models import OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework import (
decorators,
exceptions,
filters,
mixins,
pagination,
response,
throttling,
views,
viewsets,
)
from rest_framework.permissions import AllowAny
from core import models
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 models as domains_models
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, item):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, item + "s", [None])[-1]
return super().__getattribute__(item)
def get_queryset(self):
"""
Get the list of items for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
This mixin is useful to avoid to define a serializer class for each action in the
`get_serializer_class` method.
Example:
```
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
serializer_class = MySerializer
list_serializer_class = MyListSerializer
retrieve_serializer_class = MyRetrieveSerializer
```
"""
def get_serializer_class(self):
"""
Return the serializer class to use depending on the action.
"""
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
return serializer_class
return super().get_serializer_class()
class Pagination(pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
max_page_size = 100
page_size_query_param = "page_size"
class BurstRateThrottle(throttling.UserRateThrottle):
"""
Throttle rate for minutes. See DRF section in settings for default value.
"""
scope = "burst"
class SustainedRateThrottle(throttling.UserRateThrottle):
"""
Throttle rate for hours. See DRF section in settings for default value.
"""
scope = "sustained"
# pylint: disable=too-many-ancestors
class ContactViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Contact ViewSet"""
permission_classes = [permissions.AccessPermission]
queryset = models.Contact.objects.select_related("user").all()
serializer_class = serializers.ContactSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
ordering_fields = ["full_name", "short_name", "created_at"]
ordering = ["full_name"]
def list(self, request, *args, **kwargs):
"""Limit listed users by a query with throttle protection."""
user = self.request.user
queryset = self.filter_queryset(self.get_queryset())
# List only contacts that:
queryset = queryset.filter(
# - is public (owner is None)
Q(owner__isnull=True)
# - are owned by the user
| Q(owner=user)
# - are profile contacts for a user from the same organization
| Q(user__organization_id=user.organization_id),
# - are not overriden by another contact
overridden_by__isnull=True,
)
# Search by case-insensitive and accent-insensitive on:
# - full name
# - short name
# - email (from data `emails` field)
if query := self.request.GET.get("q", ""):
queryset = queryset.filter(
Q(full_name__unaccent__icontains=query)
| Q(short_name__unaccent__icontains=query)
| Q(
id__in=gen_sql_filter_json_array(
queryset.model,
"data->'emails'",
"value",
query,
)
)
)
serializer = self.get_serializer(queryset, many=True)
return response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created contact."""
user = self.request.user
serializer.validated_data["owner"] = user
return super().perform_create(serializer)
class OrganizationViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
Organization ViewSet
GET /api/organizations/<organization_id>/
Return the organization details for the given id.
PUT /api/organizations/<organization_id>/
Update the organization details for the given id.
PATCH /api/organizations/<organization_id>/
Partially update the organization details for the given id.
"""
permission_classes = [permissions.AccessPermission]
queryset = models.Organization.objects.all()
serializer_class = serializers.OrganizationSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
def get_queryset(self):
"""Limit listed organizations to the one the user belongs to."""
return (
super()
.get_queryset()
.filter(pk=self.request.user.organization_id)
.annotate(
user_role=Subquery(
models.OrganizationAccess.objects.filter(
user=self.request.user, organization=OuterRef("pk")
).values("role")[:1]
)
)
)
class UserViewSet(
SerializerPerActionMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
mixins.ListModelMixin,
):
"""
User viewset for all interactions with user infos and teams.
GET /api/users/&q=query
Return a list of users whose email or name matches the query.
"""
permission_classes = [permissions.IsSelf]
queryset = (
models.User.objects.select_related("organization").all().order_by("-created_at")
)
serializer_class = serializers.UserSerializer
get_me_serializer_class = serializers.UserMeSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
pagination_class = Pagination
def get_queryset(self):
"""Limit listed users by a query. Pagination and throttle protection apply."""
queryset = self.queryset
if self.action == "list":
# Exclude inactive contacts
queryset = queryset.filter(
is_active=True,
)
# Exclude all users already in the given team
if team_id := self.request.GET.get("team_id", ""):
queryset = queryset.exclude(teams__id=team_id)
# Search by case-insensitive and accent-insensitive
if query := self.request.GET.get("q", ""):
queryset = queryset.filter(
Q(name__unaccent__icontains=query)
| Q(email__unaccent__icontains=query)
)
return queryset
@decorators.action(
detail=False,
methods=["get"],
url_name="me",
url_path="me",
)
def get_me(self, request):
"""
Return information on currently logged user
"""
user = request.user
return response.Response(self.get_serializer(user).data)
class TeamViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Team ViewSet"""
permission_classes = [permissions.TeamPermission, permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "name", "path"]
ordering = ["-created_at"]
queryset = models.Team.objects.all()
pagination_class = None
def get_queryset(self):
"""Custom queryset to get user related teams."""
teams_queryset = models.Team.objects.filter(
accesses__user=self.request.user,
)
depth_path = teams_queryset.values("depth", "path")
if not depth_path:
return models.Team.objects.none()
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=OuterRef("pk")
).values("role")[:1]
return (
models.Team.objects.prefetch_related("accesses", "service_providers")
.filter(
reduce(
operator.or_,
(
Q(
# The team the user has access to
depth=d["depth"],
path=d["path"],
)
| Q(
# The parent team the user has access to
depth__lt=d["depth"],
path__startswith=d["path"][: models.Team.steplen],
organization_id=self.request.user.organization_id,
)
for d in depth_path
),
),
)
# Abilities are computed based on logged-in user's role for the team
# and if the user does not have access, it's ok to consider them as a member
# because it's a parent team.
.annotate(
user_role=Coalesce(
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
)
)
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created team."""
team = serializer.save()
models.TeamAccess.objects.create(
team=team,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
class TeamAccessViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with team accesses.
GET /api/v1.0/teams/<team_id>/accesses/:<team_access_id>
Return list of all team accesses related to the logged-in user or one
team access if an id is provided.
POST /api/v1.0/teams/<team_id>/accesses/ with expected data:
- user: str
- role: str [owner|admin|member]
Return newly created team access
PUT /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
- role: str [owner|admin|member]
Return updated team access
PATCH /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
- role: str [owner|admin|member]
Return partially updated team access
DELETE /api/v1.0/teams/<team_id>/accesses/<team_access_id>/
Delete targeted team access
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = (
models.TeamAccess.objects.all().select_related("user").order_by("-created_at")
)
list_serializer_class = serializers.TeamAccessReadOnlySerializer
detail_serializer_class = serializers.TeamAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering = ["role"]
ordering_fields = ["role", "user__email", "user__name"]
def get_permissions(self):
"""User only needs to be authenticated to list team accesses"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["team_id"] = self.kwargs["team_id"]
return context
def get_serializer_class(self):
"""Chooses list or detail serializer according to the action."""
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(team=self.kwargs["team_id"])
if self.action in {"list", "retrieve"}:
if query := self.request.GET.get("q", ""):
queryset = queryset.filter(
Q(user__email__unaccent__icontains=query)
| Q(user__name__unaccent__icontains=query)
)
# Determine which role the logged-in user has in the team
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=self.kwargs["team_id"]
).values("role")[:1]
queryset = (
# The logged-in user should be part of a team to see its accesses
queryset.filter(
team__accesses__user=self.request.user,
)
# Abilities are computed based on logged-in user's role and
# the user role on each team access
.annotate(
user_role=Subquery(user_role_query),
)
.select_related("user")
.distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
team = instance.team
# Check if the access being deleted is the last owner access for the team
if instance.role == "owner" and team.accesses.filter(role="owner").count() == 1:
return response.Response(
{"detail": "Cannot delete the last owner access for the team."},
status=400,
)
return super().destroy(request, *args, **kwargs)
def perform_update(self, serializer):
"""Check that we don't change the role if it leads to losing the last owner."""
instance = serializer.instance
# Check if the role is being updated and the new role is not "owner"
if (
"role" in self.request.data
and self.request.data["role"] != models.RoleChoices.OWNER
):
team = instance.team
# Check if the access being updated is the last owner access for the team
if (
instance.role == models.RoleChoices.OWNER
and team.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.ValidationError({"role": message})
serializer.save()
class InvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to team.
GET /api/v1.0/teams/<team_id>/invitations/:<invitation_id>/
Return list of invitations related to that team or or one
team access if an id is provided.
POST /api/v1.0/teams/<team_id>/invitations/ with expected data:
- email: str
- role: str [owner|admin|member]
- issuer : User, automatically added from user making query, if allowed
- team : Team, automatically added from requested URI
Return newly created invitation
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /api/v1.0/teams/<team_id>/invitations/<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = (
models.Invitation.objects.all().select_related("team").order_by("-created_at")
)
serializer_class = serializers.InvitationSerializer
def get_permissions(self):
"""User only needs to be authenticated to list invitations"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["team_id"] = self.kwargs["team_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(team=self.kwargs["team_id"])
if self.action == "list":
# Determine which role the logged-in user has in the team
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=self.kwargs["team_id"]
).values("role")[:1]
queryset = (
# The logged-in user should be part of a team to see its accesses
queryset.filter(
team__accesses__user=self.request.user,
)
# Abilities are computed based on logged-in user's role and
# the user role on each team access
.annotate(user_role=Subquery(user_role_query))
.distinct()
)
return queryset
class ConfigView(views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = ["LANGUAGES", "FEATURES", "RELEASE", "COMMIT"]
dict_settings = {}
for setting in array_settings:
dict_settings[setting] = getattr(settings, setting)
return response.Response(dict_settings)
class StatView(views.APIView):
"""API ViewSet for sharing some public metrics."""
permission_classes = [AllowAny]
@method_decorator(cache_page(3600))
def get(self, request):
"""
GET /api/v1.0/stats/
Return a dictionary of public metrics.
"""
context = {
"total_users": models.User.objects.count(),
"mau": models.User.objects.filter(
last_login__gte=timezone.now() - datetime.timedelta(30)
).count(),
"teams": models.Team.objects.count(),
"domains": domains_models.MailDomain.objects.count(),
"mailboxes": domains_models.Mailbox.objects.count(),
}
return response.Response(context)
class ServiceProviderFilter(filters.BaseFilterBackend):
"""
Filter service providers.
"""
def filter_queryset(self, request, queryset, view):
"""
Filter service providers by audience or name.
"""
if name := request.GET.get("name"):
queryset = queryset.filter(name__icontains=name)
if audience_id := request.GET.get("audience_id"):
queryset = queryset.filter(audience_id=audience_id)
return queryset
class ServiceProviderViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with service providers.
GET /api/v1.0/service-providers/
Return a list of service providers.
GET /api/v1.0/service-providers/<service_provider_id>/
Return a service provider.
"""
permission_classes = [permissions.IsAuthenticated]
queryset = models.ServiceProvider.objects.all()
serializer_class = serializers.ServiceProviderSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
pagination_class = Pagination
filter_backends = [filters.OrderingFilter, ServiceProviderFilter]
ordering = ["name"]
ordering_fields = ["name", "created_at"]
def get_queryset(self):
"""Filter the queryset to limit results to user's organization."""
queryset = super().get_queryset()
queryset = queryset.filter(organizations__id=self.request.user.organization_id)
return queryset
+18 -2
View File
@@ -1,4 +1,5 @@
"""Permission handlers for the People core app."""
from django.core import exceptions
from rest_framework import permissions
@@ -11,13 +12,13 @@ class IsAuthenticated(permissions.BasePermission):
"""
def has_permission(self, request, view):
"""Check auth token first."""
return bool(request.auth) if request.auth else request.user.is_authenticated
class IsSelf(IsAuthenticated):
"""
Allows access only to authenticated users. Alternative method checking the presence
of the auth token to avoid hitting the database.
Allows access only to user's own data.
"""
def has_object_permission(self, request, view, obj):
@@ -52,3 +53,18 @@ class AccessPermission(IsAuthenticated):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
class TeamPermission(IsAuthenticated):
"""Permission class for team objects viewset."""
def has_permission(self, request, view):
"""Check permission only when the user tries to create a new team."""
if not super().has_permission(request, view):
return False
if request.method != "POST":
return True
abilities = request.user.get_abilities()
return abilities["teams"]["can_create"]
@@ -0,0 +1 @@
"""People core resource server API endpoints"""
@@ -0,0 +1,52 @@
"""Client serializers for the People core app resource server API."""
from rest_framework import serializers
from core import models
class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
class Meta:
model = models.Team
fields = [
"id",
"created_at",
"depth",
"name",
"numchild",
"path",
"updated_at",
]
read_only_fields = [
"id",
"created_at",
"depth",
"numchild",
"path",
"updated_at",
]
def create(self, validated_data):
"""
Create a new team with organization enforcement.
In this context, called as a resource server,
the team service provider is enforced.
When the service provider audience is unknown it is created on the fly.
"""
sp_audience = self.context["from_service_provider_audience"]
service_provider, _created = models.ServiceProvider.objects.get_or_create(
audience_id=sp_audience
)
# Note: this is not the purpose of this API to check the user has an organization
return super().create(
validated_data=validated_data
| {
"organization_id": self.context["request"].user.organization_id,
"service_providers": [service_provider],
},
)
@@ -0,0 +1,125 @@
"""Resource server API endpoints"""
import operator
from functools import reduce
from django.db.models import OuterRef, Prefetch, Q, Subquery, Value
from django.db.models.functions import Coalesce
from rest_framework import (
filters,
mixins,
viewsets,
)
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
class TeamViewSet( # pylint: disable=too-many-ancestors
ResourceServerMixin,
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
Team ViewSet dedicated to the resource server.
The DELETE method is not allowed for now, because the use case is
not clear yet and it comes with complexity to know if we can delete
a team or not (eg. if a team has other SP, it might not be deleted
but what do we do then, only remove the current SP?).
GET /resource-server/v1.0/teams/
Return list of Teams of the user and available for the audience.
POST /resource-server/v1.0/teams/
Create a new Team for the user for the audience.
GET /resource-server/v1.0/teams/{team_id}/
Return the Team details if available for the audience.
PUT /resource-server/v1.0/teams/{team_id}/
Update the Team details (only name for now).
"""
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
ordering = ["-created_at"]
queryset = models.Team.objects.all()
pagination_class = Pagination
def get_queryset(self):
"""Custom queryset to get user related teams."""
teams_queryset = models.Team.objects.filter(
accesses__user=self.request.user,
)
depth_path = teams_queryset.values("depth", "path")
if not depth_path:
return models.Team.objects.none()
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=OuterRef("pk")
).values("role")[:1]
service_provider_audience = self._get_service_provider_audience()
service_provider_prefetch = Prefetch(
"service_providers",
queryset=models.ServiceProvider.objects.filter(
audience_id=service_provider_audience
),
)
return (
models.Team.objects.prefetch_related(
"accesses",
service_provider_prefetch,
)
.filter(
reduce(
operator.or_,
(
Q(
# The team the user has access to
depth=d["depth"],
path=d["path"],
)
| Q(
# The parent team the user has access to
depth__lt=d["depth"],
path__startswith=d["path"][: models.Team.steplen],
organization_id=self.request.user.organization_id,
)
for d in depth_path
),
),
service_providers__audience_id=service_provider_audience,
)
# Abilities are computed based on logged-in user's role for the team
# and if the user does not have access, it's ok to consider them as a member
# because it's a parent team.
.annotate(
user_role=Coalesce(
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
)
)
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created team."""
team = serializer.save()
models.TeamAccess.objects.create(
team=team,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
-150
View File
@@ -1,150 +0,0 @@
"""Client serializers for the People core app."""
from rest_framework import exceptions, serializers
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models
class ContactSerializer(serializers.ModelSerializer):
"""Serialize contacts."""
class Meta:
model = models.Contact
fields = [
"id",
"base",
"data",
"full_name",
"owner",
"short_name",
]
read_only_fields = ["id", "owner"]
def update(self, instance, validated_data):
"""Make "base" field readonly but only for update/patch."""
validated_data.pop("base", None)
return super().update(instance, validated_data)
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
data = serializers.SerializerMethodField(read_only=True)
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
class Meta:
model = models.User
fields = [
"id",
"email",
"data",
"language",
"timezone",
"is_device",
"is_staff",
]
read_only_fields = ["id", "email", "data", "is_device", "is_staff"]
def get_data(self, user) -> dict:
"""Return contact data for the user."""
return user.profile_contact.data if user.profile_contact else {}
class TeamAccessSerializer(serializers.ModelSerializer):
"""Serialize team accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.TeamAccess
fields = ["id", "user", "role", "abilities"]
read_only_fields = ["id", "abilities"]
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_abilities(self, access) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return access.get_abilities(request.user)
return {}
def validate(self, attrs):
"""
Check access rights specific to writing (create/update)
"""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this team."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
team_id = self.context["team_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a team ID in kwargs to create a new team access."
) from exc
if not models.TeamAccess.objects.filter(
team=team_id,
user=user,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this team."
)
if (
role == models.RoleChoices.OWNER
and not models.TeamAccess.objects.filter(
team=team_id,
user=user,
role=models.RoleChoices.OWNER,
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a team can assign other users as owners."
)
attrs["team_id"] = self.context["team_id"]
return attrs
class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TeamAccessSerializer(many=True, read_only=True)
slug = serializers.SerializerMethodField()
class Meta:
model = models.Team
fields = ["id", "name", "accesses", "abilities", "slug"]
read_only_fields = ["id", "accesses", "abilities", "slug"]
def get_abilities(self, team) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return team.get_abilities(request.user)
return {}
def get_slug(self, instance):
"""Return slug from the team's name."""
return instance.get_slug()
-14
View File
@@ -1,14 +0,0 @@
"""
Utils that can be useful throughout the People core app
"""
from rest_framework_simplejwt.tokens import RefreshToken
def get_tokens_for_user(user):
"""Get JWT tokens for user authentication."""
refresh = RefreshToken.for_user(user)
return {
"refresh": str(refresh),
"access": str(refresh.access_token),
}
-374
View File
@@ -1,374 +0,0 @@
"""API endpoints"""
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
from rest_framework import (
decorators,
exceptions,
mixins,
pagination,
response,
viewsets,
)
from rest_framework.throttling import UserRateThrottle
from core import models
from . import permissions, serializers
EMAIL_SIMILARITY_THRESHOLD = 0.01
# TrigramSimilarity threshold is lower for searching email than for names,
# to improve matching results
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, item):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, item + "s", [None])[-1]
return super().__getattribute__(item)
def get_queryset(self):
"""
Get the list of items for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
This mixin is useful to avoid to define a serializer class for each action in the
`get_serializer_class` method.
"""
serializer_classes: dict[str, type] = {}
default_serializer_class: type = None
def get_serializer_class(self):
"""
Return the serializer class to use depending on the action.
"""
return self.serializer_classes.get(self.action, self.default_serializer_class)
class Pagination(pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
max_page_size = 100
page_size_query_param = "page_size"
class BurstRateThrottle(UserRateThrottle):
"""
Throttle rate for minutes. See DRF section in settings for default value.
"""
scope = "burst"
class SustainedRateThrottle(UserRateThrottle):
"""
Throttle rate for hours. See DRF section in settings for default value.
"""
scope = "sustained"
# pylint: disable=too-many-ancestors
class ContactViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Contact ViewSet"""
permission_classes = [permissions.IsOwnedOrPublic]
queryset = models.Contact.objects.all()
serializer_class = serializers.ContactSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
def list(self, request, *args, **kwargs):
"""Limit listed users by a query with throttle protection."""
user = self.request.user
queryset = self.filter_queryset(self.get_queryset())
# Exclude contacts that:
queryset = queryset.filter(
# - belong to another user (keep public and owned contacts)
Q(owner__isnull=True) | Q(owner=user),
# - are profile contacts for a user
user__isnull=True,
# - are overriden base contacts
overriding_contacts__isnull=True,
)
# Search by case-insensitive and accent-insensitive trigram similarity
if query := self.request.GET.get("q", ""):
query = Func(Value(query), function="unaccent")
similarity = TrigramSimilarity(
Func("full_name", function="unaccent"),
query,
) + TrigramSimilarity(Func("short_name", function="unaccent"), query)
queryset = (
queryset.annotate(similarity=similarity)
.filter(
similarity__gte=0.05
) # Value determined by testing (test_api_contacts.py)
.order_by("-similarity")
)
serializer = self.get_serializer(queryset, many=True)
return response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created contact."""
user = self.request.user
serializer.validated_data["owner"] = user
return super().perform_create(serializer)
class UserViewSet(
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
):
"""
User viewset for all interactions with user infos and teams.
GET /api/users/&q=query
Return a list of users whose email matches the query. Similarity is
calculated using trigram similarity, allowing for partial,
case-insensitive matches and accented queries.
"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all().select_related("profile_contact")
serializer_class = serializers.UserSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
pagination_class = Pagination
def get_queryset(self):
"""Limit listed users by a query. Pagination and throttle protection apply."""
queryset = self.queryset
if self.action == "list":
# Exclude inactive contacts
queryset = queryset.filter(
is_active=True,
)
# Search by case-insensitive and accent-insensitive trigram similarity
if query := self.request.GET.get("q", ""):
similarity = Max(
TrigramSimilarity(
Func("identities__email", function="unaccent"),
Func(Value(query), function="unaccent"),
)
)
queryset = (
queryset.annotate(similarity=similarity)
.filter(similarity__gte=EMAIL_SIMILARITY_THRESHOLD)
.order_by("-similarity")
)
return queryset
@decorators.action(
detail=False,
methods=["get"],
url_name="me",
url_path="me",
)
def get_me(self, request):
"""
Return information on currently logged user
"""
context = {"request": request}
return response.Response(
self.serializer_class(request.user, context=context).data
)
class TeamViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Team ViewSet"""
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
queryset = models.Team.objects.all()
def get_queryset(self):
"""Custom queryset to get user related teams."""
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=OuterRef("pk")
).values("role")[:1]
return models.Team.objects.filter(accesses__user=self.request.user).annotate(
user_role=Subquery(user_role_query)
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created team."""
team = serializer.save()
models.TeamAccess.objects.create(
team=team,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
class TeamAccessViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with team accesses.
GET /api/v1.0/teams/<team_id>/accesses/:<team_access_id>
Return list of all team accesses related to the logged-in user or one
team access if an id is provided.
POST /api/v1.0/teams/<team_id>/accesses/ with expected data:
- user: str
- role: str [owner|admin|member]
Return newly created team access
PUT /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
- role: str [owner|admin|member]
Return updated team access
PATCH /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
- role: str [owner|admin|member]
Return partially updated team access
DELETE /api/v1.0/teams/<team_id>/accesses/<team_access_id>/
Delete targeted team access
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = models.TeamAccess.objects.all().select_related("user")
serializer_class = serializers.TeamAccessSerializer
def get_permissions(self):
"""User only needs to be authenticated to list team accesses"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["team_id"] = self.kwargs["team_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(team=self.kwargs["team_id"])
if self.action == "list":
# Limit to team access instances related to a team THAT also has a team access
# instance for the logged-in user (we don't want to list only the team access
# instances pointing to the logged-in user)
user_role_query = models.TeamAccess.objects.filter(
team__accesses__user=self.request.user
).values("role")[:1]
queryset = (
queryset.filter(
team__accesses__user=self.request.user,
)
.annotate(user_role=Subquery(user_role_query))
.distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
team = instance.team
# Check if the access being deleted is the last owner access for the team
if instance.role == "owner" and team.accesses.filter(role="owner").count() == 1:
return response.Response(
{"detail": "Cannot delete the last owner access for the team."},
status=400,
)
return super().destroy(request, *args, **kwargs)
def perform_update(self, serializer):
"""Check that we don't change the role if it leads to losing the last owner."""
instance = serializer.instance
# Check if the role is being updated and the new role is not "owner"
if (
"role" in self.request.data
and self.request.data["role"] != models.RoleChoices.OWNER
):
team = instance.team
# Check if the access being updated is the last owner access for the team
if (
instance.role == models.RoleChoices.OWNER
and team.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.ValidationError({"role": message})
serializer.save()
-59
View File
@@ -1,59 +0,0 @@
"""Authentication for the People core app."""
from django.conf import settings
from django.utils.functional import SimpleLazyObject
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from drf_spectacular.authentication import SessionScheme, TokenScheme
from drf_spectacular.plumbing import build_bearer_security_scheme_object
from rest_framework import authentication
from rest_framework_simplejwt.authentication import JWTAuthentication
class DelegatedJWTAuthentication(JWTAuthentication):
"""Override JWTAuthentication to create missing users on the fly."""
def get_user(self, validated_token):
"""
Return the user related to the given validated token, creating or updating it if necessary.
"""
get_user = import_string(settings.JWT_USER_GETTER)
return SimpleLazyObject(lambda: get_user(validated_token))
class OpenApiJWTAuthenticationExtension(TokenScheme):
"""Extension for specifying JWT authentication schemes."""
target_class = "core.authentication.DelegatedJWTAuthentication"
name = "DelegatedJWTAuthentication"
def get_security_definition(self, auto_schema):
"""Return the security definition for JWT authentication."""
return build_bearer_security_scheme_object(
header_name="Authorization",
token_prefix="Bearer", # noqa S106
)
class SessionAuthenticationWithAuthenticateHeader(authentication.SessionAuthentication):
"""
This class is needed, because REST Framework's default SessionAuthentication does
never return 401's, because they cannot fill the WWW-Authenticate header with a
valid value in the 401 response. As a result, we cannot distinguish calls that are
not unauthorized (401 unauthorized) and calls for which the user does not have
permission (403 forbidden).
See https://github.com/encode/django-rest-framework/issues/5968
We do set authenticate_header function in SessionAuthentication, so that a value
for the WWW-Authenticate header can be retrieved and the response code is
automatically set to 401 in case of unauthenticated requests.
"""
def authenticate_header(self, request):
return "Session"
class OpenApiSessionAuthenticationExtension(SessionScheme):
"""Extension for specifying session authentication schemes."""
target_class = "core.api.authentication.SessionAuthenticationWithAuthenticateHeader"
@@ -0,0 +1 @@
"""Authentication module."""
+247
View File
@@ -0,0 +1,247 @@
"""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 core.models import (
Contact,
Organization,
OrganizationAccess,
OrganizationRoleChoices,
)
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):
"""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.
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.
Returns:
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
"""
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),
}
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
)
# if sub is absent, try matching on email
user = self.get_existing_user(sub, email)
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)
# 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
if not user.organization_id:
organization_registration_id = claims.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
)
domain = get_domain_from_email(email)
try:
organization, organization_created = (
Organization.objects.get_or_create_from_user_claims(
registration_id=organization_registration_id,
domain=domain,
)
)
if organization_created:
logger.info("Organization %s created", organization)
# For this case, we don't create an OrganizationAccess we will
# manage this manually later, because we don't want the first
# user who log in after the release to be the admin of their
# organization. We will keep organization without admin, and
# we will have to manually clean things up (while there is
# not that much organization in the database).
except ValueError as exc:
# Raised when there is no recognizable organization
# identifier (domain or registration_id)
logger.warning("Unable to update user organization: %s", exc)
else:
user.organization = organization
user.save()
logger.info(
"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")
if sub is None:
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
)
email = claims.get("email")
name = claims.get("name")
# Extract or create the organization from the data
organization_registration_id = claims.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
)
domain = get_domain_from_email(email)
try:
organization, organization_created = (
Organization.objects.get_or_create_from_user_claims(
registration_id=organization_registration_id,
domain=domain,
)
)
except ValueError as exc:
raise SuspiciousOperation(
_("Claims contained no recognizable organization identification")
) from exc
if organization_created:
logger.info("Organization %s created", organization)
logger.info("Creating user %s / %s", sub, email)
user = self.UserModel.objects.create(
organization=organization,
password="!", # noqa: S106
sub=sub,
email=email,
name=name,
)
if organization_created:
# Warning: we may remove this behavior in the near future when we
# add a feature to claim the organization ownership.
OrganizationAccess.objects.create(
organization=organization,
user=user,
role=OrganizationRoleChoices.ADMIN,
)
# Initiate the user's profile
Contact.objects.create(
owner=user,
user=user,
full_name=name or email,
data={
"emails": [
{"type": "Work", "value": email},
],
},
)
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."""
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
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)
+18
View File
@@ -0,0 +1,18 @@
"""Authentication URLs for the People core app."""
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozzila_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",
),
*mozzila_oidc_urls,
]
+137
View File
@@ -0,0 +1,137 @@
"""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)
+10
View File
@@ -1,7 +1,9 @@
"""
Core application enums declaration
"""
from django.conf import global_settings, settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
@@ -13,3 +15,11 @@ ALL_LANGUAGES = getattr(
"ALL_LANGUAGES",
[(language, _(name)) for language, name in global_settings.LANGUAGES],
)
class WebhookStatusChoices(models.TextChoices):
"""Defines the possible statuses in which a webhook can be."""
FAILURE = "failure", _("Failure")
PENDING = "pending", _("Pending")
SUCCESS = "success", _("Success")
+129 -22
View File
@@ -2,6 +2,7 @@
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
@@ -114,31 +115,83 @@ class ContactFactory(BaseContactFactory):
class Meta:
model = models.Contact
base = factory.SubFactory("core.factories.ContactFactory", base=None, owner=None)
owner = factory.SubFactory("core.factories.UserFactory", profile_contact=None)
owner = factory.SubFactory("core.factories.UserFactory")
class ProfileContactFactory(BaseContactFactory):
"""A factory to create profile contacts for a user"""
class Meta:
model = models.Contact
owner = factory.SelfAttribute(".user")
user = factory.SubFactory("core.factories.UserFactory")
class OverrideContactFactory(BaseContactFactory):
"""A factory to create contacts for a user"""
class Meta:
model = models.Contact
override = factory.SubFactory("core.factories.ContactFactory", owner=None)
owner = factory.SubFactory("core.factories.UserFactory")
class OrganizationFactory(factory.django.DjangoModelFactory):
"""Factory to create organizations for testing purposes."""
name = factory.Faker("company")
class Meta:
model = models.Organization
class Params: # pylint: disable=missing-class-docstring
with_registration_id = factory.Trait(
registration_id_list=factory.List(
[factory.Sequence(lambda n: f"{n:014d}")]
),
)
with_domain = factory.Trait(
domain_list=factory.List([factory.Faker("domain_name")]),
)
class OrganizationAccessFactory(factory.django.DjangoModelFactory):
"""Factory to create organization accesses for testing purposes."""
class Meta:
model = models.OrganizationAccess
user = factory.SubFactory(
"core.factories.UserFactory",
organization=factory.SelfAttribute("..organization"),
)
organization = factory.SubFactory(
"core.factories.OrganizationFactory", with_registration_id=True
)
role = factory.fuzzy.FuzzyChoice(models.OrganizationRoleChoices.values)
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to random users for testing purposes."""
"""A factory to create random users for testing purposes."""
class Meta:
model = models.User
email = factory.Faker("email")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
class IdentityFactory(factory.django.DjangoModelFactory):
"""A factory to create identities for a user"""
class Meta:
model = models.Identity
django_get_or_create = ("sub",)
user = factory.SubFactory(UserFactory)
class Params:
with_organization = factory.Trait(
organization=factory.SubFactory(
OrganizationFactory, with_registration_id=True
),
)
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
name = factory.Faker("name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
class TeamFactory(factory.django.DjangoModelFactory):
@@ -146,19 +199,27 @@ class TeamFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.Team
django_get_or_create = ("name",)
skip_postgeneration_save = True
name = factory.Sequence(lambda n: f"team{n}")
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to team from a given list of users with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, models.User):
TeamAccessFactory(team=self, user=item)
else:
TeamAccessFactory(team=self, user=item[0], role=item[1])
if not create or not extracted:
return
for user_entry in extracted:
if isinstance(user_entry, models.User):
TeamAccessFactory(team=self, user=user_entry)
else:
TeamAccessFactory(team=self, user=user_entry[0], role=user_entry[1])
@factory.post_generation
def service_providers(self, create, extracted, **kwargs):
"""Add service providers to team from a given list of service providers."""
if not create or not extracted:
return
self.service_providers.set(extracted)
class TeamAccessFactory(factory.django.DjangoModelFactory):
@@ -170,3 +231,49 @@ class TeamAccessFactory(factory.django.DjangoModelFactory):
team = factory.SubFactory(TeamFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TeamWebhookFactory(factory.django.DjangoModelFactory):
"""Create fake team webhooks for testing."""
class Meta:
model = models.TeamWebhook
team = factory.SubFactory(TeamFactory)
url = factory.Sequence(lambda n: f"https://nosuchdomain.xyz/Groups/{n!s}")
class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""
class Meta:
model = models.Invitation
team = factory.SubFactory(TeamFactory)
email = factory.Faker("email")
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
class ServiceProviderFactory(factory.django.DjangoModelFactory):
"""A factory to create service providers for testing purposes."""
class Meta:
model = models.ServiceProvider
skip_postgeneration_save = True
audience_id = factory.Faker("uuid4")
@factory.post_generation
def teams(self, create, extracted, **kwargs):
"""Add teams to service provider from a given list."""
if not create or not extracted:
return
self.teams.set(extracted)
@factory.post_generation
def organizations(self, create, extracted, **kwargs):
"""Add organization to service provider from a given list."""
if not create or not extracted:
return
self.organizations.set(extracted)
+39 -21
View File
@@ -1,6 +1,6 @@
# Generated by Django 5.0.1 on 2024-02-06 15:08
# Generated by Django 5.0.3 on 2024-06-08 09:25
import core.models
import django.contrib.auth.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('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=100)),
('slug', models.SlugField(max_length=100, unique=True)),
('slug', models.SlugField(editable=False, max_length=100, unique=True)),
],
options={
'verbose_name': 'Team',
@@ -45,8 +45,10 @@ class Migration(migrations.Migration):
('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')),
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
('language', models.CharField(choices=settings.LANGUAGES, default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
@@ -60,7 +62,7 @@ class Migration(migrations.Migration):
'db_table': 'people_user',
},
managers=[
('objects', core.models.UserManager()),
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
@@ -88,21 +90,20 @@ class Migration(migrations.Migration):
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='core.contact'),
),
migrations.CreateModel(
name='Identity',
name='Invitation',
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')),
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.team')),
],
options={
'verbose_name': 'identity',
'verbose_name_plural': 'identities',
'db_table': 'people_identity',
'ordering': ('-is_main', 'email'),
'verbose_name': 'Team invitation',
'verbose_name_plural': 'Team invitations',
'db_table': 'people_invitation',
},
),
migrations.CreateModel(
@@ -126,21 +127,38 @@ class Migration(migrations.Migration):
name='users',
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
migrations.CreateModel(
name='TeamWebhook',
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')),
('url', models.URLField(verbose_name='url')),
('secret', models.CharField(blank=True, max_length=255, null=True, verbose_name='secret')),
('status', models.CharField(choices=[('failure', 'Failure'), ('pending', 'Pending'), ('success', 'Success')], default='pending', max_length=10)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhooks', to='core.team')),
],
options={
'verbose_name': 'Team webhook',
'verbose_name_plural': 'Team webhooks',
'db_table': 'people_team_webhook',
},
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
constraint=models.CheckConstraint(condition=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(condition=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
),
migrations.AlterUniqueTogether(
name='contact',
unique_together={('owner', 'base')},
),
migrations.AddConstraint(
model_name='identity',
constraint=models.UniqueConstraint(fields=('user', 'email'), name='unique_user_email', violation_error_message='This email address is already declared for this user.'),
model_name='invitation',
constraint=models.UniqueConstraint(fields=('email', 'team'), name='email_and_team_unique_together'),
),
migrations.AddConstraint(
model_name='teamaccess',
@@ -0,0 +1,62 @@
# Generated by Django 5.1.1 on 2024-10-22 10:07
import core.models
import django.contrib.postgres.fields
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Organization',
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=100, verbose_name='name')),
('registration_id_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=128), blank=True, default=list, size=None, verbose_name='registration ID list')),
('domain_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=256), blank=True, default=list, size=None, verbose_name='domain list')),
],
options={
'verbose_name': 'organization',
'verbose_name_plural': 'organizations',
'db_table': 'people_organization',
'constraints': [models.CheckConstraint(condition=models.Q(('registration_id_list__len__gt', 0), ('domain_list__len__gt', 0), _connector='OR'), name='registration_id_or_domain', violation_error_message='An organization must have at least a registration ID or a domain.')],
},
),
migrations.AddField(
model_name='team',
name='organization',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='teams', to='core.organization'),
),
migrations.AddField(
model_name='user',
name='organization',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='core.organization'),
),
migrations.CreateModel(
name='OrganizationAccess',
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')),
('role', models.CharField(choices=[('administrator', 'Administrator')], default='administrator', max_length=20)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_accesses', to='core.organization')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_accesses', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Organization/user relation',
'verbose_name_plural': 'Organization/user relations',
'db_table': 'people_organization_access',
'constraints': [models.UniqueConstraint(fields=('user', 'organization'), name='unique_organization_user', violation_error_message='This user is already in this organization.')],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-11-04 14:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_add_organization_and_more'),
]
operations = [
migrations.AlterField(
model_name='team',
name='slug',
field=models.SlugField(max_length=100, null=True),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.1.3 on 2024-11-20 10:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_team_slug_nullable'),
]
operations = [
migrations.RemoveField(
model_name='team',
name='slug',
),
]
@@ -0,0 +1,39 @@
# Generated by Django 5.1.2 on 2024-11-07 16:24
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_remove_team_slug'),
]
operations = [
migrations.CreateModel(
name='ServiceProvider',
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=256, unique=True, verbose_name='name')),
('audience_id', models.CharField(db_index=True, max_length=256, unique=True, verbose_name='audience id')),
],
options={
'verbose_name': 'service provider',
'verbose_name_plural': 'service providers',
'db_table': 'people_service_provider',
},
),
migrations.AddField(
model_name='organization',
name='service_providers',
field=models.ManyToManyField(blank=True, related_name='organizations', to='core.serviceprovider'),
),
migrations.AddField(
model_name='team',
name='service_providers',
field=models.ManyToManyField(blank=True, related_name='teams', to='core.serviceprovider'),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.1.3 on 2024-11-28 09:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_add_serviceprovider'),
]
operations = [
migrations.AddField(
model_name='contact',
name='notes',
field=models.TextField(blank=True, default='', verbose_name='notes'),
),
migrations.AlterField(
model_name='contact',
name='full_name',
field=models.CharField(default='-', max_length=150, verbose_name='full name'),
preserve_default=False,
),
]
@@ -0,0 +1,47 @@
# Generated by Django 5.1.3 on 2024-12-02 10:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_contact_notes_alter_contact_full_name'),
]
operations = [
migrations.RemoveConstraint(
model_name='contact',
name='base_owner_constraint',
),
migrations.RemoveConstraint(
model_name='contact',
name='base_not_self',
),
migrations.AlterUniqueTogether(
name='contact',
unique_together=set(),
),
migrations.AddField(
model_name='contact',
name='override',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='overridden_by', to='core.contact'),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(condition=models.Q(('override__isnull', False), ('owner__isnull', True), _negated=True), name='override_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(condition=models.Q(('override', models.F('id')), _negated=True), name='override_not_self', violation_error_message='A contact cannot override itself.'),
),
migrations.RemoveField(
model_name='contact',
name='base',
),
migrations.AlterUniqueTogether(
name='contact',
unique_together={('owner', 'override')},
),
]
@@ -0,0 +1,47 @@
# Generated by Django 5.1.3 on 2024-12-02 10:58
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def init_profile_contact(apps, schema_editor):
"""Create a Contact for each User to be used as their profile."""
User = apps.get_model("core", "User")
Contact = apps.get_model("core", "Contact")
for user in User.objects.all(): # This is a small table for now
Contact.objects.create(
owner=user,
user=user,
full_name=user.name or user.email,
data={
"emails": [
{"type": "Work", "value": user.email},
],
},
)
class Migration(migrations.Migration):
dependencies = [
('core', '0007_rename_contact_base_to_override'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='profile_contact',
),
migrations.AddField(
model_name='contact',
name='user',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='profile_contact', to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(condition=models.Q(('user__isnull', True), models.Q(('owner', models.F('user')), ('owner__isnull', False)), _connector='OR'), name='profile_contact_owner_constraint', violation_error_message='Users can only declare as profile a contact they own.'),
),
migrations.RunPython(init_profile_contact, reverse_code=migrations.RunPython.noop),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2024-12-05 13:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_change_user_profile_to_contact'),
]
operations = [
# From 100ms to 78ms for 5 000 contacts on my computer
# This is not a big improvement and may need further investigation
migrations.RunSQL(
"CREATE INDEX json_field_index_emails_value ON people_contact USING GIN ((data->'emails'->'value') jsonb_path_ops);"
)
]
@@ -0,0 +1,58 @@
# Generated by Django 5.1.3 on 2024-11-26 13:55
from django.db import migrations, models
from treebeard.numconv import NumConv
def update_team_paths(apps, schema_editor):
Team = apps.get_model("core", "Team")
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
steplen = 5
# Initialize NumConv with the specified custom alphabet
converter = NumConv(len(alphabet), alphabet)
nodes = Team.objects.all().order_by("created_at")
for i, node in enumerate(nodes, 1):
node.depth = 1 # root nodes are at depth 1
# Use NumConv to encode the index `i` to a base representation and
# pad it to the specified step length using the custom alphabet
node.path = converter.int2str(i).rjust(steplen, alphabet[0])
if nodes:
Team.objects.bulk_update(nodes, ["depth", "path"])
class Migration(migrations.Migration):
dependencies = [
('core', '0009_contacts_add_index_on_data_emails'),
]
operations = [
migrations.AddField(
model_name='team',
name='depth',
field=models.PositiveIntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name='team',
name='numchild',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='team',
name='path',
field=models.CharField(default='', max_length=400, db_collation="C"),
preserve_default=False,
),
migrations.RunPython(update_team_paths, migrations.RunPython.noop),
migrations.AlterField(
model_name="team",
name="path",
field=models.CharField(unique=True, max_length=400, db_collation="C"),
),
]
+669 -152
View File
@@ -1,23 +1,42 @@
"""
Declare and configure the models for the People core application
"""
import json
import os
import smtplib
import uuid
from contextlib import suppress
from datetime import timedelta
from logging import getLogger
from typing import Tuple
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.db import models
from django.utils.functional import lazy
from django.utils.text import slugify
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext, override
from django.utils.translation import gettext_lazy as _
import jsonschema
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.settings import api_settings
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.validators import get_field_validators_from_setting
logger = getLogger(__name__)
current_dir = os.path.dirname(os.path.abspath(__file__))
contact_schema_path = os.path.join(current_dir, "jsonschema", "contact_data.json")
@@ -25,7 +44,7 @@ with open(contact_schema_path, "r", encoding="utf-8") as contact_schema_file:
contact_schema = json.load(contact_schema_file)
class RoleChoices(models.TextChoices):
class RoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
"""Defines the possible roles a user can have in a team."""
MEMBER = "member", _("Member")
@@ -33,6 +52,17 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
class OrganizationRoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
"""
Defines the possible roles a user can have in an organization.
For now, we only have one role, but we might add more in the future.
administrator: The user can manage the organization: change name, add/remove users.
"""
ADMIN = "administrator", _("Administrator")
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -67,16 +97,16 @@ class BaseModel(models.Model):
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
super().save(*args, **kwargs)
return super().save(*args, **kwargs)
class Contact(BaseModel):
"""User contacts"""
base = models.ForeignKey(
override = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
related_name="overriding_contacts",
related_name="overridden_by",
null=True,
blank=True,
)
@@ -87,11 +117,19 @@ class Contact(BaseModel):
null=True,
blank=True,
)
full_name = models.CharField(_("full name"), max_length=150, null=True, blank=True)
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="profile_contact",
blank=True,
null=True,
)
full_name = models.CharField(_("full name"), max_length=150)
short_name = models.CharField(_("short name"), max_length=30, null=True, blank=True)
# avatar =
# notes =
notes = models.TextField(_("notes"), blank=True, default="")
data = models.JSONField(
_("contact information"),
help_text=_("A JSON object containing the contact information"),
@@ -110,31 +148,38 @@ class Contact(BaseModel):
ordering = ("full_name", "short_name")
verbose_name = _("contact")
verbose_name_plural = _("contacts")
unique_together = ("owner", "base")
unique_together = ("owner", "override")
constraints = [
models.CheckConstraint(
check=~models.Q(base__isnull=False, owner__isnull=True),
name="base_owner_constraint",
condition=~models.Q(override__isnull=False, owner__isnull=True),
name="override_owner_constraint",
violation_error_message="A contact overriding a base contact must be owned.",
),
models.CheckConstraint(
check=~models.Q(base=models.F("id")),
name="base_not_self",
violation_error_message="A contact cannot be based on itself.",
condition=~models.Q(override=models.F("id")),
name="override_not_self",
violation_error_message="A contact cannot override itself.",
),
# When a user is set, the owner must be the user
models.CheckConstraint(
condition=models.Q(user__isnull=True)
| models.Q(owner=models.F("user"), owner__isnull=False),
name="profile_contact_owner_constraint",
violation_error_message="Users can only declare as profile a contact they own.",
),
]
def __str__(self):
return self.full_name or self.short_name
return self.full_name
def clean(self):
"""Validate fields."""
super().clean()
# Check if the contact points to a base contact that itself points to another base contact
if self.base_id and self.base.base_id:
# Check if the contact overrides a contact that itself overrides another base contact
if self.override_id and self.override.override_id:
raise exceptions.ValidationError(
"A contact cannot point to a base contact that itself points to a base contact."
"A contact cannot override a contact that itself overrides a contact."
)
# Validate the content of the "data" field against our jsonschema definition
@@ -146,31 +191,276 @@ class Contact(BaseModel):
error_message = f"Validation error in '{field_path:s}': {e.message}"
raise exceptions.ValidationError({"data": [error_message]}) from e
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the contact.
class UserManager(auth_models.UserManager):
Beware that the model allows owner to be None, we are still not
sure about the use case for this and the API does not allow this.
For now, we still consider here, a contact without owner is "public"
so we allow access to it.
"""
is_owner = user.pk == self.owner_id
is_profile_member_or_same_organization = bool(self.user_id) and (
self.user.organization_id == user.organization_id
)
return {
"get": is_owner or is_profile_member_or_same_organization or not self.owner,
"patch": is_owner,
"put": is_owner,
"delete": is_owner and not self.user, # Can't delete a profile contact
}
class ServiceProvider(BaseModel):
"""
Override user manager to get the related contact in the same query by default (Contact model)
Represents a service provider that will consume our information.
Organization uses this model to define the list of SP available to their users.
Team uses this model to define their visibility to the various SP.
"""
def get_queryset(self):
"""Always select the related contact when doing a query on users."""
return super().get_queryset().select_related("profile_contact")
name = models.CharField(_("name"), max_length=256, unique=True)
audience_id = models.CharField(
_("audience id"), max_length=256, unique=True, db_index=True
)
class Meta:
db_table = "people_service_provider"
verbose_name = _("service provider")
verbose_name_plural = _("service providers")
def __str__(self):
return self.name
def save(self, *args, **kwargs):
"""Enforce name (even if ugly) from the `audience_id` field."""
if not self.name:
self.name = self.audience_id # ok, same length
return super().save(*args, **kwargs)
class OrganizationManager(models.Manager):
"""
Custom manager for the Organization model, to manage complexity/automation.
"""
def get_or_create_from_user_claims(
self, registration_id: str = None, domain: str = None, **kwargs
) -> Tuple["Organization", bool]:
"""
Get or create an organization using the most fitting information from the user's claims.
We expect to have only one organization per registration_id, but
the registration_id might not be provided.
When the registration_id is not provided, we use the domain to identify the organization.
If both are provided, we use the registration_id first to create missing organization.
Dev note: When a registration_id is provided by the Identity Provider, we don't want
to use the domain to create the organization, because it is less reliable: for example,
a professional user, may have a personal email address, and the domain would be gmail.com
which is not a good identifier for an organization. The domain email is just a fallback
when the registration_id is not provided by the Identity Provider. We can use the domain
to create the organization manually when we are sure about the "safety" of it.
"""
if not any([registration_id, domain]):
raise ValueError("You must provide either a registration_id or a domain.")
filters = models.Q()
if registration_id:
filters |= models.Q(registration_id_list__icontains=registration_id)
if domain:
filters |= models.Q(domain_list__icontains=domain)
with suppress(self.model.DoesNotExist):
# If there are several organizations, we must raise an error and fix the data
# If there is an organization, we return it
return self.get(filters, **kwargs), False
# Manage the case where the organization does not exist: we create one
if registration_id:
return self.create(
name=registration_id, registration_id_list=[registration_id], **kwargs
), True
if domain:
return self.create(name=domain, domain_list=[domain], **kwargs), True
raise ValueError("Should never reach this point.")
def create(self, **kwargs):
"""
Create an organization with the given kwargs.
This method is overridden to call the Organization plugins.
"""
instance = super().create(**kwargs)
organization_plugins_run_after_create(instance)
return instance
class OrganizationAccessManager(models.Manager):
"""
Custom manager for the OrganizationAccess model, to manage complexity/automation.
"""
def create(self, **kwargs):
"""
Create an organization access with the given kwargs.
This method is overridden to call the Organization plugins.
"""
instance = super().create(**kwargs)
organization_plugins_run_after_grant_access(instance)
return instance
class Organization(BaseModel):
"""
Organization model used to regroup Teams.
Each User have an Organization, which corresponds actually to a default organization
because a user can belong to a Team from another organization.
Each Team have an Organization, which is the Organization from the User who created
the Team.
Organization is managed automatically, the User should never choose their Organization.
When creating a User, you must use the `get_or_create` method from the
OrganizationManager to find the proper Organization.
An Organization can have several registration IDs and domains but during automatic
creation process, only one will be used. We may want to allow (manual) organization merge
later, to regroup several registration IDs or domain in the same Organization.
"""
name = models.CharField(_("name"), max_length=100)
registration_id_list = ArrayField(
models.CharField(
max_length=128,
validators=get_field_validators_from_setting(
"ORGANIZATION_REGISTRATION_ID_VALIDATORS"
),
),
verbose_name=_("registration ID list"),
default=list,
blank=True,
# list overlap validation is done in the validate_unique method
)
domain_list = ArrayField(
models.CharField(max_length=256),
verbose_name=_("domain list"),
default=list,
blank=True,
# list overlap validation is done in the validate_unique method
)
service_providers = models.ManyToManyField(
ServiceProvider,
related_name="organizations",
blank=True,
)
objects = OrganizationManager()
class Meta:
db_table = "people_organization"
verbose_name = _("organization")
verbose_name_plural = _("organizations")
constraints = [
models.CheckConstraint(
name="registration_id_or_domain",
condition=models.Q(registration_id_list__len__gt=0)
| models.Q(domain_list__len__gt=0),
violation_error_message=_(
"An organization must have at least a registration ID or a domain."
),
),
# Check a registration ID str can only be present in one
# organization registration ID list
# Check a domain str can only be present in one organization domain list
# Those checks cannot be done with Django constraints
]
def __str__(self):
return f"{self.name} (# {self.pk})"
def validate_unique(self, exclude=None):
"""
Validate Registration/Domain values in an array field are unique
across all instances.
This can't be done in the field validator because we need to
exclude the current object if already in database.
"""
super().validate_unique(exclude=exclude)
if self.pk:
organization_qs = Organization.objects.exclude(pk=self.pk)
else:
organization_qs = Organization.objects.all()
# Check a registration ID can only be present in one organization
if (
self.registration_id_list
and organization_qs.filter(
registration_id_list__overlap=self.registration_id_list
).exists()
):
raise ValidationError(
"registration_id_list value must be unique across all instances."
)
# Check a domain can only be present in one organization
if (
self.domain_list
and organization_qs.filter(domain_list__overlap=self.domain_list).exists()
):
raise ValidationError(
"domain_list value must be unique across all instances."
)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the organization.
"""
# Use the role from queryset annotation will raise on purpose if not used properly
is_admin = self.user_role == OrganizationRoleChoices.ADMIN # pylint: disable=no-member
return {
"get": user.organization_id == self.pk,
"patch": is_admin,
"put": is_admin,
"delete": False,
}
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
email = models.EmailField(_("email address"), unique=True, null=True, blank=True)
profile_contact = models.OneToOneField(
Contact,
on_delete=models.SET_NULL,
related_name="user",
blank=True,
null=True,
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
validators=[sub_validator],
)
email = models.EmailField(_("email address"), null=True, blank=True)
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
choices=settings.LANGUAGES,
default=settings.LANGUAGE_CODE,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
@@ -199,10 +489,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts."
),
)
organization = models.ForeignKey(
Organization,
on_delete=models.PROTECT,
related_name="users",
null=True, # Need to be set to False when everything is migrated
blank=True, # Need to be set to False when everything is migrated
)
objects = UserManager()
objects = auth_models.UserManager()
USERNAME_FIELD = "email"
USERNAME_FIELD = "sub"
REQUIRED_FIELDS = []
class Meta:
@@ -211,112 +508,197 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
verbose_name_plural = _("users")
def __str__(self):
return (
str(self.profile_contact)
if self.profile_contact
else self.email or str(self.id)
)
return self.name if self.name else self.email or f"User {self.sub}"
def save(self, *args, **kwargs):
"""
If it's a new user, give her access to the relevant teams.
"""
if self._state.adding:
self._convert_valid_invitations()
super().save(*args, **kwargs)
def clean(self):
"""Validate fields."""
super().clean()
if self.email:
self.email = User.objects.normalize_email(self.email)
if self.profile_contact_id and not self.profile_contact.owner == self:
raise exceptions.ValidationError(
"Users can only declare as profile a contact they own."
)
def _convert_valid_invitations(self):
"""
Convert valid invitations to team accesses.
Expired invitations are ignored.
"""
valid_invitations = Invitation.objects.filter(
email=self.email,
created_at__gte=(
timezone.now()
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
),
).select_related("team")
if not valid_invitations.exists():
return
TeamAccess.objects.bulk_create(
[
TeamAccess(user=self, team=invitation.team, role=invitation.role)
for invitation in valid_invitations
]
)
valid_invitations.delete()
def email_user(self, subject, message, from_email=None, **kwargs):
"""Send an email to this user."""
main_identity = self.identities.get(is_main=True)
mail.send_mail(subject, message, from_email, [main_identity.email], **kwargs)
"""Email this user."""
if not self.email:
raise ValueError("You must first set an email for the user.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
@classmethod
def get_email_field_name(cls):
def get_abilities(self):
"""
Raise error when trying to get email field name from the user as we are using
a separate Email model to allow several emails per user.
Return the user permissions at the application level (ie feature availability).
Note: this is for display purposes only for now.
- Contacts view and creation is globally available (or not) for now.
- Teams view is available if the user is owner or admin of at least
one Team for now. It allows to restrict users knowing the existence of
this feature.
- Teams creation is globally available (or not) for now.
- Mailboxes view is available if the user has access to at least one
MailDomain for now. It allows to restrict users knowing the existence of
this feature.
- Mailboxes creation is globally available (or not) for now.
"""
raise NotImplementedError(
"This feature is deactivated to allow several emails per user."
user_info = (
self._meta.model.objects.filter(pk=self.pk)
.annotate(
teams_can_view=models.Exists(
self.accesses.model.objects.filter( # pylint: disable=no-member
user=models.OuterRef("pk"),
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN],
)
),
mailboxes_can_view=models.Exists(
self.mail_domain_accesses.model.objects.filter( # pylint: disable=no-member
user=models.OuterRef("pk")
)
),
)
.only("id")
.get()
)
teams_can_view = user_info.teams_can_view or settings.FEATURES["TEAMS_DISPLAY"]
mailboxes_can_view = user_info.mailboxes_can_view
class Identity(BaseModel):
"""User identity"""
return {
"contacts": {
"can_view": settings.FEATURES["CONTACTS_DISPLAY"],
"can_create": (
settings.FEATURES["CONTACTS_DISPLAY"]
and settings.FEATURES["CONTACTS_CREATE"]
),
},
"teams": {
"can_view": teams_can_view,
"can_create": teams_can_view and settings.FEATURES["TEAMS_CREATE"],
},
"mailboxes": {
"can_view": mailboxes_can_view,
"can_create": mailboxes_can_view
and settings.FEATURES["MAILBOXES_CREATE"],
},
}
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
class OrganizationAccess(BaseModel):
"""
Link table between organization and users,
only for user with specific rights on Organization.
"""
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="organization_accesses",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="organization_accesses",
)
role = models.CharField(
max_length=20,
choices=OrganizationRoleChoices.choices,
default=OrganizationRoleChoices.ADMIN,
)
user = models.ForeignKey(User, related_name="identities", on_delete=models.CASCADE)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
validators=[sub_validator],
)
email = models.EmailField(_("email address"), null=True, blank=True)
is_main = models.BooleanField(
_("main"),
default=False,
help_text=_("Designates whether the email is the main one."),
)
objects = OrganizationAccessManager()
class Meta:
db_table = "people_identity"
ordering = ("-is_main", "email")
verbose_name = _("identity")
verbose_name_plural = _("identities")
db_table = "people_organization_access"
verbose_name = _("Organization/user relation")
verbose_name_plural = _("Organization/user relations")
constraints = [
# Uniqueness
models.UniqueConstraint(
fields=["user", "email"],
name="unique_user_email",
violation_error_message=_(
"This email address is already declared for this user."
),
fields=["user", "organization"],
name="unique_organization_user",
violation_error_message=_("This user is already in this organization."),
),
]
def __str__(self):
main_str = "[main]" if self.is_main else ""
return f"{self.email:s}{main_str:s}"
def save(self, *args, **kwargs):
"""Ensure users always have one and only one main identity."""
super().save(*args, **kwargs)
if self.is_main is True:
self.user.identities.exclude(id=self.id).update(is_main=False)
def clean(self):
"""Normalize the email field and clean the 'is_main' field."""
if self.email:
self.email = User.objects.normalize_email(self.email)
if not self.user.identities.exclude(pk=self.pk).filter(is_main=True).exists():
if not self.created_at:
self.is_main = True
elif not self.is_main:
raise exceptions.ValidationError(
{"is_main": "A user should have one and only one main identity."}
)
super().clean()
return f"{self.user!s} is {self.role:s} in organization {self.organization!s}"
class Team(BaseModel):
class TeamManager(MP_NodeManager):
"""
Custom manager for the Team model, to manage complexity/automation.
"""
def create(self, parent_id=None, **kwargs):
"""
Replace the default create method to ease the Team creation process.
Notes:
- the `add_*` methods from django-treebeard does not support the "using db".
Which means it will always use the default db.
- the `add_*` methods from django-treebeard does not support the "force_insert".
"""
if parent_id is None:
return self.model.add_root(**kwargs)
# Retrieve parent object, because django-treebeard uses raw queries for most
# write operations, and raw queries dont update the django objects of the db
# entries they modify. See caveats in the django-treebeard documentation.
# This might be changed later if we never do any operation on the parent object
# before creating the child.
# Beware the N+1 here.
return self.get(pk=parent_id).add_child(**kwargs)
class Team(MP_Node, BaseModel):
"""
Represents the link between teams and users, specifying the role a user has in a team.
When a team is created from here, the user have to choose which Service Providers
can see it.
When a team is created from a Service Provider this one is automatically set in the
Team `service_providers`.
"""
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
# Django treebeard does not allow max_length = None...
steplen = 5
path = models.CharField(max_length=5 * 80, unique=True, db_collation="C")
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100, unique=True, null=False, editable=False)
users = models.ManyToManyField(
User,
@@ -324,6 +706,20 @@ class Team(BaseModel):
through_fields=("team", "user"),
related_name="teams",
)
organization = models.ForeignKey(
Organization,
on_delete=models.PROTECT,
related_name="teams",
null=True, # Need to be set to False when everything is migrated
blank=True, # Need to be set to False when everything is migrated
)
service_providers = models.ManyToManyField(
ServiceProvider,
related_name="teams",
blank=True,
)
objects = TeamManager()
class Meta:
db_table = "people_team"
@@ -334,15 +730,6 @@ class Team(BaseModel):
def __str__(self):
return self.name
def save(self, *args, **kwargs):
"""Overriding save function to compute the slug."""
self.slug = self.get_slug()
return super().save(*args, **kwargs)
def get_slug(self):
"""Compute slug value from name."""
return slugify(self.name)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the team.
@@ -362,7 +749,7 @@ class Team(BaseModel):
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
return {
"get": True,
"get": bool(role),
"patch": is_owner_or_admin,
"put": is_owner_or_admin,
"delete": role == RoleChoices.OWNER,
@@ -402,6 +789,34 @@ class TeamAccess(BaseModel):
def __str__(self):
return f"{self.user!s} is {self.role:s} in team {self.team!s}"
def save(self, *args, **kwargs):
"""
Override save function to fire webhooks on any addition or update
to a team access.
"""
if self._state.adding:
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)
return instance
def delete(self, *args, **kwargs):
"""
Override delete method to fire webhooks on to team accesses.
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)
def get_abilities(self, user):
"""
Compute and return abilities for a given user taking into account
@@ -452,38 +867,140 @@ class TeamAccess(BaseModel):
}
def oidc_user_getter(validated_token):
"""
Given a valid OIDC token , retrieve, create or update corresponding user/contact/email from db.
class TeamWebhook(BaseModel):
"""Webhooks fired on changes in teams."""
The token is expected to have the following fields in payload:
- sub
- email
- ...
"""
try:
user_id = validated_token[api_settings.USER_ID_CLAIM]
except KeyError as exc:
raise InvalidToken(
_("Token contained no recognizable user identification")
) from exc
try:
email_param = {"email": validated_token["email"]}
except KeyError:
email_param = {}
user = (
User.objects.filter(identities__sub=user_id)
.annotate(identity_email=models.F("identities__email"))
.distinct()
.first()
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)
status = models.CharField(
max_length=10,
default=WebhookStatusChoices.PENDING,
choices=WebhookStatusChoices.choices,
)
if user is None:
user = User.objects.create(password="!", **email_param) # noqa: S106
Identity.objects.create(user=user, sub=user_id, **email_param)
elif email_param and validated_token["email"] != user.identity_email:
Identity.objects.filter(sub=user_id).update(email=validated_token["email"])
class Meta:
db_table = "people_team_webhook"
verbose_name = _("Team webhook")
verbose_name_plural = _("Team webhooks")
return user
def __str__(self):
return f"Webhook to {self.url} for {self.team}"
def get_headers(self):
"""Build header dict from webhook object."""
headers = {"Content-Type": "application/json"}
if self.secret:
headers["Authorization"] = f"Bearer {self.secret:s}"
return headers
class Invitation(BaseModel):
"""User invitation to teams."""
email = models.EmailField(_("email address"), null=False, blank=False)
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
related_name="invitations",
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
issuer = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="invitations",
)
class Meta:
db_table = "people_invitation"
verbose_name = _("Team invitation")
verbose_name_plural = _("Team invitations")
constraints = [
models.UniqueConstraint(
fields=["email", "team"], name="email_and_team_unique_together"
)
]
def __str__(self):
return f"{self.email} invited to {self.team}"
def save(self, *args, **kwargs):
"""Make invitations read-only."""
if self.created_at:
raise exceptions.PermissionDenied()
super().save(*args, **kwargs)
self.email_invitation()
def clean(self):
"""Validate fields."""
super().clean()
# Check if a user already exists for the provided email
if User.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
if not self.created_at:
return None
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
role = None
if user.is_authenticated:
try:
role = self.user_role
except AttributeError:
try:
role = self.team.accesses.filter(user=user).values("role")[0][
"role"
]
except (self._meta.model.DoesNotExist, IndexError):
role = None
can_delete = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
return {
"delete": can_delete,
"get": bool(role),
"patch": False,
"put": False,
}
def email_invitation(self):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
subject = gettext("Invitation to join La Régie!")
template_vars = {
"title": subject,
"site": Site.objects.get_current(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
mail.send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
[self.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)
# It's not clear yet how best to split this file.
# pylint: disable=C0302
+1
View File
@@ -0,0 +1 @@
"""Core plugins package."""
+19
View File
@@ -0,0 +1,19 @@
"""Base plugin class for organization plugins."""
class BaseOrganizationPlugin:
"""
Base class for organization plugins.
Plugins must implement all methods of this class even if it is only to "pass".
"""
def run_after_create(self, organization) -> None:
"""Method called after creating an organization."""
raise NotImplementedError("Plugins must implement the run_after_create method")
def run_after_grant_access(self, organization_access) -> None:
"""Method called after creating an organization."""
raise NotImplementedError(
"Plugins must implement the run_after_grant_access method"
)
+44
View File
@@ -0,0 +1,44 @@
"""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)
@@ -0,0 +1 @@
"""Backend resource server module."""
@@ -0,0 +1,72 @@
"""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
@@ -0,0 +1,240 @@
"""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")
@@ -0,0 +1,97 @@
"""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
@@ -0,0 +1,53 @@
"""
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
@@ -0,0 +1,9 @@
"""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
@@ -0,0 +1,48 @@
"""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
@@ -0,0 +1,40 @@
"""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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1 @@
"""Core template tags module."""
@@ -0,0 +1,58 @@
"""Custom template tags for the core application of People."""
import base64
from django import template
from django.contrib.staticfiles import finders
from PIL import ImageFile as PillowImageFile
register = template.Library()
def image_to_base64(file_or_path, close=False):
"""
Return the src string of the base64 encoding of an image represented by its path
or file opened or not.
Inspired by Django's "get_image_dimensions"
"""
pil_parser = PillowImageFile.Parser()
if hasattr(file_or_path, "read"):
file = file_or_path
if file.closed and hasattr(file, "open"):
file_or_path.open()
file_pos = file.tell()
file.seek(0)
else:
try:
# pylint: disable=consider-using-with
file = open(file_or_path, "rb")
except OSError:
return ""
close = True
try:
image_data = file.read()
if not image_data:
return ""
pil_parser.feed(image_data)
if pil_parser.image:
mime_type = pil_parser.image.get_format_mimetype()
encoded_string = base64.b64encode(image_data)
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
return ""
finally:
if close:
file.close()
else:
file.seek(file_pos)
@register.simple_tag
def base64_static(path):
"""Return a static file into a base64."""
full_path = finders.find(path)
if full_path:
return image_to_base64(full_path, True)
return ""
+1
View File
@@ -0,0 +1 @@
"""Core tests."""
@@ -0,0 +1 @@
"""Core authentication tests."""
@@ -0,0 +1,455 @@
"""Unit tests for the Authentication Backends."""
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import pytest
from core import factories, models
from core.authentication.backends import OIDCAuthenticationBackend
pytestmark = pytest.mark.django_db
User = get_user_model()
def test_authentication_getter_existing_user_no_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the user's info sub, the user should be returned.
"""
klass = OIDCAuthenticationBackend()
user = factories.UserFactory()
def get_userinfo_mocked(*args):
return {"sub": user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
"""
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = factories.UserFactory(name="John Doe", with_organization=True)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the user when they change.
"""
klass = OIDCAuthenticationBackend()
user = factories.UserFactory(
name="John Doe", email="john.doe@example.com", with_organization=True
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.name == f"{first_name:s} {last_name:s}"
def test_authentication_getter_existing_user_keep_fields(
django_assert_num_queries, monkeypatch
):
"""
Falsy values in claim should not update the user's fields.
"""
klass = OIDCAuthenticationBackend()
user = factories.UserFactory(
name="John Doe", email="john.doe@example.com", with_organization=True
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": None,
"first_name": "",
"last_name": "",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# No field changed no more query
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == "john.doe@example.com"
assert user.name == "John Doe"
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email,
the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(with_organization=True)
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub doesn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should not be created without email
nor organization registration ID.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "123"} # No email, no organization registration ID
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
pytest.raises(
SuspiciousOperation,
match="Claims contained no recognizable organization identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
def test_authentication_getter_new_user_with_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's email and name should be set on the user.
"""
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email == email
assert user.name == "John Doe"
assert user.password == "!"
assert models.User.objects.count() == 1
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
"""The user's info doesn't contain a sub."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"test": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the sub but is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(1),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not matches the sub but match the email and is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": "random",
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(2),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_with_email_new_organization(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
If the corresponding organization doesn't exist, it should be created.
"""
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.organization is not None
assert user.organization.domain_list == ["example.com"]
assert user.organization.registration_id_list == []
@pytest.mark.parametrize(
"registration_id_setting,expected_registration_id_list,expected_domain_list",
[
(None, [], ["example.com"]),
("missing-claim", [], ["example.com"]),
("registration_number", ["12345678901234"], []),
],
)
def test_authentication_getter_new_user_with_registration_id_new_organization(
monkeypatch,
settings,
registration_id_setting,
expected_registration_id_list,
expected_domain_list,
):
"""
If no user matches the user's info sub, a user should be created.
If the corresponding organization doesn't exist, it should be created.
"""
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = registration_id_setting
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": email,
"first_name": "John",
"last_name": "Doe",
"registration_number": "12345678901234",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.organization is not None
assert user.organization.domain_list == expected_domain_list
assert user.organization.registration_id_list == expected_registration_id_list
def test_authentication_getter_existing_user_via_email_update_organization(
django_assert_num_queries, monkeypatch
):
"""
If an existing user already exists without organization, the organization must be updated.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", email="toto@my-domain.com")
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(9):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
assert user.organization is not None
assert user.organization.domain_list == ["my-domain.com"]
def test_authentication_getter_existing_user_with_registration_id(
monkeypatch,
settings,
):
"""
Authenticate a user who already exists with an organization registration ID.
This asserts the "update_user_if_needed" does not fail when the claim
contains the organization registration ID (or any value not present in the
User model).
"""
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "registration_number"
klass = OIDCAuthenticationBackend()
_existing_user = factories.UserFactory(
sub="123",
name="John Doe",
email="people@example.com",
)
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": "people@example.com",
"first_name": "John",
"last_name": "Doe",
"registration_number": "12345678901234",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.organization is not None
assert user.organization.registration_id_list == ["12345678901234"]
@@ -0,0 +1,10 @@
"""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")
@@ -0,0 +1,231 @@
"""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"

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