Compare commits

...

250 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
298 changed files with 25816 additions and 13300 deletions
+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! 👌
+1 -18
View File
@@ -11,26 +11,9 @@ jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
+51 -60
View File
@@ -1,4 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -15,42 +16,59 @@ env:
DOCKER_USER: 1001:127
jobs:
trivy-scan:
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
lasuite/people-frontend
-
name: Run trivy scan (backend)
uses: numerique-gouv/action-trivy-cache@main
with:
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:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-backend
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
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
@@ -62,39 +80,29 @@ jobs:
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-frontend
-
name: Load sops secrets
uses: rouja/actions-sops@main
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
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 "$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: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
target: frontend-production
@@ -110,29 +118,12 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
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
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 }}
-22
View File
@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "people,secrets"
+35 -9
View File
@@ -16,7 +16,7 @@ jobs:
- name: Checkout repository
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,7 +39,7 @@ jobs:
- name: Checkout repository
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
@@ -139,7 +139,7 @@ jobs:
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Check linting
run: cd src/frontend/ && yarn lint
@@ -147,6 +147,11 @@ jobs:
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
@@ -192,19 +197,40 @@ jobs:
run: |
make demo FLUSH_ARGS='--no-input'
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install
- 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 }}
- 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:
@@ -226,8 +252,8 @@ jobs:
- name: Persist mails' templates
uses: actions/upload-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
name: mails-templates
path: src/backend/core/templates/mail
lint-back:
runs-on: ubuntu-latest
+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 }}
-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,11 +58,6 @@ src/frontend/tsclient
# Logs
*.log
# Terraform
.terraform
*.tfstate
*.tfstate.backup
# Test & lint
.coverage
coverage.json
+1
View File
@@ -8,3 +8,4 @@ creation_rules:
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
+191 -11
View File
@@ -8,6 +8,173 @@ and this project adheres to
## [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
@@ -35,6 +202,7 @@ and this project adheres to
- ✨(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
@@ -124,14 +292,26 @@ and this project adheres to
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.4.1...main
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
[1.3.0]: https://github.com/numerique-gouv/people/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/people/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/people/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/people/releases/v1.1.0
[1.0.2]: https://github.com/numerique-gouv/people/releases/v1.0.2
[1.0.1]: https://github.com/numerique-gouv/people/releases/v1.0.1
[1.0.0]: https://github.com/numerique-gouv/people/releases/v1.0.0
[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
+31 -33
View File
@@ -1,18 +1,17 @@
# 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
FROM node:20 AS frontend-deps
WORKDIR /deps
@@ -25,7 +24,7 @@ COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint
RUN yarn --frozen-lockfile
### ---- Front-end builder dev image ----
FROM node:20 as frontend-builder-dev
FROM node:20 AS frontend-builder-dev
WORKDIR /builder
@@ -35,12 +34,18 @@ COPY ./src/frontend .
WORKDIR ./apps/desk
### ---- Front-end builder image ----
FROM frontend-builder-dev as frontend-builder
FROM frontend-builder-dev AS frontend-builder
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
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
@@ -61,7 +66,7 @@ CMD ["nginx", "-g", "daemon off;"]
# ---- Back-end builder image ----
FROM base as back-builder
FROM base AS back-builder
WORKDIR /builder
@@ -73,7 +78,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 as mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
@@ -84,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
@@ -111,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
@@ -149,15 +149,13 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core as backend-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
@@ -177,7 +175,7 @@ ENV DB_HOST=postgresql \
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core as backend-production
FROM core AS backend-production
ARG PEOPLE_STATIC_ROOT=/data/static
+54 -5
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
@@ -131,11 +132,6 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
reset-dimail-container:
@$(COMPOSE) up --force-recreate -d dimail
@$(MAKE) setup-dimail-db
.PHONY: reset-dimail-container
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
@@ -233,6 +229,9 @@ resetdb: ## flush database and create a superuser "admin"
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
env.d/development/france:
cp -n env.d/development/france.dist env.d/development/france
env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql
@@ -283,6 +282,10 @@ 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
@@ -355,10 +358,56 @@ 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
+17 -1
View File
@@ -29,7 +29,9 @@ docker_build(
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n desk -e dev template .'))
# 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
@@ -56,3 +58,17 @@ cmd_button('Migrate db',
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 "$@"
Regular → Executable
+2 -101
View File
@@ -1,102 +1,3 @@
#!/bin/sh
set -o errexit
#!/usr/bin/env bash
CURRENT_DIR=$(pwd)
# 0. Create ca
echo "0. Create ca"
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
# 1. Create registry container unless it already exists
echo "1. Create registry container unless it already exists"
reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
# 2. Create kind cluster with containerd registry config dir enabled
echo "2. Create kind cluster with containerd registry config dir enabled"
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
# See:
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
cat <<EOF | kind create cluster --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: kindest/node:v1.27.3
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
image: kindest/node:v1.27.3
- role: worker
image: kindest/node:v1.27.3
EOF
# 3. Add the registry config to the nodes
echo "3. Add the registry config to the nodes"
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
# In other words: localhost in the container is not localhost on the host.
#
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
# 4. Connect the registry to the cluster network if not already connected
echo "4. Connect the registry to the cluster network if not already connected"
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
# 5. Document the local registry
echo "5. Document the local registry"
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${reg_port}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
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 "$@"
+8 -20
View File
@@ -9,8 +9,8 @@ services:
redis:
image: redis:5
mailcatcher:
image: sj26/mailcatcher:latest
maildev:
image: maildev/maildev:latest
ports:
- "1081:1080"
@@ -27,6 +27,7 @@ services:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/france
- env.d/development/postgresql
ports:
- "8071:8000"
@@ -35,8 +36,9 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- dimail
- postgresql
- mailcatcher
- maildev
- redis
celery-dev:
@@ -116,22 +118,6 @@ 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
ports:
@@ -168,7 +154,9 @@ services:
- kc_postgresql
dimail:
image: registry.mim-libre.fr/dimail/dimail-api:latest
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
+268 -2
View File
@@ -58,6 +58,23 @@
],
"realmRoles": ["user"]
},
{
"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",
@@ -99,6 +116,216 @@
}
],
"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"]
}
],
"roles": {
@@ -485,9 +712,17 @@
"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false,
"webAuthnPolicyPasswordlessAcceptableAaguids": [],
"scopeMappings": [
{
"clientScope": "siret",
"roles": [
"user"
]
},
{
"clientScope": "offline_access",
"roles": ["offline_access"]
"roles": [
"offline_access"
]
}
],
"clientScopeMappings": {
@@ -737,6 +972,7 @@
"acr",
"roles",
"profile",
"siret",
"email"
],
"optionalClientScopes": [
@@ -897,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",
@@ -1363,7 +1628,8 @@
"email",
"roles",
"web-origins",
"acr"
"acr",
"siret"
],
"defaultOptionalClientScopes": [
"offline_access",
+14 -4
View File
@@ -9,19 +9,29 @@ API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.
## 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 and accesses.
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 valid and mailboxes can be created on it.
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.
### Accesses
### Permissions
As for People, an access - a permissions (or "allows" in dimail) - grants an user permission to create objects on a domain.
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:
+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.
+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.
+1 -1
View File
@@ -10,7 +10,7 @@ PYTHONPATH=/app
# People settings
# Mail
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_HOST="maildev"
DJANGO_EMAIL_PORT=1025
# Backend url
+1
View File
@@ -0,0 +1 @@
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation
+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="."
+71 -25
View File
@@ -1,3 +1,5 @@
# pylint: disable=line-too-long
import datetime
import os
import re
@@ -22,18 +24,6 @@ def update_files(version):
with open(path, 'w+') as file:
file.writelines(lines)
# helm files
sys.stdout.write("Update helm files...\n")
for env in ["preprod", "production"]:
path = f"src/helm/env.d/{env}/values.desk.yaml.gotmpl"
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)
# frontend package.json
sys.stdout.write("Update package.json...\n")
files_to_modify = []
@@ -53,6 +43,17 @@ def update_files(version):
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
"""
@@ -75,7 +76,43 @@ def update_changelog(path, version):
file.writelines(lines)
def prepare_release(version, kind):
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)
@@ -88,22 +125,29 @@ def prepare_release(version, kind):
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."""
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
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"""
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR and merge code to main than tag version
>> git tag -a v{version}
\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 docker image: https://hub.docker.com/r/lasuite/people-backend/tags
>> git tag -d preprod
>> git tag preprod
>> git push -f origin preprod
--> Now please generate release on github interface for the current tag 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__":
@@ -112,4 +156,6 @@ if __name__ == "__main__":
version = input("Enter your release version:")
while kind not in RELEASE_KINDS:
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
prepare_release(version, kind)
if "--only-deployment-part" not in sys.argv:
project_part(version, kind)
deployment_part(version, kind)
+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."""
+117 -6
View File
@@ -1,12 +1,19 @@
"""Admin classes and registrations for People's core app."""
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
from .plugins.loader import (
get_organization_plugins,
organization_plugins_run_after_create,
)
class TeamAccessInline(admin.TabularInline):
@@ -18,6 +25,15 @@ 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."""
@@ -31,6 +47,7 @@ class TeamWebhookInline(admin.TabularInline):
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
autocomplete_fields = ["organization"]
fieldsets = (
(
None,
@@ -39,6 +56,7 @@ class UserAdmin(auth_admin.UserAdmin):
"id",
"sub",
"password",
"organization",
)
},
),
@@ -67,9 +85,10 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
)
inlines = (TeamAccessInline, MailDomainAccessInline)
inlines = (TeamAccessInline, MailDomainAccessInline, OrganizationAccessInline)
list_display = (
"get_user",
"organization",
"created_at",
"updated_at",
"is_active",
@@ -97,18 +116,28 @@ class UserAdmin(auth_admin.UserAdmin):
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, TeamWebhookInline)
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)
@@ -148,8 +177,8 @@ class InvitationAdmin(admin.ModelAdmin):
)
def get_readonly_fields(self, request, obj=None):
"""Mark all fields read only, i.e. disable update."""
if obj:
# all fields read only = disable update
return self.fields
return self.readonly_fields
@@ -162,5 +191,87 @@ class InvitationAdmin(admin.ModelAdmin):
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")
+1
View File
@@ -0,0 +1 @@
"""People core client API endpoints"""
@@ -4,28 +4,43 @@ 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",
"base",
"abilities",
"override",
"data",
"full_name",
"notes",
"owner",
"short_name",
]
read_only_fields = ["id", "owner"]
read_only_fields = ["id", "owner", "abilities"]
extra_kwargs = {
"override": {"required": False},
}
def update(self, instance, validated_data):
"""Make "base" field readonly but only for update/patch."""
validated_data.pop("base", None)
"""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):
"""
@@ -34,6 +49,8 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
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)
@@ -48,12 +65,40 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
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
@@ -62,6 +107,7 @@ class UserSerializer(DynamicFieldsModelSerializer):
"email",
"language",
"name",
"organization",
"timezone",
"is_device",
"is_staff",
@@ -69,6 +115,41 @@ class UserSerializer(DynamicFieldsModelSerializer):
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."""
@@ -170,28 +251,50 @@ class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
abilities = serializers.SerializerMethodField(read_only=True)
slug = serializers.SerializerMethodField()
service_providers = serializers.PrimaryKeyRelatedField(
queryset=ServiceProvider.objects.all(), many=True, required=False
)
class Meta:
model = models.Team
fields = [
"id",
"name",
"accesses",
"abilities",
"slug",
"accesses",
"created_at",
"depth",
"name",
"numchild",
"path",
"service_providers",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"slug",
"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")
@@ -199,10 +302,6 @@ class TeamSerializer(serializers.ModelSerializer):
return team.get_abilities(request.user)
return {}
def get_slug(self, instance):
"""Return slug from the team's name."""
return instance.get_slug()
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
@@ -237,3 +336,12 @@ class InvitationSerializer(serializers.ModelSerializer):
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"]
@@ -1,9 +1,15 @@
"""API endpoints"""
import datetime
import operator
from functools import reduce
from django.conf import settings
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
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,
@@ -19,10 +25,11 @@ from rest_framework import (
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 . import permissions, serializers
SIMILARITY_THRESHOLD = 0.04
from mailbox_manager import models as domains_models
class NestedGenericViewSet(viewsets.GenericViewSet):
@@ -85,16 +92,23 @@ class SerializerPerActionMixin:
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
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.
"""
return self.serializer_classes.get(self.action, self.default_serializer_class)
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
return serializer_class
return super().get_serializer_class()
class Pagination(pagination.PageNumberPagination):
@@ -130,39 +144,46 @@ class ContactViewSet(
):
"""Contact ViewSet"""
permission_classes = [permissions.IsOwnedOrPublic]
queryset = models.Contact.objects.all()
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())
# Exclude contacts that:
# List only 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,
# - 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 trigram similarity
# Search by case-insensitive and accent-insensitive on:
# - full name
# - short name
# - email (from data `emails` field)
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")
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)
@@ -175,21 +196,64 @@ class ContactViewSet(
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(
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
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 matches the query. Similarity is
calculated using trigram similarity, allowing for partial,
case-insensitive matches and accented queries.
Return a list of users whose email or name matches the query.
"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all().order_by("-created_at")
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
@@ -207,22 +271,11 @@ class UserViewSet(
if team_id := self.request.GET.get("team_id", ""):
queryset = queryset.exclude(teams__id=team_id)
# Search by case-insensitive and accent-insensitive trigram similarity
# Search by case-insensitive and accent-insensitive
if query := self.request.GET.get("q", ""):
similarity = Max(
TrigramSimilarity(
Coalesce(Func("email", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
+ TrigramSimilarity(
Coalesce(Func("name", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
)
queryset = (
queryset.annotate(similarity=similarity)
.filter(similarity__gte=SIMILARITY_THRESHOLD)
.order_by("-similarity")
queryset = queryset.filter(
Q(name__unaccent__icontains=query)
| Q(email__unaccent__icontains=query)
)
return queryset
@@ -238,9 +291,7 @@ class UserViewSet(
Return information on currently logged user
"""
user = request.user
return response.Response(
self.serializer_class(user, context={"request": request}).data
)
return response.Response(self.get_serializer(user).data)
class TeamViewSet(
@@ -253,20 +304,57 @@ class TeamViewSet(
):
"""Team ViewSet"""
permission_classes = [permissions.AccessPermission]
permission_classes = [permissions.TeamPermission, permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
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.filter(accesses__user=self.request.user).annotate(
user_role=Subquery(user_role_query)
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):
@@ -340,6 +428,7 @@ class TeamAccessViewSet(
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
@@ -351,20 +440,9 @@ class TeamAccessViewSet(
if self.action in {"list", "retrieve"}:
if query := self.request.GET.get("q", ""):
similarity = Max(
TrigramSimilarity(
Coalesce(Func("user__email", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
+ TrigramSimilarity(
Coalesce(Func("user__name", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
)
queryset = (
queryset.annotate(similarity=similarity)
.filter(similarity__gte=SIMILARITY_THRESHOLD)
.order_by("-similarity")
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
@@ -506,9 +584,79 @@ class ConfigView(views.APIView):
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = ["LANGUAGES", "FEATURES", "RELEASE"]
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
+17 -2
View File
@@ -12,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):
@@ -53,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,
)
@@ -0,0 +1 @@
"""Authentication module."""
+126 -18
View File
@@ -1,5 +1,9 @@
"""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
@@ -10,9 +14,26 @@ 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.
@@ -67,21 +88,25 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
user_info = self.get_userinfo(access_token, id_token, payload)
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
email = user_info.get("email")
claims = {
"email": email,
"name": full_name,
}
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)
@@ -90,7 +115,41 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
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
@@ -101,13 +160,60 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
)
email = claims.get("email")
name = claims.get("name")
return self.UserModel.objects.create(
# 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=claims.get("email"),
name=claims.get("name"),
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."""
@@ -131,9 +237,11 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
value and value != getattr(user, key) for key, value in claims.items()
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
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)
+95 -4
View File
@@ -115,8 +115,62 @@ 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):
@@ -126,6 +180,13 @@ class UserFactory(factory.django.DjangoModelFactory):
model = models.User
django_get_or_create = ("sub",)
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")
@@ -138,7 +199,6 @@ 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}")
@@ -154,6 +214,13 @@ class TeamFactory(factory.django.DjangoModelFactory):
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):
"""Create fake team user accesses for testing."""
@@ -173,7 +240,7 @@ class TeamWebhookFactory(factory.django.DjangoModelFactory):
model = models.TeamWebhook
team = factory.SubFactory(TeamFactory)
url = factory.Sequence(lambda n: f"https://example.com/Groups/{n!s}")
url = factory.Sequence(lambda n: f"https://nosuchdomain.xyz/Groups/{n!s}")
class InvitationFactory(factory.django.DjangoModelFactory):
@@ -186,3 +253,27 @@ class InvitationFactory(factory.django.DjangoModelFactory):
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)
+1 -1
View File
@@ -48,7 +48,7 @@ class Migration(migrations.Migration):
('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="(('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')),
('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')),
@@ -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"),
),
]
+461 -44
View File
@@ -6,27 +6,35 @@ 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.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.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext, override
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import jsonschema
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager
from core.enums import WebhookStatusChoices
from core.plugins.loader import (
organization_plugins_run_after_create,
organization_plugins_run_after_grant_access,
)
from core.utils.webhooks import scim_synchronizer
from core.validators import get_field_validators_from_setting
logger = getLogger(__name__)
@@ -36,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")
@@ -44,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
@@ -84,10 +103,10 @@ class BaseModel(models.Model):
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,
)
@@ -98,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"),
@@ -121,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(
condition=~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(
condition=~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
@@ -157,6 +191,250 @@ 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.
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):
"""
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.
"""
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."""
@@ -180,16 +458,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
email = models.EmailField(_("email address"), null=True, blank=True)
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
profile_contact = models.OneToOneField(
Contact,
on_delete=models.SET_NULL,
related_name="user",
blank=True,
null=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."),
@@ -218,6 +489,13 @@ 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 = auth_models.UserManager()
@@ -248,11 +526,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
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.
@@ -284,14 +557,148 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
raise ValueError("You must first set an email for the user.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
def get_abilities(self):
"""
Return the user permissions at the application level (ie feature availability).
class Team(BaseModel):
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.
"""
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
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"],
},
}
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,
)
objects = OrganizationAccessManager()
class Meta:
db_table = "people_organization_access"
verbose_name = _("Organization/user relation")
verbose_name_plural = _("Organization/user relations")
constraints = [
models.UniqueConstraint(
fields=["user", "organization"],
name="unique_organization_user",
violation_error_message=_("This user is already in this organization."),
),
]
def __str__(self):
return f"{self.user!s} is {self.role:s} in organization {self.organization!s}"
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,
@@ -299,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"
@@ -309,15 +730,6 @@ class Team(BaseModel):
def __str__(self):
return self.name
def save(self, *args, **kwargs):
"""Override 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.
@@ -570,14 +982,15 @@ class Invitation(BaseModel):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
subject = gettext("Invitation to join La Régie!")
template_vars = {
"title": _("Invitation to join Desk!"),
"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(
_("Invitation to join Desk!"),
subject,
msg_plain,
settings.EMAIL_FROM,
[self.email],
@@ -587,3 +1000,7 @@ class Invitation(BaseModel):
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)
# It's not clear yet how best to split this file.
# pylint: disable=C0302
+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."""
@@ -19,6 +19,7 @@ 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:
@@ -40,7 +41,7 @@ class ResourceServerAuthentication(OIDCAuthentication):
def get_access_token(self, request):
"""Retrieve and decode the access token from the request.
This method overcharges the 'get_access_token' method from the parent class,
This method overrides the 'get_access_token' method from the parent class,
to support service providers that would base64 encode the bearer token.
"""
@@ -52,3 +53,20 @@ class ResourceServerAuthentication(OIDCAuthentication):
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
@@ -33,6 +33,7 @@ class ResourceServerBackend:
# 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()
@@ -60,6 +61,10 @@ class ResourceServerBackend:
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
@@ -84,6 +89,8 @@ class ResourceServerBackend:
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"])
@@ -99,6 +106,8 @@ class ResourceServerBackend:
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):
@@ -126,6 +135,12 @@ class ResourceServerBackend:
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):
@@ -218,6 +233,8 @@ class ResourceServerBackend:
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")
+3 -1
View File
@@ -19,7 +19,7 @@ class AuthorizationServerClient:
- Setting appropriate headers for secure communication as recommended by RFC drafts.
"""
# ruff: noqa: PLR0913 PLR017
# ruff: noqa: PLR0913 PLR0917
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-arguments
def __init__(
@@ -31,6 +31,8 @@ class AuthorizationServerClient:
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."
@@ -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
@@ -0,0 +1 @@
"""Core template tags module."""
+1
View File
@@ -0,0 +1 @@
"""Core tests."""
@@ -0,0 +1 @@
"""Core authentication tests."""
@@ -42,7 +42,8 @@ def test_authentication_getter_existing_user_with_email(
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = factories.UserFactory(name="John Doe")
user = factories.UserFactory(name="John Doe", with_organization=True)
def get_userinfo_mocked(*args):
return {
@@ -79,7 +80,9 @@ def test_authentication_getter_existing_user_change_fields(
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")
user = factories.UserFactory(
name="John Doe", email="john.doe@example.com", with_organization=True
)
def get_userinfo_mocked(*args):
return {
@@ -103,6 +106,39 @@ def test_authentication_getter_existing_user_change_fields(
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
):
@@ -112,7 +148,7 @@ def test_authentication_getter_existing_user_via_email(
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory()
db_user = factories.UserFactory(with_organization=True)
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
@@ -158,25 +194,23 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's info doesn't contain an email/name, created user's email/name should be empty.
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"}
return {"sub": "123"} # No email, no organization registration ID
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 is None
assert user.name is None
assert user.password == "!"
assert models.User.objects.count() == 1
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):
@@ -284,3 +318,138 @@ def test_authentication_getter_existing_disabled_user_via_email(
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 @@
"""Contacts tests package."""
@@ -0,0 +1,213 @@
"""
Test contacts API endpoints in People's core app.
"""
from django.test.utils import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
CONTACT_DATA = {
"emails": [
{"type": "Work", "value": "john.doe@work.com"},
{"type": "Home", "value": "john.doe@home.com"},
],
"phones": [
{"type": "Work", "value": "(123) 456-7890"},
{"type": "Other", "value": "(987) 654-3210"},
],
"addresses": [
{
"type": "Home",
"street": "123 Main St",
"city": "Cityville",
"state": "CA",
"zip": "12345",
"country": "USA",
}
],
"links": [
{"type": "Blog", "value": "http://personalwebsite.com"},
{"type": "Website", "value": "http://workwebsite.com"},
],
"customFields": {"custom_field_1": "value1", "custom_field_2": "value2"},
"organizations": [
{
"name": "ACME Corporation",
"department": "IT",
"jobTitle": "Software Engineer",
},
{
"name": "XYZ Ltd",
"department": "Marketing",
"jobTitle": "Marketing Specialist",
},
],
}
def test_api_contacts_create_anonymous_forbidden():
"""Anonymous users should not be able to create contacts via the API."""
response = APIClient().post(
"/api/v1.0/contacts/",
{
"full_name": "David",
"short_name": "Bowman",
},
)
assert response.status_code == 401
assert not models.Contact.objects.exists()
def test_api_contacts_create_authenticated_missing_base():
"""Authenticated user should be able to create contact without override."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/contacts/",
{
"full_name": "David Bowman",
"short_name": "Dave",
"data": {},
},
format="json",
)
assert response.status_code == 201
new_contact = models.Contact.objects.get(owner=user)
assert response.json() == {
"override": None,
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
"data": {},
"full_name": "David Bowman",
"id": str(new_contact.pk),
"notes": "",
"owner": str(user.pk),
"short_name": "Dave",
}
def test_api_contacts_create_authenticated_successful():
"""Authenticated users should be able to create contacts."""
user = factories.UserFactory()
base_contact = factories.BaseContactFactory()
client = APIClient()
client.force_login(user)
# Existing override for another user should not interfere
factories.ContactFactory(override=base_contact)
response = client.post(
"/api/v1.0/contacts/",
{
"override": str(base_contact.id),
"full_name": "David Bowman",
"short_name": "Dave",
"data": CONTACT_DATA,
},
format="json",
)
assert response.status_code == 201
assert models.Contact.objects.count() == 3
contact = models.Contact.objects.get(owner=user)
assert response.json() == {
"id": str(contact.id),
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
"override": str(base_contact.id),
"data": CONTACT_DATA,
"full_name": "David Bowman",
"notes": "",
"owner": str(user.id),
"short_name": "Dave",
}
assert contact.full_name == "David Bowman"
assert contact.short_name == "Dave"
assert contact.data == CONTACT_DATA
assert contact.override == base_contact
assert contact.owner == user
@override_settings(ALLOW_API_USER_CREATE=True)
def test_api_contacts_create_authenticated_existing_override():
"""
Trying to create a contact overriding a contact that is already overridden by the user
should receive a 400 error.
"""
user = factories.UserFactory()
base_contact = factories.BaseContactFactory()
factories.ContactFactory(override=base_contact, owner=user)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/contacts/",
{
"override": str(base_contact.id),
"full_name": "David Bowman",
"notes": "",
"short_name": "Dave",
"data": CONTACT_DATA,
},
format="json",
)
assert response.status_code == 400
assert models.Contact.objects.count() == 2
assert response.json() == {
"__all__": ["Contact with this Owner and Override already exists."]
}
def test_api_contacts_create_authenticated_successful_with_notes():
"""Authenticated users should be able to create contacts with notes."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/contacts/",
{
"full_name": "David Bowman",
"short_name": "Dave",
"data": CONTACT_DATA,
"notes": "This is a note",
},
format="json",
)
assert response.status_code == 201
assert models.Contact.objects.count() == 1
contact = models.Contact.objects.get(owner=user)
assert response.json() == {
"id": str(contact.id),
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
"override": None,
"data": CONTACT_DATA,
"full_name": "David Bowman",
"notes": "This is a note",
"owner": str(user.id),
"short_name": "Dave",
}
assert contact.full_name == "David Bowman"
assert contact.short_name == "Dave"
assert contact.data == CONTACT_DATA
assert contact.owner == user
assert contact.notes == "This is a note"
@@ -0,0 +1,120 @@
"""
Test contacts API endpoints in People's core app.
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_contacts_delete_list_anonymous():
"""Anonymous users should not be allowed to delete a list of contacts."""
factories.ContactFactory.create_batch(2)
response = APIClient().delete("/api/v1.0/contacts/")
assert response.status_code == 401
assert models.Contact.objects.count() == 2
def test_api_contacts_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of contacts."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.ContactFactory.create_batch(2)
response = client.delete("/api/v1.0/contacts/")
assert response.status_code == 405
assert models.Contact.objects.count() == 2
def test_api_contacts_delete_anonymous():
"""Anonymous users should not be allowed to delete a contact."""
contact = factories.ContactFactory()
client = APIClient()
response = client.delete(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 401
assert models.Contact.objects.count() == 1
def test_api_contacts_delete_authenticated_public():
"""
Authenticated users should not be allowed to delete a public contact.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
contact = factories.BaseContactFactory()
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
)
assert response.status_code == 403
assert models.Contact.objects.count() == 1
def test_api_contacts_delete_authenticated_owner():
"""
Authenticated users should be allowed to delete a contact they own.
"""
user = factories.UserFactory()
contact = factories.ContactFactory(owner=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
)
assert response.status_code == 204
assert models.Contact.objects.count() == 0
assert models.Contact.objects.filter(id=contact.id).exists() is False
def test_api_contacts_delete_authenticated_profile():
"""
Authenticated users should not be allowed to delete their profile contact.
"""
user = factories.UserFactory()
contact = factories.ProfileContactFactory(user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
)
assert response.status_code == 403
assert models.Contact.objects.exists() is True
def test_api_contacts_delete_authenticated_other():
"""
Authenticated users should not be allowed to delete a contact they don't own.
"""
user = factories.UserFactory()
contact = factories.ContactFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
)
assert response.status_code == 403
assert models.Contact.objects.count() == 1
@@ -0,0 +1,305 @@
"""
Test contacts API endpoints in People's core app.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_contacts_list_anonymous():
"""Anonymous users should not be allowed to list contacts."""
factories.ContactFactory.create_batch(2)
response = APIClient().get("/api/v1.0/contacts/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_contacts_list_authenticated_no_query(django_assert_num_queries):
"""
Authenticated users should be able to list contacts without applying a query.
Profile and overridden contacts should be excluded.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
# The user's profile contact should be listed (why not)
user_profile_contact = factories.ContactFactory(
owner=user, user=user, full_name="Dave Bowman"
)
# A contact that belongs to another user should not be listed
factories.ContactFactory()
# even if from the same organization
factories.ContactFactory(owner__organization=organization)
# A profile contact should not be listed if from another organization
factories.ProfileContactFactory()
# A profile contact for someone in the same organization should be listed
profile_contact = factories.ProfileContactFactory(
user__organization=organization, full_name="Frank Poole"
)
# An overridden contact should not be listed, but the override must be
overriden_contact = factories.ProfileContactFactory(user__organization=organization)
override_contact = factories.ContactFactory(
owner=user, override=overriden_contact, full_name="Nicole Foole"
)
client = APIClient()
client.force_login(user)
with django_assert_num_queries(2):
response = client.get("/api/v1.0/contacts/")
assert response.status_code == 200
assert response.json() == [
{
"id": str(user_profile_contact.pk),
"abilities": {"delete": False, "get": True, "patch": True, "put": True},
"override": None,
"owner": str(user.pk),
"data": user_profile_contact.data,
"full_name": user_profile_contact.full_name,
"notes": "",
"short_name": user_profile_contact.short_name,
},
{
"id": str(profile_contact.pk),
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
"override": None,
"owner": str(profile_contact.user.pk),
"data": profile_contact.data,
"full_name": profile_contact.full_name,
"notes": "",
"short_name": profile_contact.short_name,
},
{
"id": str(override_contact.pk),
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
"override": str(overriden_contact.pk),
"owner": str(user.pk),
"data": override_contact.data,
"full_name": override_contact.full_name,
"notes": "",
"short_name": override_contact.short_name,
},
]
def test_api_contacts_list_authenticated_by_full_name():
"""
Authenticated users should be able to search users with a case insensitive and
partial query on the full name.
"""
user = factories.UserFactory(name="Prudence Crandall")
dave = factories.BaseContactFactory(full_name="David Bowman", data={})
nicole = factories.BaseContactFactory(full_name="Nicole Foole", data={})
frank = factories.BaseContactFactory(full_name="Frank Poole", data={})
factories.BaseContactFactory(full_name="Heywood Floyd", data={})
# Full query should work
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/?q=David%20Bowman")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Partial query should work
response = client.get("/api/v1.0/contacts/?q=ank")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(frank.id)]
response = client.get("/api/v1.0/contacts/?q=ole")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(frank.id), str(nicole.id)]
response = client.get("/api/v1.0/contacts/?q=ool")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(frank.id), str(nicole.id)]
def test_api_contacts_list_authenticated_by_email():
"""
Authenticated users should be able to search users with a case insensitive and
partial query on the email.
"""
user = factories.UserFactory()
dave = factories.BaseContactFactory(
full_name="0", # don't match on full name but allow ordering
data={
"emails": [
{"type": "Home", "value": "dave@personal.com"},
{"type": "Work", "value": "david.bowman@example.com"},
],
},
)
nicole = factories.BaseContactFactory(
full_name="1", # don't match on full name but allow ordering
data={
"emails": [
{"type": "Work", "value": "nicole.foole@example.com"},
],
},
)
frank = factories.BaseContactFactory(
full_name="2", # don't match on full name but allow ordering
data={
"emails": [
{"type": "Home", "value": "francky@personal.com"},
{"type": "Work", "value": "franck.poole@example.com"},
],
},
)
factories.BaseContactFactory(
full_name="3", # don't match on full name but allow ordering
data={
"emails": [
{"type": "Work", "value": "heywood.floyd@example.com"},
],
},
)
# Full query should work
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/?q=david.bowman@example.com")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.pk)]
# Partial query should work
response = client.get("/api/v1.0/contacts/?q=anc")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(frank.pk)]
response = client.get("/api/v1.0/contacts/?q=olé") # accented
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(nicole.pk), str(frank.pk)]
response = client.get("/api/v1.0/contacts/?q=oOl") # mixed case
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(nicole.pk), str(frank.pk)]
def test_api_contacts_list_authenticated_uppercase_content():
"""Upper case content should be found by lower case query."""
user = factories.UserFactory()
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
# Unaccented full name
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/?q=eee")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = client.get("/api/v1.0/contacts/?q=aaa")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
def test_api_contacts_list_authenticated_capital_query():
"""Upper case query should find lower case content."""
user = factories.UserFactory()
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
client = APIClient()
client.force_login(user)
# Unaccented full name
response = client.get("/api/v1.0/contacts/?q=EEE")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = client.get("/api/v1.0/contacts/?q=AAA")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
def test_api_contacts_list_authenticated_accented_content():
"""Accented content should be found by unaccented query."""
user = factories.UserFactory()
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
client = APIClient()
client.force_login(user)
# Unaccented full name
response = client.get("/api/v1.0/contacts/?q=eee")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = client.get("/api/v1.0/contacts/?q=aaa")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
def test_api_contacts_list_authenticated_accented_query():
"""Accented query should find unaccented content."""
user = factories.UserFactory()
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
client = APIClient()
client.force_login(user)
# Unaccented full name
response = client.get("/api/v1.0/contacts/?q=ééé")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = client.get("/api/v1.0/contacts/?q=ààà")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
@@ -0,0 +1,89 @@
"""
Test contacts API endpoints in People's core app.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_contacts_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user."""
client = APIClient()
contact = factories.ContactFactory()
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_contacts_retrieve_authenticated_owned(django_assert_num_queries):
"""
Authenticated users should be allowed to retrieve a contact they own.
"""
user = factories.UserFactory()
contact = factories.ContactFactory(owner=user)
client = APIClient()
client.force_login(user)
with django_assert_num_queries(2):
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(contact.id),
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
"override": None,
"owner": str(contact.owner.id),
"data": contact.data,
"full_name": contact.full_name,
"notes": "",
"short_name": contact.short_name,
}
def test_api_contacts_retrieve_authenticated_public():
"""
Authenticated users should be able to retrieve public contacts.
"""
user = factories.UserFactory()
contact = factories.BaseContactFactory()
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(contact.id),
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
"override": None,
"owner": None,
"data": contact.data,
"full_name": contact.full_name,
"notes": "",
"short_name": contact.short_name,
}
def test_api_contacts_retrieve_authenticated_other():
"""
Authenticated users should not be allowed to retrieve another user's contacts.
"""
user = factories.UserFactory()
contact = factories.ContactFactory()
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@@ -0,0 +1,134 @@
"""
Test contacts API endpoints in People's core app.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api.client import serializers
pytestmark = pytest.mark.django_db
def test_api_contacts_update_anonymous():
"""Anonymous users should not be allowed to update a contact."""
contact = factories.ContactFactory()
old_contact_values = serializers.ContactSerializer(instance=contact).data
new_contact_values = serializers.ContactSerializer(
instance=factories.ContactFactory()
).data
new_contact_values["override"] = str(factories.ContactFactory().id)
response = APIClient().put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
contact.refresh_from_db()
contact_values = serializers.ContactSerializer(instance=contact).data
assert contact_values == old_contact_values
def test_api_contacts_update_authenticated_owned(django_assert_num_queries):
"""
Authenticated users should be allowed to update their own contacts.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
contact = factories.ContactFactory(owner=user) # Owned by the logged-in user
old_contact_values = serializers.ContactSerializer(instance=contact).data
new_contact_values = serializers.ContactSerializer(
instance=factories.ContactFactory()
).data
new_contact_values["override"] = str(factories.ContactFactory().id)
with django_assert_num_queries(8):
# user, 2x contact, user, 3x check, update contact
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
)
assert response.status_code == 200
contact.refresh_from_db()
contact_values = serializers.ContactSerializer(instance=contact).data
for key, value in contact_values.items():
if key in ["override", "owner", "id"]:
assert value == old_contact_values[key]
else:
assert value == new_contact_values[key]
def test_api_contacts_update_authenticated_profile():
"""
Authenticated users should be allowed to update their profile contact.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
contact = factories.ContactFactory(owner=user, user=user)
old_contact_values = serializers.ContactSerializer(instance=contact).data
new_contact_values = serializers.ContactSerializer(
instance=factories.ContactFactory()
).data
new_contact_values["override"] = str(factories.ContactFactory().id)
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
)
assert response.status_code == 200
contact.refresh_from_db()
contact_values = serializers.ContactSerializer(instance=contact).data
for key, value in contact_values.items():
if key in ["override", "owner", "id"]:
assert value == old_contact_values[key]
else:
assert value == new_contact_values[key]
def test_api_contacts_update_authenticated_other():
"""
Authenticated users should not be allowed to update contacts owned by other users.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
contact = factories.ContactFactory() # owned by another user
old_contact_values = serializers.ContactSerializer(instance=contact).data
new_contact_values = serializers.ContactSerializer(
instance=factories.ContactFactory()
).data
new_contact_values["override"] = str(factories.ContactFactory().id)
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
)
assert response.status_code == 403
contact.refresh_from_db()
contact_values = serializers.ContactSerializer(instance=contact).data
assert contact_values == old_contact_values
@@ -0,0 +1 @@
"""Test organization API endpoints."""
@@ -0,0 +1,91 @@
"""
Tests for Organizations API endpoint in People's core app: retrieve
"""
import pytest
from rest_framework import status
from core import factories
pytestmark = pytest.mark.django_db
def test_api_organizations_retrieve_anonymous(client):
"""Anonymous users should not be allowed to retrieve an organization."""
organization = factories.OrganizationFactory(with_registration_id=True)
response = client.get(f"/api/v1.0/organizations/{organization.pk}/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_organizations_retrieve_authenticated_unrelated(client):
"""
Authenticated users should not be allowed to retrieve an organization to which they are
not related.
"""
user = factories.UserFactory()
organization = factories.OrganizationFactory(with_registration_id=True)
client.force_login(user)
response = client.get(
f"/api/v1.0/organizations/{organization.pk!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Organization matches the given query."}
def test_api_organizations_retrieve_authenticated_belong_to_organization(client):
"""
Authenticated users should be allowed to retrieve an organization to which they
belong to.
"""
organization = factories.OrganizationFactory(
registration_id_list=["56618615316840", "31561861231231", "98781236231482"],
domain_list=["example.com", "example.org"],
)
user = factories.UserFactory(organization=organization)
client.force_login(user)
response = client.get(
f"/api/v1.0/organizations/{organization.pk!s}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(organization.pk),
"name": organization.name,
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
"domain_list": ["example.com", "example.org"],
"registration_id_list": ["56618615316840", "31561861231231", "98781236231482"],
}
def test_api_organizations_retrieve_authenticated_administrator(client):
"""
Authenticated users should be allowed to retrieve an organization
which they administrate.
"""
organization_access = (
factories.OrganizationAccessFactory()
) # only role is administrator for now
user = organization_access.user
organization = organization_access.organization
client.force_login(user)
response = client.get(
f"/api/v1.0/organizations/{organization.pk!s}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["abilities"] == {
"delete": False,
"get": True,
"patch": True,
"put": True,
}
@@ -0,0 +1,96 @@
"""
Tests for Organizations API endpoint in People's core app: update
"""
import pytest
from rest_framework import status
from core import factories
pytestmark = pytest.mark.django_db
def test_api_organizations_update_anonymous(client):
"""Anonymous users should not be allowed to update an organization."""
organization = factories.OrganizationFactory(with_registration_id=True)
response = client.patch(
f"/api/v1.0/organizations/{organization.pk}/",
{"name": "New Name"},
content_type="application/json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_organizations_update_authenticated_unrelated(client):
"""
Authenticated users should not be allowed to update an organization to which they are
not related.
"""
user = factories.UserFactory()
organization = factories.OrganizationFactory(with_registration_id=True)
client.force_login(user)
response = client.patch(
f"/api/v1.0/organizations/{organization.pk}/",
{"name": "New Name"},
content_type="application/json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Organization matches the given query."}
def test_api_organizations_update_authenticated_belong_to_organization(client):
"""
Authenticated users should NOT be allowed to update an organization to which they
belong to.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
client.force_login(user)
response = client.patch(
f"/api/v1.0/organizations/{organization.pk}/",
{"name": "New Name"},
content_type="application/json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_organizations_update_authenticated_administrator(client):
"""
Authenticated users should be allowed to update an organization
which they administrate.
"""
organization = factories.OrganizationFactory(
registration_id_list=["56618615316840", "31561861231231", "98781236231482"],
domain_list=["example.com", "example.org"],
)
organization_access = factories.OrganizationAccessFactory(organization=organization)
user = organization_access.user
client.force_login(user)
response = client.patch(
f"/api/v1.0/organizations/{organization.pk}/",
{"name": "New Name"},
content_type="application/json",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(organization.pk),
"name": "New Name",
"abilities": {"delete": False, "get": True, "patch": True, "put": True},
"domain_list": ["example.com", "example.org"],
"registration_id_list": ["56618615316840", "31561861231231", "98781236231482"],
}
@@ -0,0 +1 @@
"""Core resource server tests."""
@@ -0,0 +1,178 @@
"""Tests for the authentication process of the resource server."""
import base64
import json
import pytest
import responses
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from joserfc import jwe as jose_jwe
from joserfc import jwt as jose_jwt
from joserfc.rfc7518.rsa_key import RSAKey
from jwt.utils import to_base64url_uint
from rest_framework.request import Request as DRFRequest
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
from core.factories import UserFactory
from core.models import ServiceProvider
from core.resource_server.authentication import ResourceServerAuthentication
pytestmark = pytest.mark.django_db
def build_authorization_bearer(token):
"""
Build an Authorization Bearer header value from a token.
This can be used like this:
client.post(
...
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
)
"""
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
@responses.activate
def test_resource_server_authentication_class(client, settings):
"""
Defines the settings for the resource server
for a full authentication with introspection process.
This is an integration test that checks the authentication process
when using the ResourceServerAuthentication class.
This test asserts the DRF request object contains the
`resource_server_token_audience` attribute which is used in
the resource server views.
This test uses the `/resource-server/v1.0/teams/` URL as an example
because we don't want to create a new URL just for this test.
"""
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
unencrypted_pem_private_key = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
pem_public_key = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
settings.OIDC_RS_PRIVATE_KEY_STR = unencrypted_pem_private_key.decode("utf-8")
settings.OIDC_RS_ENCRYPTION_KEY_TYPE = "RSA"
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
settings.OIDC_RS_SIGNING_ALGO = "RS256"
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
# Mock the JWKS endpoint
public_numbers = private_key.public_key().public_numbers()
responses.add(
responses.GET,
settings.OIDC_OP_JWKS_ENDPOINT,
body=json.dumps(
{
"keys": [
{
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
"alg": settings.OIDC_RS_SIGNING_ALGO,
"use": "sig",
"kid": "1234567890",
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
}
]
}
),
)
def encrypt_jwt(json_data):
"""Encrypt the JWT token for the backend to decrypt."""
token = jose_jwt.encode(
{
"kid": "1234567890",
"alg": settings.OIDC_RS_SIGNING_ALGO,
},
json_data,
RSAKey.import_key(unencrypted_pem_private_key),
algorithms=[settings.OIDC_RS_SIGNING_ALGO],
)
return jose_jwe.encrypt_compact(
protected={
"alg": settings.OIDC_RS_ENCRYPTION_ALGO,
"enc": settings.OIDC_RS_ENCRYPTION_ENCODING,
},
plaintext=token,
public_key=RSAKey.import_key(pem_public_key),
algorithms=[
settings.OIDC_RS_ENCRYPTION_ALGO,
settings.OIDC_RS_ENCRYPTION_ENCODING,
],
)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
body=encrypt_jwt(
{
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"token_introspection": {
"sub": "very-specific-sub",
"iss": "https://oidc.example.com",
"aud": "some_service_provider",
"scope": "openid groups",
"active": True,
},
}
),
)
# Try to authenticate while the user does not exist => 401
response = client.get(
"/resource-server/v1.0/teams/", # use an exising URL here
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert ServiceProvider.objects.count() == 0
# Create a user with the specific sub, the access is authorized
UserFactory(sub="very-specific-sub")
response = client.get(
"/resource-server/v1.0/teams/", # use an exising URL here
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
)
assert response.status_code == HTTP_200_OK
response_request = response.renderer_context.get("request")
assert isinstance(response_request, DRFRequest)
assert isinstance(
response_request.successful_authenticator, ResourceServerAuthentication
)
# Check that the user is authenticated
assert response_request.user.is_authenticated
# Check the request contains the resource server token audience
assert response_request.resource_server_token_audience == "some_service_provider"
# Check that no service provider is created here
assert ServiceProvider.objects.count() == 0
@@ -296,7 +296,7 @@ def test_introspect_public_key_import_failure(
def test_verify_user_info_success(resource_server_backend):
"""Test '_verify_user_info' with a successful response."""
introspection_response = {"active": True, "scope": "groups"}
introspection_response = {"active": True, "scope": "groups", "aud": "123"}
result = resource_server_backend._verify_user_info(introspection_response)
assert result == introspection_response
@@ -333,7 +333,7 @@ def test_get_user_success(resource_server_backend):
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {"sub": "user123"}}
mock_claims = {"token_introspection": {"sub": "user123", "aud": "123"}}
mock_user = Mock()
resource_server_backend._introspect = Mock(return_value=mock_jwt)
@@ -0,0 +1 @@
"""Tests for the resource server API endpoints."""
@@ -0,0 +1,79 @@
"""Defines fixtures for the resource server API tests."""
from contextlib import contextmanager
from typing import Optional
from unittest import mock
from django.contrib.auth import get_user_model
import pytest
import responses
from faker import Faker
from core.resource_server.authentication import ResourceServerAuthentication
User = get_user_model()
fake = Faker()
@contextmanager
def _force_login_via_resource_server(
client_fixture,
user: User,
service_provider_audience: Optional[str],
):
"""
Context manager to authenticate a user with a service provider via
a resource server call.
This allows to authenticate a user with a service provider without doing
all the introspection process.
This is a private function, use the `force_login_via_resource_server`
fixture instead.
The `service_provider_audience` parameter might not match any existing
service provider audience, doing so allow to check the behavior when
the service provider is not yet known.
"""
def mock_authenticate(self, request): # pylint: disable=unused-argument
request.resource_server_token_audience = (
service_provider_audience or fake.pystr(min_chars=10, max_chars=10)
)
return user, "unused-token"
with mock.patch.object(
ResourceServerAuthentication, "authenticate", mock_authenticate
):
client_fixture.force_login(
user,
backend="core.resource_server.authentication.ResourceServerAuthentication",
)
yield
@pytest.fixture(name="force_login_via_resource_server")
@responses.activate
def force_login_via_resource_server_fixture():
"""
Fixture to authenticate a user with a service provider via a resource server call.
Usage:
```
def test_login_with_resource_server(
client, force_login_via_resource_server,
):
user = UserFactory()
service_provider = ServiceProviderFactory()
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
"/resource-server/v1.0/<whatever>/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
# response is authenticated
```
"""
return _force_login_via_resource_server
@@ -0,0 +1 @@
"""Tests for the resource server Team API endpoints."""
@@ -0,0 +1,166 @@
"""
Tests for Teams API endpoint in People's core app: create
"""
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_401_UNAUTHORIZED,
)
from rest_framework.test import APIClient
from core.factories import OrganizationFactory, ServiceProviderFactory, UserFactory
from core.models import ServiceProvider, Team
pytestmark = pytest.mark.django_db
def test_api_teams_create_anonymous():
"""Anonymous users should not be allowed to create teams."""
response = APIClient().post(
"/resource-server/v1.0/teams/",
{
"name": "my team",
},
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert not Team.objects.exists()
def test_api_teams_create_authenticated_new_service_provider(
client, force_login_via_resource_server
):
"""
Authenticated users should be able to create teams and should automatically be declared
as the owner of the newly created team and a new service provider should be created and
associated to the team.
"""
organization = OrganizationFactory(with_registration_id=True)
user = UserFactory(organization=organization)
assert ServiceProvider.objects.count() == 0
with force_login_via_resource_server(client, user, "some_service_provider"):
response = client.post(
"/resource-server/v1.0/teams/",
{
"name": "my team",
},
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
team_access = team.accesses.get()
service_provider = ServiceProvider.objects.get() # service provider created
assert response.json() == {
"created_at": team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"id": str(team.pk),
"depth": team.depth,
"name": "my team",
"numchild": team.numchild,
"path": team.path,
"updated_at": team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
# check team data
assert team.name == "my team"
assert team.organization == organization
# check team access data
assert team_access.role == "owner"
assert team_access.user == user
# check service provider data
assert service_provider.audience_id == "some_service_provider"
def test_api_teams_create_authenticated_existing_service_provider(
client,
force_login_via_resource_server,
):
"""
Authenticated users should be able to create teams and should automatically be declared
as the owner of the newly created team and an existing service provider should be associated
to the team.
"""
user = UserFactory()
service_provider = ServiceProviderFactory()
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.post(
"/resource-server/v1.0/teams/",
{
"name": "my team",
},
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_201_CREATED
assert ServiceProvider.objects.count() == 1 # no object created
team = Team.objects.get() # team created
assert team.service_providers.get().audience_id == service_provider.audience_id
assert team.name == "my team"
assert team.accesses.filter(role="owner", user=user).exists()
def test_api_teams_create_cannot_override_organization(
client, force_login_via_resource_server
):
"""
Authenticated users should be able to create teams and not
be able to set the organization manually (for now).
"""
organization = OrganizationFactory(with_registration_id=True)
user = UserFactory(organization=organization)
service_provider = ServiceProviderFactory()
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.post(
"/resource-server/v1.0/teams/",
{
"name": "my team",
"organization": OrganizationFactory(
with_registration_id=True
).pk, # ignored
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == "my team"
assert team.organization == organization
assert team.accesses.filter(role="owner", user=user).exists()
def test_api_teams_create_cannot_override_service_provider(
client, force_login_via_resource_server
):
"""
Authenticated users should be able to create teams and not
be able to set the team service provider manually.
"""
user = UserFactory(with_organization=True)
service_provider = ServiceProviderFactory()
other_service_provider = ServiceProviderFactory()
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.post(
"/resource-server/v1.0/teams/",
{
"name": "my team",
"service_providers": [str(other_service_provider.pk)], # ignored
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == "my team"
assert team.service_providers.get().audience_id == service_provider.audience_id
@@ -0,0 +1,49 @@
"""
Tests for Teams API endpoint in People's core app: delete
"""
import pytest
from rest_framework.status import (
HTTP_401_UNAUTHORIZED,
HTTP_405_METHOD_NOT_ALLOWED,
)
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_teams_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team."""
team = factories.TeamFactory()
response = APIClient().delete(
f"/resource-server/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert models.Team.objects.count() == 1
def test_api_teams_delete_not_allowed(client, force_login_via_resource_server):
"""
Authenticated users should not be allowed to delete a team from a resource
server, even if they have the right permissions.
This may be implemented in the future, but for now, it is not allowed.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
team = factories.TeamFactory(
users=[(user, "owner")], service_providers=[service_provider]
)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.delete(
f"/resource-server/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.json() == {"detail": 'Method "DELETE" not allowed.'}
assert models.Team.objects.count() == 1
@@ -0,0 +1,275 @@
"""
Tests for Teams API endpoint in People's core app: list
"""
import pytest
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_teams_list_anonymous():
"""Anonymous users should not be allowed to list teams."""
factories.TeamFactory.create_batch(2)
response = APIClient().get("/resource-server/v1.0/teams/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
client, django_assert_num_queries, force_login_via_resource_server
):
"""
Authenticated users should be able to list teams
they are an owner/administrator/member of, and only list from the
requesting service provider should appear.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
hidden_service_provider = factories.ServiceProviderFactory()
team_access_1 = factories.TeamAccessFactory(
user=user, team__service_providers=[service_provider], role="member"
)
team_1 = team_access_1.team
team_access_2 = factories.TeamAccessFactory(
user=user,
team__service_providers=[hidden_service_provider, service_provider],
role="member",
)
team_2 = team_access_2.team
team_access_3 = factories.TeamAccessFactory(
user=user, team__service_providers=[service_provider], role="administrator"
)
team_3 = team_access_3.team
team_access_4 = factories.TeamAccessFactory(
user=user, team__service_providers=[service_provider], role="owner"
)
team_4 = team_access_4.team
# Team filtered out because of the service provider
_not_included_team_access = factories.TeamAccessFactory(
user=user, team__service_providers=[hidden_service_provider]
)
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
with django_assert_num_queries(5):
# queries: Team path, Count, Team, ServiceProvider, TeamAccess
response = client.get(
"/resource-server/v1.0/teams/?ordering=created_at",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"count": 4,
"next": None,
"previous": None,
"results": [
{
"created_at": team_1.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_1.depth,
"id": str(team_1.pk),
"name": team_1.name,
"numchild": team_1.numchild,
"path": team_1.path,
"updated_at": team_1.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
{
"created_at": team_2.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_2.depth,
"id": str(team_2.pk),
"name": team_2.name,
"numchild": team_2.numchild,
"path": team_2.path,
"updated_at": team_2.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
{
"created_at": team_3.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_3.depth,
"id": str(team_3.pk),
"name": team_3.name,
"numchild": team_3.numchild,
"path": team_3.path,
"updated_at": team_3.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
{
"created_at": team_4.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_4.depth,
"id": str(team_4.pk),
"name": team_4.name,
"numchild": team_4.numchild,
"path": team_4.path,
"updated_at": team_4.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
],
}
def test_api_teams_list_authenticated_new_service_provider(
client, force_login_via_resource_server
):
"""
Team list from not yet known service provider should be empty (not fail).
Teams without service providers should not be listed.
"""
user = factories.UserFactory()
_team = factories.TeamFactory(users=[user])
with force_login_via_resource_server(client, user, "some_service_provider"):
response = client.get(
"/resource-server/v1.0/teams/",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_teams_list_authenticated_distinct(client, force_login_via_resource_server):
"""A team with several related users should only be listed once."""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
other_user = factories.UserFactory()
team = factories.TeamFactory(
users=[user, other_user], service_providers=[service_provider]
)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
"/resource-server/v1.0/teams/",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 1
results = content["results"]
assert len(results) == 1
assert results[0]["id"] == str(team.id)
def test_api_teams_order_param(client, force_login_via_resource_server):
"""
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
team_ids = [
str(team.id)
for team in factories.TeamFactory.create_batch(
5, users=[user], service_providers=[service_provider]
)
]
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
"/resource-server/v1.0/teams/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
assert response_team_ids == team_ids, (
"created_at values are not sorted from oldest to newest"
)
def test_api_teams_list_with_parent_teams(client, force_login_via_resource_server):
"""
Authenticated users should be able to list teams including parent teams.
Parent teams should not be listed if they don't have the service provider.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
root_team = factories.TeamFactory(name="Root", service_providers=[service_provider])
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(
name="Second", parent_id=first_team.pk, service_providers=[service_provider]
)
factories.TeamAccessFactory(user=user, team=second_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
"/resource-server/v1.0/teams/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
response_data = response.json()
assert response_data["count"] == 2
team_ids = [team["id"] for team in response_data["results"]]
assert len(team_ids) == 2
assert set(team_ids) == {str(root_team.id), str(second_team.id)}
def test_api_teams_list_with_parent_teams_other_organization(
client, force_login_via_resource_server
):
"""
Authenticated users should be able to list teams including parent teams.
Parent teams should not be listed if they don't have the service provider
or if the user does not belong to the organization.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
other_organization = factories.OrganizationFactory(with_registration_id=True)
root_team = factories.TeamFactory(
name="Root",
service_providers=[service_provider],
organization=other_organization,
)
first_team = factories.TeamFactory(
name="First", parent_id=root_team.pk, organization=other_organization
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=other_organization,
)
factories.TeamAccessFactory(user=user, team=second_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
"/resource-server/v1.0/teams/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
response_data = response.json()
assert response_data["count"] == 1
team_ids = [team["id"] for team in response_data["results"]]
assert len(team_ids) == 1
assert set(team_ids) == {str(second_team.id)}
@@ -0,0 +1,231 @@
"""
Tests for Teams API endpoint in People's core app: retrieve
"""
import pytest
from rest_framework import status
from rest_framework.status import HTTP_404_NOT_FOUND
from rest_framework.test import APIClient
from core import factories
from core.factories import TeamAccessFactory, UserFactory
pytestmark = pytest.mark.django_db
def test_api_teams_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a team."""
team = factories.TeamFactory()
response = APIClient().get(f"/resource-server/v1.0/teams/{team.id}/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_teams_retrieve_authenticated_unrelated(
client, force_login_via_resource_server
):
"""
Authenticated users should not be allowed to retrieve a team to which they are
not related.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
team = factories.TeamFactory(service_providers=[service_provider])
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{team.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
def test_api_teams_retrieve_authenticated_related(
client, force_login_via_resource_server
):
"""
Authenticated users should be allowed to retrieve a team to which they
are related whatever the role even if the request is authenticated via
a resource server.
"""
service_provider = factories.ServiceProviderFactory(
audience_id="some_service_provider"
)
user = factories.UserFactory()
team = factories.TeamFactory(users=[user], service_providers=[service_provider])
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{team.id!s}/",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"depth": 1,
"id": str(team.id),
"name": team.name,
"numchild": 0,
"path": team.path,
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_teams_retrieve_authenticated_other_service_provider(
client, force_login_via_resource_server
):
"""
Authenticated users should not be able to retrieve a team
if the request is authenticated via a different resource server.
"""
user = UserFactory()
service_provider = factories.ServiceProviderFactory()
other_service_provider = factories.ServiceProviderFactory(
audience_id="some_service_provider"
)
team = factories.TeamFactory(
users=[user], service_providers=[other_service_provider]
)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{team.id!s}/",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_404_NOT_FOUND
def test_api_teams_retrieve_authenticated_related_parent_same_organization(
client, force_login_via_resource_server
):
"""
Authenticated users should be allowed to retrieve a parent team
of a team to which they are related, only if they belong to the
same organization.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
root_team = factories.TeamFactory(
name="Root",
organization=organization,
)
first_team = factories.TeamFactory(
name="First",
parent_id=root_team.pk,
organization=organization,
service_providers=[service_provider],
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=organization,
)
TeamAccessFactory(user=user, team=second_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{first_team.pk}/",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
"depth": 2,
"id": str(first_team.pk),
"name": first_team.name,
"numchild": 1,
"path": first_team.path,
"updated_at": first_team.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_teams_retrieve_authenticated_related_parent_other_organization(
client, force_login_via_resource_server
):
"""
Authenticated users should be allowed to retrieve a parent team
of a team to which they are related, only if they belong to the
same organization.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
other_organization = factories.OrganizationFactory(with_registration_id=True)
root_team = factories.TeamFactory(
name="Root",
organization=other_organization,
)
first_team = factories.TeamFactory(
name="First",
parent_id=root_team.pk,
organization=other_organization,
service_providers=[service_provider],
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=other_organization,
)
TeamAccessFactory(user=user, team=second_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{first_team.pk}/",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
def test_api_teams_retrieve_authenticated_related_child_same_organization(
client, force_login_via_resource_server
):
"""
Authenticated users should NOT be allowed to retrieve a child team
of a team to which they are related, even if they belong to the
same organization.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
root_team = factories.TeamFactory(
name="Root",
organization=organization,
)
first_team = factories.TeamFactory(
name="First",
parent_id=root_team.pk,
organization=organization,
service_providers=[service_provider],
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=organization,
)
TeamAccessFactory(user=user, team=first_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{second_team.pk}/",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
@@ -0,0 +1,338 @@
"""
Tests for Teams API endpoint in People's core app: update
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
)
from rest_framework.test import APIClient
from core import factories
from core.api.resource_server import serializers
pytestmark = pytest.mark.django_db
def test_api_teams_update_anonymous():
"""Anonymous users should not be allowed to update a team."""
team = factories.TeamFactory()
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
f"/resource-server/v1.0/teams/{team.id!s}/",
new_team_values,
content_type="application/json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
def test_api_teams_update_authenticated_unrelated(
client, force_login_via_resource_server
):
"""
Authenticated users should not be allowed to update a team to which they are not related.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
team = factories.TeamFactory(service_providers=[service_provider])
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.put(
f"/resource-server/v1.0/teams/{team.id!s}/",
new_team_values,
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
def test_api_teams_update_authenticated(
client,
force_login_via_resource_server,
):
"""
Authenticated users should be allowed to update a team to which they
are related whatever the role even if the request is authenticated via
a resource server.
"""
service_provider = factories.ServiceProviderFactory(
audience_id="some_service_provider"
)
user = factories.UserFactory()
team = factories.TeamFactory(
name="Old name",
users=[(user, "owner")],
service_providers=[service_provider],
)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.put(
f"/resource-server/v1.0/teams/{team.id!s}/",
data=serializers.TeamSerializer(instance=team).data
| {
"name": "New name",
},
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
team.refresh_from_db()
assert team.name == "New name"
def test_api_teams_update_authenticated_other_resource_server(
client, force_login_via_resource_server
):
"""
Authenticated users should not be able to update a team for which they are directly
owner, if the request is authenticated via a different service provider.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
other_service_provider = factories.ServiceProviderFactory(
audience_id="some_service_provider"
)
team = factories.TeamFactory(
name="Old name",
users=[(user, "owner")],
service_providers=[other_service_provider],
)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.put(
f"/resource-server/v1.0/teams/{team.id!s}/",
data=serializers.TeamSerializer(instance=team).data
| {
"name": "New name",
},
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
team.refresh_from_db()
assert team.name == "Old name"
def test_api_teams_update_authenticated_members(
client, force_login_via_resource_server
):
"""
Users who are members of a team but not administrators should
not be allowed to update it.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
team = factories.TeamFactory(
users=[(user, "member")], service_providers=[service_provider]
)
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.put(
f"/resource-server/v1.0/teams/{team.id!s}/",
new_team_values,
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_teams_update_authenticated_administrators(
client, force_login_via_resource_server, role
):
"""Administrators or owners of a team should be allowed to update it."""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
team = factories.TeamFactory(
users=[(user, role)],
service_providers=[service_provider],
name="old name",
)
initial_created_at = team.created_at
initial_updated_at = team.updated_at
initial_pk = team.pk
# generate new random values
new_values = serializers.TeamSerializer(instance=factories.TeamFactory.build()).data
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.put(
f"/resource-server/v1.0/teams/{team.id!s}/",
new_values,
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
team.refresh_from_db()
assert team.pk == initial_pk
assert team.name == new_values["name"]
assert team.created_at == initial_created_at
assert team.updated_at > initial_updated_at
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_teams_update_administrator_or_owner_of_another(
client, force_login_via_resource_server, role
):
"""
Being administrator or owner of a team should not grant authorization to update
another team.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
factories.TeamFactory(users=[(user, role)], service_providers=[service_provider])
team = factories.TeamFactory(name="Old name", service_providers=[service_provider])
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.put(
f"/resource-server/v1.0/teams/{team.id!s}/",
new_team_values,
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
@pytest.mark.parametrize("role", ["administrator", "member", "owner"])
def test_api_teams_update_parent_team(client, force_login_via_resource_server, role):
"""
Belonging to a team should NOT grant authorization to update
another parent team.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
root_team = factories.TeamFactory(
name="Root",
organization=organization,
)
first_team = factories.TeamFactory(
name="First",
parent_id=root_team.pk,
organization=organization,
service_providers=[service_provider],
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=organization,
)
factories.TeamAccessFactory(user=user, team=second_team, role=role)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.patch(
f"/resource-server/v1.0/teams/{first_team.pk}/",
{
"name": "New name",
},
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
first_team.refresh_from_db()
assert first_team.name == "First"
@pytest.mark.parametrize("role", ["administrator", "member", "owner"])
def test_api_teams_update_child_team(client, force_login_via_resource_server, role):
"""
Belonging to a team should NOT grant authorization to update
another child team.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
root_team = factories.TeamFactory(
name="Root",
organization=organization,
)
first_team = factories.TeamFactory(
name="First",
parent_id=root_team.pk,
organization=organization,
service_providers=[service_provider],
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=organization,
)
factories.TeamAccessFactory(user=user, team=first_team, role=role)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.patch(
f"/resource-server/v1.0/teams/{second_team.pk}/",
{
"name": "New name",
},
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
second_team.refresh_from_db()
assert second_team.name == "Second"
@@ -0,0 +1,3 @@
"""
Test for the service providers viewset.
"""
@@ -0,0 +1,91 @@
"""
Tests for Service Provider API endpoint in People's core app: list
"""
import pytest
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
from core import factories
pytestmark = pytest.mark.django_db
def test_api_service_providers_list_anonymous(client):
"""Anonymous users should not be allowed to list service providers."""
factories.ServiceProviderFactory.create_batch(2)
response = client.get("/api/v1.0/service-providers/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_service_providers_list_authenticated(client):
"""
Authenticated users should be able to list service providers
of their organization.
"""
user = factories.UserFactory(with_organization=True)
client.force_login(user)
service_provider_1 = factories.ServiceProviderFactory(
name="A", organizations=[user.organization]
)
service_provider_2 = factories.ServiceProviderFactory(
name="B", organizations=[user.organization]
)
# Generate some not fetched data
factories.ServiceProviderFactory.create_batch(
2, organizations=[factories.OrganizationFactory(with_registration_id=True)]
) # Other service providers
factories.TeamFactory(
users=[user], service_providers=[factories.ServiceProviderFactory()]
)
response = client.get(
"/api/v1.0/service-providers/",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"audience_id": str(service_provider_1.audience_id),
"id": str(service_provider_1.pk),
"name": "A",
},
{
"audience_id": str(service_provider_2.audience_id),
"id": str(service_provider_2.pk),
"name": "B",
},
],
}
def test_api_service_providers_order(client):
"""Test that the service providers are sorted as requested."""
user = factories.UserFactory(with_organization=True)
factories.ServiceProviderFactory(name="A", organizations=[user.organization])
factories.ServiceProviderFactory(name="B", organizations=[user.organization])
client.force_login(user)
# Test ordering by name descending
response = client.get("/api/v1.0/service-providers/?ordering=-name")
assert response.status_code == 200
response_data = response.json()["results"]
assert response_data[0]["name"] == "B"
assert response_data[1]["name"] == "A"
# Test ordering by creation date ascending
response = client.get("/api/v1.0/service-providers/?ordering=created_at")
response_data = response.json()["results"]
assert response_data[0]["name"] == "A"
assert response_data[1]["name"] == "B"
@@ -0,0 +1,85 @@
"""
Tests for Service Provider API endpoint in People's core app: retrieve
"""
import pytest
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND
from core import factories
pytestmark = pytest.mark.django_db
def test_api_service_providers_retrieve_anonymous(client):
"""Anonymous users should not be allowed to retrieve service providers."""
service_provider = factories.ServiceProviderFactory()
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_service_providers_retrieve_authenticated_allowed(client):
"""
Authenticated users should be able to retrieve service providers
of their organization.
"""
user = factories.UserFactory(with_organization=True)
client.force_login(user)
service_provider = factories.ServiceProviderFactory(
organizations=[user.organization]
)
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"audience_id": str(service_provider.audience_id),
"id": str(service_provider.pk),
"name": service_provider.name,
}
def test_api_service_providers_retrieve_authenticated_other_organization(client):
"""
Authenticated users should not be able to retrieve service providers
of other organization.
"""
user = factories.UserFactory(with_organization=True)
client.force_login(user)
service_provider = factories.ServiceProviderFactory(
organizations=[factories.OrganizationFactory(with_registration_id=True)]
)
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No ServiceProvider matches the given query."}
def test_api_service_providers_retrieve_authenticated_on_teams(client):
"""
Authenticated users should not be able to retrieve service providers
just because it is related to one of their teams if it is not related
to their organization (might change later if needed).
"""
user = factories.UserFactory(with_organization=True)
client.force_login(user)
other_organization = factories.OrganizationFactory(with_registration_id=True)
service_provider = factories.ServiceProviderFactory()
factories.TeamFactory(
users=[user],
organization=other_organization,
service_providers=[service_provider],
)
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No ServiceProvider matches the given query."}
@@ -0,0 +1 @@
"""Core Swagger tests."""
@@ -0,0 +1 @@
"""Team accesses tests package."""
@@ -338,14 +338,19 @@ def test_api_team_accesses_list_authenticated_ordering():
def test_api_team_accesses_list_authenticated_ordering_user(ordering_field):
"""Team accesses can be ordered by user's fields."""
user = factories.UserFactory()
john = factories.UserFactory(email="john.doe@example.com", name="John Doe")
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user)
models.TeamAccess.objects.create(team=team, user=john)
# create 20 new team members
for _ in range(20):
extra_user = factories.UserFactory()
factories.TeamAccessFactory(team=team, user=extra_user)
# create new team members
dave = factories.UserFactory(email="bowbow@example.com", name="David Bowman")
nicole = factories.UserFactory(
email="nicole_foole@example.com", name="Nicole Foole"
)
frank = factories.UserFactory(email="frank_poole@example.com", name="Frank Poole")
mary = factories.UserFactory(email="mary_pol@example.com", name="Mary Pol")
for user in (dave, nicole, frank, mary):
factories.TeamAccessFactory(team=team, user=user)
client = APIClient()
client.force_login(user)
@@ -354,14 +359,11 @@ def test_api_team_accesses_list_authenticated_ordering_user(ordering_field):
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=user__{ordering_field}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
def normalize(x):
"""Mimic Django order_by, which is case-insensitive and space-insensitive"""
return x.casefold().replace(" ", "")
results = [
team_access["user"][ordering_field]
for team_access in response.json()["results"]
assert response.json()["count"] == 5
assert [access["user"]["name"] for access in response.json()["results"]] == [
dave.name,
frank.name,
john.name,
mary.name,
nicole.name,
]
assert sorted(results, key=normalize) == results
@@ -9,7 +9,7 @@ import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.api.client import serializers
pytestmark = pytest.mark.django_db
+1
View File
@@ -0,0 +1 @@
"""Teams tests package."""
@@ -5,12 +5,12 @@ Tests for Teams API endpoint in People's core app: create
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
)
from rest_framework.test import APIClient
from core.factories import TeamFactory, UserFactory
from core.factories import OrganizationFactory, UserFactory
from core.models import Team
pytestmark = pytest.mark.django_db
@@ -29,16 +29,25 @@ def test_api_teams_create_anonymous():
assert not Team.objects.exists()
def test_api_teams_create_authenticated():
def test_api_teams_create_authenticated(settings):
"""
Authenticated users should be able to create teams and should automatically be declared
as the owner of the newly created team.
"""
user = UserFactory()
organization = OrganizationFactory(with_registration_id=True)
user = UserFactory(organization=organization)
client = APIClient()
client.force_login(user)
settings.FEATURES = {
"TEAMS_DISPLAY": True,
"TEAMS_CREATE": True,
"CONTACTS_DISPLAY": False,
"CONTACTS_CREATE": False,
"MAILBOXES_CREATE": False,
}
response = client.post(
"/api/v1.0/teams/",
{
@@ -50,76 +59,64 @@ def test_api_teams_create_authenticated():
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == "my team"
assert team.organization == organization
assert team.accesses.filter(role="owner", user=user).exists()
def test_api_teams_create_authenticated_slugify_name():
def test_api_teams_create_authenticated_feature_disabled(settings):
"""
Creating teams should automatically generate a slug.
Authenticated users should not be able to create teams when feature is disabled.
"""
user = UserFactory()
organization = OrganizationFactory(with_registration_id=True)
user = UserFactory(organization=organization)
client = APIClient()
client.force_login(user)
settings.FEATURES = {
"TEAMS_DISPLAY": True,
"TEAMS_CREATE": False,
"CONTACTS_DISPLAY": False,
"CONTACTS_CREATE": False,
"MAILBOXES_CREATE": False,
}
response = client.post(
"/api/v1.0/teams/",
{
"name": "my team",
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert not Team.objects.exists()
def test_api_teams_create_cannot_override_organization():
"""
Authenticated users should be able to create teams and not
be able to set the organization manually (for now).
"""
organization = OrganizationFactory(with_registration_id=True)
user = UserFactory(organization=organization)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
{"name": "my team"},
{
"name": "my team",
"organization": OrganizationFactory(
with_registration_id=True
).pk, # ignored
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == "my team"
assert team.slug == "my-team"
@pytest.mark.parametrize(
"param",
[
("my team", "my-team"),
("my team", "my-team"),
("MY TEAM TOO", "my-team-too"),
("mon équipe", "mon-equipe"),
("front devs & UX", "front-devs-ux"),
],
)
def test_api_teams_create_authenticated_expected_slug(param):
"""
Creating teams should automatically create unaccented, no unicode, lower-case slug.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
{
"name": param[0],
},
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == param[0]
assert team.slug == param[1]
def test_api_teams_create_authenticated_unique_slugs():
"""
Creating teams should raise an error if already existing slug.
"""
TeamFactory(name="existing team")
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
{
"name": "èxisting team",
},
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["slug"] == ["Team with this Slug already exists."]
assert team.organization == organization
assert team.accesses.filter(role="owner", user=user).exists()
@@ -113,3 +113,59 @@ def test_api_teams_delete_authenticated_owner():
assert response.status_code == HTTP_204_NO_CONTENT
assert models.Team.objects.exists() is False
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_delete_authenticated_owner_parent_team(client, role):
"""
Authenticated users should not be able to delete a parent team they
don't own.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
# user is a member of the second team
factories.TeamAccessFactory(team=second_team, user=user, role=role)
response = client.delete(f"/api/v1.0/teams/{first_team.pk}/")
assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Team.objects.count() == 3
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_delete_authenticated_owner_child_team(client, role):
"""
Authenticated users should not be able to delete a children team they
don't own.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
# user is a member of the first team
factories.TeamAccessFactory(team=first_team, user=user, role=role)
response = client.delete(f"/api/v1.0/teams/{second_team.pk}/")
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
assert models.Team.objects.count() == 3

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