Compare commits

...

262 Commits

Author SHA1 Message Date
Sabrina Demagny 8865e250a0 🔖(minor) release version 1.9.0
Update all version files and changelog for minor release.
2024-12-17 14:48:38 +01:00
Nathan Panchout fa114b1064 (frontend) disable mailbox and allow to create pending mailbox
Allow to disable or enable mailboxes.
And enable mailbox creation button when domain is pending to
allow creation of mailboxes in pending status.
2024-12-17 14:16:06 +01:00
Marie PUPO JEAMMET 38369a8312 🚚(backend) split users test file to improve readability
"test_api_users" was a single test file of 900+ lines.
We used gitfilesplit to split it into several shorter files
for readability.
2024-12-17 14:00:35 +01:00
Marie PUPO JEAMMET d3940f6d09 Split test_api_users.py into users/test_api_users_retrieve.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET 6061a65e44 Split test_api_users.py into users/test_api_users_create.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET 4a763396e2 Split test_api_users.py into users/test_api_users_delete.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET fcc63f9b82 Split test_api_users.py into users/test_api_users_update.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET 0f4db82b02 Split test_api_users.py into users/test_api_users_list.py 2024-12-17 13:48:18 +01:00
renovate[bot] bcdc57905b ⬆️(dependencies) update python dependencies 2024-12-17 12:12:57 +01:00
Marie PUPO JEAMMET 30832a20e1 ⬆️(dimail) upgrade dimail to 0.0.20
upgrade to 0.0.20 to fix weird anonymous admin behaviour when database
not initialized.
2024-12-17 11:48:52 +01:00
Sabrina Demagny a021e9eec6 🐛(maildomain) fix domain access creation
During a new domain creation, a call to dimail is made
to create user/allow on dimail side before owner role creation
on our side.
So when user/allow creation on dimain side fails,
the owner role is not created on our side.
Therefore the domain is created but invisible on the user interface.
The user will probably try to create the same domain again
and see the error message 'this domain already exists'.
To avoid this we make sure to create owner role on our side
despite dimail failure and set domain to failed status to retry
later dimail access creation.
2024-12-17 10:56:44 +01:00
Quentin BEY fa80edfaa8 🔧(helm) add organization name from siret plugin
This declares the use of the `NameFromSiretOrganizationPlugin`
to allow automatic SIRET -> Name guess when creating an
organization
2024-12-16 16:08:08 +01:00
Quentin BEY 38a5f158b5 (organizations) add siret to name conversion
This adds the plugin system to easily manage
Organization related customizations. This first
plugin tries (best effort) to get a proper name
for the Organization, using its SIRET. This
is French specificities but another plugin can
be defined for other cases.
2024-12-16 16:08:08 +01:00
Quentin BEY 6e14c2e61f (e2e) fix flaky test
The URL may or may not include the "page" query parameter.
2024-12-16 15:51:25 +01:00
Quentin BEY dd9a905dc0 📝(tilt) add startup guide to Tilt use
This provides an "how-to" to deploy a local dev
Kubernetes environment.
2024-12-16 12:35:48 +01:00
Quentin BEY 2f380b49d0 🧑‍💻(dimail) allow account populate from DB
Allow the dimail account creation command to create
accounts from the data in people's database.
2024-12-16 12:18:18 +01:00
Quentin BEY 9953dd2111 💄(frontend) redirect home according to abilities
We try to detect the landing page according to user
permissions (abilities) instead of just the configuration
setting.
This will be improved when the homepage is developed
2024-12-16 11:43:46 +01:00
Sabrina Demagny 5d84e226b7 (maildomain_access) add API endpoint to search users
Add new API endpoint to search for new users
to whom we can assign new roles.
2024-12-13 16:27:48 +01:00
Quentin BEY 6fde76fb46 (contacts) add "abilities" to API endpoint data
Returns the possible actions to the frontend using the
model's `get_abilities`.
2024-12-13 11:52:10 +01:00
Quentin BEY 7154a491f4 ♻️(contacts) switch API to get_abilities
Use the common way to define permissions on the API.

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

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

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

Contact:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

💚(ci) preserve server logs

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

💚(ci) move back to 1 worker on CI

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

💚(ci) improve E2E tests

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

💚(ci) make dummy data creation more robust

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

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

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

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

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

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

* (frontend) adapt tests

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

Closes issue #293

♻️(frontend) improve general error catching

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

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

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

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

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

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

How:

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

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

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

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

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

Important:

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

References:

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

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

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

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

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

Token introspection will be explained in upcoming commits.

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

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

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

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

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

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

Additionally, the current implementation is tightly coupled with joserfc.

This lays the foundation for further improvements.

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

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

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

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

Please find the benchmark:

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

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

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

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

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

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

The issue originated from the third party 'easy_thumbnail'.
Please refer to the issue #641 on Django's repo. This is
the suggested workaround by @Miketsukami.
2024-08-28 12:21:02 +02:00
renovate[bot] e4dd3395bb ⬆️(dependencies) update python dependencies 2024-08-28 12:21:02 +02:00
Marie PUPO JEAMMET 93681b0030 (mailboxes) remove '+' from valid special caracters
We previously accepted '+' as a special caracter during mailbox creation.
We now remove it, as this caracter has a very special meaning and it wouldn't
make sense to create a mail using it.
2024-08-28 11:17:19 +02:00
Marie PUPO JEAMMET e04a994d37 👽️(mailboxes) fix mailbox creation after dimail api improvement
Latest dimail modification lead to a bug in our app, preventing mailbox creation
from working properly. I swapped old dimail url to new one, mirrored dimail
modification and fixed tests and tada!
2024-08-28 11:17:19 +02:00
Sabrina Demagny ba46d7de54 (users) improve user display on admin users list
If user email exists, display it instead of sub to identify
users in admin view list.
2024-08-28 11:00:34 +02:00
Sabrina Demagny b79b4b1853 (domains) manage domain roles on user admin view
Allow to manage mail domain roles on user admin interface
2024-08-28 11:00:34 +02:00
Laurent Bossavit e3f8633931 (test) fix flaky search test
By making this email address invariant, we remove failures due to FactoryBoy's
random address being considered as a match to our test query
(and hence returning unexpected number of matches).
2024-08-28 10:47:19 +02:00
Anthony LC b84e8b89f7 🔧(helm) deactivate teams feature on productions env
Teams feature is not ready for production yet,
so we need to deactivate it on productions environment.
preprod should be a copy of production,
so we need to deactivate it on preprod too.
2024-08-21 15:13:12 +02:00
Anthony LC 32889b9e8c ♻️(frontend) replace env NEXT_PUBLIC_FEATURE_TEAM
NEXT_PUBLIC_FEATURE_TEAM is a buid-time env
variable, it is not easy to overload it per
environment.
We will use the config endpoint to get the
feature flag at runtime.
To do so, we are using the ConfigStore.
2024-08-21 15:13:12 +02:00
Anthony LC 75647fe289 (frontend) add config provider
Add a ConfigProvider to the frontend to provide
configuration to the app.
The configuration is loaded from the config
endpoint and stored in a zustand store.
2024-08-21 15:13:12 +02:00
Anthony LC 03bfef6061 (backend) add public endpoint /api/v1.0/config/
Add public endpoint /api/v1.0/config/ to
share some public configuration values
with the frontend.
2024-08-21 15:13:12 +02:00
daproclaima 85c789bb1a 🔖(patch) patch release to 1.0.1
- upgrade semver for all package.json, env.d files,
and pyproject.toml
- update CHANGELOG.md
2024-08-20 10:03:11 +02:00
daproclaima 049d8695be ️(frontend) updates links
- remove buttons inside links in panels and
teams index
- update component and e2e tests
2024-08-19 16:04:57 +02:00
daproclaima 49c238155c (frontend) user can add mail domain
- user can add an externally created mail domain
from UI and see the mail domain status on mail
domain page and left panel links.
- user can not create mailboxes to domain if mail
domain status is not equal to `enabled`
- update related tests and translations
2024-08-19 16:04:57 +02:00
daproclaima b79725acbe 💬(frontend) change text in mail domains list
- use more meaningful message
- update translations and e2e tests
2024-08-19 16:04:57 +02:00
Marie PUPO JEAMMET 439ddb9d4a 🔖(major) major release to 1.0.0
🎉! For changelog, see changelog.md
2024-08-09 15:21:42 +02:00
Sabrina Demagny a7a923e790 (mailboxes) manage bad secret sent to dimail API
- manage 403 returned by dimail API when mail domain secret is not valid
- improve some tests
- improve MailboxFactory to mock success for dimail API POST call
- override 403.html to return a nice failing error in django admin
- an error message is displayed on mailbox creation form of frontend
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET 5ed63fc091 (test) add test list mailboxes non existing domain
Test that API raises a 404 when trying to list mailboxes
of a domain that does not exist.
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET f55cb3a813 (mailboxes) add mail provisioning api integration
We want people to create new mailboxes in La Régie.
This commit adds integration with intermediary dimail-api,
which will in turn send our email creation request to Open-Xchange.
2024-08-09 13:37:20 +02:00
Marie PUPO JEAMMET 2c82f38c59 🗃️(domains) add "secret" field to domains
add a "secret" field to domain model. This secret will be used as
password in mail provisioning API.
2024-08-09 13:37:20 +02:00
Sabrina Demagny 8963f0bb3d (mail) add status on domain create or retrieve API
to display status on frontend
2024-08-08 23:47:49 +02:00
Anthony LC 733a1d8861 🔥(frontend) remove temporarily team feature
We want to make a first realease, but the
team feature is not ready yet.
So we will hide it for now by hiding the menu.
We will still let the feature in dev environment.
2024-08-08 16:29:06 +02:00
daproclaima 7916f7d7d0 (frontend) show names in mailbox table
- show a concatenation of first and last names
for each row in mailbox table
- update related e2e tests
2024-08-08 13:53:07 +02:00
daproclaima 45dbdd6c4c (frontend) restrict mailbox creation
- update mailbox creation feature by introducing the use of
new mail domain ability field to hide or show
mailbox creation button
- update related e2e tests
2024-08-08 13:53:07 +02:00
348 changed files with 30094 additions and 13103 deletions
+55 -2
View File
@@ -1,4 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -15,6 +16,44 @@ env:
DOCKER_USER: 1001:127
jobs:
trivy-scan:
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: 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:
@@ -48,9 +87,16 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$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
@@ -88,13 +134,20 @@ jobs:
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
with:
name: "version.json"
json: '{"source":"${{github.repository}}", "version":"${{github.ref_name}}", "commit":"${{github.sha}}", "build": "NA"}'
dir: 'src/frontend/apps/desk'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$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
+22
View File
@@ -0,0 +1,22 @@
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"
+39 -86
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,10 +147,15 @@ 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
- name: Set services env variables
run: |
make create-env-files
@@ -175,7 +180,7 @@ jobs:
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
- name: Build and Start Docker Servers
env:
DOCKER_BUILDKIT: 1
@@ -183,7 +188,7 @@ jobs:
run: |
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make run
- name: Apply DRF migrations
run: |
make migrate
@@ -192,19 +197,43 @@ jobs:
run: |
make demo FLUSH_ARGS='--no-input'
- name: Setup Dimail DB
run: |
make dimail-setup-db
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install
- 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 +255,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
@@ -306,79 +335,3 @@ jobs:
run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
i18n-crowdin:
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: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Download sources from Crowdin to stay synchronized
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin download sources -c /app/crowdin/config.yml
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml
-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
+11
View File
@@ -0,0 +1,11 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
+233
View File
@@ -7,3 +7,236 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.9.0] - 2024-12-17
### Fixed
- 🐛(backend) fix manage roles on domain admin view
### Added
- ✨(frontend) disable mailbox and allow to create pending mailbox
- ✨(organizations) add siret to name conversion #584
- 💄(frontend) redirect home according to abilities #588
- ✨(maildomain_access) add API endpoint to search users #508
## [1.8.0] - 2024-12-12
### Added
- ✨(contacts) add "abilities" to API endpoint data #585
- ✨(contacts) allow filter list API with email
- ✨(contacts) return profile contact from same organization
- ✨(dimail) automate allows requests to dimail
- ✨(contacts) add notes & force full_name #565
### Changed
- ♻️(contacts) move user profile to contact #572
- ♻️(contacts) split api test module in actions #573
### Fixed
- 🐛(backend) fix manage roles on domain admin view
- 🐛(mailbox) fix activate mailbox feature
- 🔧(helm) fix the configuration environment #579
## [1.7.1] - 2024-11-28
## [1.7.0] - 2024-11-28
### Added
- ✨(mailbox) allow to activate mailbox
- ✨(mailbox) allow to disable mailbox
- ✨(backend) add ServiceProvider #522
- 💄(admin) allow header color customization #552
- ✨(organization) add API endpoints #551
### Fixed
- 🐛(admin) add organization on user #555
## [1.6.1] - 2024-11-22
### Fixed
- 🩹(mailbox) fix status of current mailboxes
- 🚑️(backend) fix claim contains non-user field #548
## [1.6.0] - 2024-11-20
### Removed
- 🔥(teams) remove search by trigram for team access and contact
### Added
- ✨(domains) allow creation of "pending" mailboxes
- ✨(teams) allow team management for team admins/owners #509
### Fixed
- 🔊(backend) update logger config to info #542
## [1.5.0] - 2024-11-14
### Removed
- ⬆️(dependencies) remove unneeded dependencies
- 🔥(teams) remove pagination of teams listing
- 🔥(teams) remove search users by trigram
- 🗃️(teams) remove `slug` field
### Added
- ✨(dimail) send domain creation requests to dimail #454
- ✨(dimail) synchronize mailboxes from dimail to our db #453
- ✨(ci) add security scan #429
- ✨(teams) register contacts on admin views
### Fixed
- 🐛(mail) fix display button on outlook
- 💚(ci) improve E2E tests #492
- 🔧(sentry) restore default integrations
- 🔇(backend) remove Sentry duplicated warning/errors
- 👷(ci) add sharding e2e tests #467
- 🐛(dimail) fix unexpected status_code for proper debug #454
## [1.4.1] - 2024-10-23
### Fixed
- 🚑️(frontend) fix MailDomainsLayout
## [1.4.0] - 2024-10-23
### Added
- ✨(frontend) add tabs inside #466
### Fixed
- ✏️(mail) fix typo into mailbox creation email
- 🐛(sentry) fix duplicated sentry errors #479
- 🐛(script) improve and fix release script
## [1.3.1] - 2024-10-18
## [1.3.0] - 2024-10-18
### Added
- ✨(api) add RELEASE version on config endpoint #459
- ✨(backend) manage roles on domain admin view
- ✨(frontend) show version number in footer #369
- 👔(backend) add Organization model
### Changed
- 🛂(backend) match email if no existing user matches the sub
### Fixed
- 💄(mail) improve mailbox creation email #462
- 🐛(frontend) fix update accesses form #448
- 🛂(backend) do not duplicate user when disabled
## [1.2.1] - 2024-10-03
### Fixed
- 🔧(mail) use new scaleway email gateway #435
## [1.2.0] - 2024-09-30
### Added
- ✨(ci) add helmfile linter and fix argocd sync #424
- ✨(domains) add endpoint to list and retrieve domain accesses #404
- 🍱(dev) embark dimail-api as container #366
- ✨(dimail) allow la regie to request a token for another user #416
- ✨(frontend) show username on AccountDropDown #412
- 🥅(frontend) improve add & update group forms error handling #387
- ✨(frontend) allow group members filtering #363
- ✨(mailbox) send new mailbox confirmation email #397
- ✨(domains) domain accesses update API #423
- ✨(backend) domain accesses create API #428
- 🥅(frontend) catch new errors on mailbox creation #392
- ✨(api) domain accesses delete API #433
- ✨(frontend) add mail domain access management #413
### Fixed
- ♿️(frontend) fix left nav panel #396
- 🔧(backend) fix configuration to avoid different ssl warning #432
### Changed
- ♻️(serializers) move business logic to serializers #414
## [1.1.0] - 2024-09-10
### Added
- 📈(monitoring) configure sentry monitoring #378
- 🥅(frontend) improve api error handling #355
### Changed
- 🗃️(models) move dimail 'secret' to settings #372
### Fixed
- 🐛(dimail) improve handling of dimail errors on failed mailbox creation #377
- 💬(frontend) fix group member removal text #382
- 💬(frontend) fix add mail domain text #382
- 🐛(frontend) fix keyboard navigation #379
- 🐛(frontend) fix add mail domain form submission #355
## [1.0.2] - 2024-08-30
### Added
- 🔧Runtime config for the frontend (#345)
- 🔧(helm) configure resource server in staging
### Fixed
- 👽️(mailboxes) fix mailbox creation after dimail api improvement (#360)
## [1.0.1] - 2024-08-19
### Fixed
- ✨(frontend) user can add mail domains
## [1.0.0] - 2024-08-09
### Added
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.9.0...main
[1.9.0]: https://github.com/numerique-gouv/people/releases/v1.9.0
[1.8.0]: https://github.com/numerique-gouv/people/releases/v1.8.0
[1.7.1]: https://github.com/numerique-gouv/people/releases/v1.7.1
[1.7.0]: https://github.com/numerique-gouv/people/releases/v1.7.0
[1.6.1]: https://github.com/numerique-gouv/people/releases/v1.6.1
[1.6.0]: https://github.com/numerique-gouv/people/releases/v1.6.0
[1.5.0]: https://github.com/numerique-gouv/people/releases/v1.5.0
[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
+25 -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,12 @@ 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
# Un-privileged user running the application
ARG DOCKER_USER
@@ -61,7 +60,7 @@ CMD ["nginx", "-g", "daemon off;"]
# ---- Back-end builder image ----
FROM base as back-builder
FROM base AS back-builder
WORKDIR /builder
@@ -73,7 +72,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 as mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
@@ -84,15 +83,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 +108,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 +143,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 +169,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
+18
View File
@@ -88,6 +88,7 @@ bootstrap: \
back-i18n-compile \
mails-install \
mails-build \
dimail-setup-db \
install-front-desk
.PHONY: bootstrap
@@ -109,6 +110,7 @@ run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
@@ -129,6 +131,7 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \
@@ -272,6 +275,17 @@ i18n-generate-and-upload: \
crowdin-upload
.PHONY: i18n-generate-and-upload
# -- INTEROPERABILTY
# -- Dimail configuration
recreate-dimail-container:
@$(COMPOSE) up --force-recreate -d dimail
.PHONY: recreate-dimail-container
dimail-setup-db:
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
@$(MANAGE) setup_dimail_db
.PHONY: dimail-setup-db
# -- Mail generator
@@ -343,3 +357,7 @@ start-kind: ## Create the kubernetes cluster
tilt-up: ## start tilt - k8s local development
tilt up -f ./bin/Tiltfile
.PHONY: tilt-up
release: ## helper for release and deployment
python scripts/release.py
.PHONY: release
+25 -18
View File
@@ -1,12 +1,12 @@
# People
People is an application to handle users and teams.
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
As of today, this project is **not yet ready for production**. Expect breaking changes.
People is built on top of [Django Rest
It is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/).
All interoperabilities will be described in `docs/interoperability`.
## Getting started
### Prerequisite
@@ -25,7 +25,7 @@ $ docker compose -v
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
### Project bootstrap
### Bootstrap project
The easiest way to start working on the project is to use GNU Make:
@@ -38,7 +38,7 @@ database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
Your Docker services should now be up and running! 🎉
Note that if you need to run them afterward, you can use the eponym Make rule:
@@ -46,13 +46,7 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
$ make run
```
### Adding content
You can create a basic demo site by running:
$ make demo
Finally, you can check all available Make rules using:
You can check all available Make rules using:
```bash
$ make help
@@ -71,6 +65,23 @@ $ make superuser
You can then login with sub `admin` and password `admin`.
### Adding demo content
You can create a basic demo site by running:
```bash
$ make demo
```
### Setting dimail database
To ease local development when working on interoperability between people and dimail, we embark dimail-api in a container running in "fake" mode.
To populate dimail local database with users/domains/permissions needed for basic development:
- log in with "people" user
- run `make dimail-setup-db`
### Run frontend
Run the front with:
@@ -79,14 +90,10 @@ Run the front with:
$ make run-front-desk
```
Then access at
[http://localhost:3000](http://localhost:3000)
Then access [http://localhost:3000](http://localhost:3000) with :
user: people
password: people
## Contributing
This project is intended to be community-driven, so please, do not hesitate to
-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}"
}
Regular → Executable
View File
-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 "$@"
+11 -18
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
postgresql:
image: postgres:16
@@ -37,6 +35,7 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- dimail
- postgresql
- mailcatcher
- redis
@@ -118,22 +117,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,3 +151,13 @@ services:
- "8080:8080"
depends_on:
- kc_postgresql
dimail:
entrypoint: /opt/dimail-api/start-dev.sh
image: registry.mim-libre.fr/dimail/dimail-api:v0.0.20
pull_policy: always
environment:
DIMAIL_MODE: FAKE
DIMAIL_JWT_SECRET: fake_jwt_secret
ports:
- "8001:8000"
+210
View File
@@ -99,6 +99,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": {
+45
View File
@@ -0,0 +1,45 @@
# dimail
## What is dimail ?
The mailing solution provided in La Suite is [Open-XChange](https://www.open-xchange.com/) (OX).
OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.fr/docs#/).
## Architectural links of dimail
As dimail's primary goal is to act as an interface between People and OX, its architecture is similar to that of People. A series of requests are sent from People to dimail upon creating domains, users, permissions and mailboxes.
### Domains
Upon creating a domain on People, the same domain is created on dimail and will undergo a series of checks. When all checks have passed, the domain is considered enabled.
Domains in OX have a field called "context". Context is a shared space between domains, allowing users to discover users not only on their domain but on their entire context.
### Mailboxes
Mailboxes can be created by a domain owners or administrators in People's domain tab.
On enabled domains, mailboxes are created at the same time on dimail (and a confirmation email is sent to the secondary email).
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's activation.
On disabled domains, mailboxes creation is not allowed.
### Users
The ones issuing requests. Dimail users will reflect domains owners and administrators on People, for logging purposes and to allow direct use of dimail, if desired. User reconciliation is made on user uuid provided by ProConnect.
### Permissions
As for People, an access - a permissions (or an "allow" in dimail) - grants an user permission to create objects on a domain.
Permissions requests are sent automatically upon :
- dimail database initialisation:
+ permission for dimail user People to create users and domains
- domain creation :
+ permission for dimail user People to manage domain
+ permission for new owner on new domain
- user creation:
+ permission for People to manage user
- access creation, if owner or admin:
+ permission for this user to manage domain
+115
View File
@@ -0,0 +1,115 @@
# 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
```
### Deploy the application
```bash
tilt up -f ./bin/Tiltfile
```
**or** run the equivalent using the makefile
```bash
make tilt-up
```
That's it! You should now have a local development environment for Kubernetes.
You can access the application at https://desk.127.0.0.1.nip.io
## Management
To manage the cluster, you can use k9s.
## Next steps
- Add dimail to the local development environment
- Add a reset demo `cmd_button` to Tilt
+103 -73
View File
@@ -6,16 +6,56 @@ interact efficiently.
Let's see how we could interact with Django's shell to recreate David's environment in the app.
## Base contacts from the organization records
## Profile contact
David Bowman is an exemplary employee at Space Odyssey Corporation. His email is
`david.bowman@spaceodyssey.com` and he is registered in the organization's records via a base
contact as follows:
The system identifies Dave through the `sub` associated with his OIDC session.
The OIDC should also provide his email address.
Note: as some Identity Providers do not provide a constant `sub`, the system should be able to
fallback to the email address to find it the user is already in the database.
When Dave logs in for the first time, the system checks if he is already in the database. If not
a new user is created for him.
Users needs to be linked to an Organization (see [organizations.md](./organizations.md)).
```python
david_base_contact = Contact.objects.create(
space_odyssey, _created = Organization.objects.get_or_create(...)
david_user = User.objects.create(
organization=space_odyssey,
language="en-us",
timezone="America/Los_Angeles",
name="David Bowman",
sub="1234567890",
email="david.bowman@spaceodyssey.com",
)
```
The system then creates a profile contact for Dave, based on the information provided by the OIDC:
```python
david_profile_contact = Contact.objects.create(
owner=david_user,
user=david_user, # this means it's the user's profile contact
full_name="David Bowman",
short_name="David",
data={
"emails": [
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
],
},
)
```
**Future feature:**
Dave will be prompted to confirm the details of the base contact stored in the database
and fill the details for a better profile.
His profile contact will be updated with the new information.
```python
Contact.objects.filter(user=david_user, owner=david_user).update(
short_name="Dave",
data={
"emails": [
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
@@ -48,91 +88,81 @@ david_base_contact = Contact.objects.create(
)
```
When David logs-in to the People application for the first time using the corporation's OIDC
Single Sign-On service. A user is created for him on the fly by the system, together with an
identity record representing the OIDC session:
David's profile contact is available to all users of his company/organization.
```python
david_user = User.objects.create(
language="en-us",
timezone="America/Los_Angeles",
)
david_identity = Identity.objects.create(
"user": david_user,
"sub": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"email" : "david.bowman@spaceodyssey.com",
"is_main": True,
)
```
## Profile contact
The system identifies Dave through the email associated with his OIDC session and prompts him to
confirm the details of the base contact stored in the database.
When David confirms, giving an alternative short name that he prefers, a contact override is
created on top of the organization's base contact. This new contact is marked as David's profile
on the user:
```python
david_contact = Contact.objects.create(
base=david_base_contact,
owner=david_user,
full_name="David Bowman",
short_name="Dave",
data={}
)
david_user.profile_contact = david_contact
david_user.save()
```
If Dave had not had any existing contact in the organization's records, the profile contact would
have been created independently, without any connection to a base contact:
```python
david_contact = Contact.objects.create(
base=None,
owner=david_user,
full_name="David Bowman",
short_name="Dave",
data={}
)
```
Now, Dave feels like sharing his mobile phone number with his colleagues. He can do this
by editing his contact in the application:
```python
contact.data["phones"] = [
{"type": "Mobile", "value": "(123) 456-7890"},
]
contact.save()
```
## Contact override
During a Space conference he attended, Dave met Dr Ryan Stone, a medical engineer who gave him
her professional email address. Ryan is already present in the system but her email is missing.
her personal email address (because she will quit the company she works for). Ryan is already
present in the system.
Dave can add it to his private version of the contact:
```python
ryan_base_contact = Contact.objects.create(
full_name="Ryan Stone",
data={}
ryan_user = User.objects.create(
organization=space_odyssey,
language="en-us",
timezone="America/Los_Angeles",
name="Ryan Stone",
sub="0987654321",
email="ryan.stone@spaceodyssey.com",
)
ryan_contact = Contact.objects.create(
base=ryan_base_contact,
owner=david_user,
ryan_profile_contact = Contact.objects.create(
user=ryan_user,
owner=ryan_user,
full_name="Ryan Stone",
short_name="Dr Ryan",
data={
"emails": [
{"type": "Work", "value": "ryan.stone@hubblestation.com"},
],
}
},
)
david_ryan_contact = Contact.objects.create(
override=ryan_profile_contact,
owner=david_user,
full_name="Ryan Stone",
short_name="Dr Ryan",
data={
"emails": [
{"type": "Home", "value": "ryan@stone.com"},
],
},
)
```
## Personal contacts
Dave met a lot of people during the last Opensource Experience event. He can add them to his personal contacts:
```python
julie_personal_contact = Contact.objects.create(
owner=david_user,
full_name="Julie Powell",
short_name="Julie",
data={
"emails": [
{"type": "Work", "value": "julie.powell@example.com"},
],
"phones": [
{"type": "Work", "value": "(123) 456-7890"},
],
},
)
```
These contacts are only available to Dave.
## Contacts from shared directories
This is a future feature that will allow Dave to access contacts from shared directories.
Shared directories will provide contacts for all users from several organizations.
## Team Collaboration
Dave wants to form a team with Ryan and other colleagues to work together better on using the organization's digital tools for their projects.
+41
View File
@@ -0,0 +1,41 @@
# Organization model
## Purpose
The initial `Organization` model allows to regroup `User` and `Team` under a same object.
While the purpose was purely technical in the first place, few features emerged afterward.
## Link with the `User` model
Each user must have a "default" organization.
When a user logs in:
- The backend tries to get an existing organization from a "registration ID" or from the user's mail domain
- If the organization does not exist, it is created using the "registration ID" and fallbacks on the user's mail domain
- The user organization is set to this organization.
**Note:** While deploying the new code, we allow the already existing user to not have one.
## Link with the `Team` model
Each team must have an organization. This organization is not defined by the user, but automatically set
using the user's organization.
The team's organization does not restrict the users who can be added to the team.
There is currently no feature relying on the team organization.
**Note:** While deploying the new code, we allow the teams to not have one.
## Organization-related features
### Organization UI
A new interface can be created to allow users to see all members of an organization.
This could be used along with the contacts.
+164
View File
@@ -0,0 +1,164 @@
## Resource Server
For detailed information, please refer to the [OAuth 2.0 Resource Server documentation](https://www.oauth.com/oauth2-servers/the-resource-server/) and review the relevant commits that implement the resource server backend.
---
#### Overview
A resource server is a crucial component in an OAuth 2.0 ecosystem. It is responsible for accepting access tokens issued by the authorization server (in this case, Agent Connect), introspecting and validating those tokens, and then returning the requested protected resources to the client.
By implementing a resource server, we can securely share data between different services within La Suite. This ensures that only clients with the appropriate scopes and permissions can access specific resources, thereby enhancing security and maintaining proper access control across services.
---
#### Disclaimer
- Currently compatible only with Agent Connect.
- The development setup requires simplification, with dependencies on Agent Connect ideally mocked.
- Terminology aligns with the specification: what is referred to as a "resource server" is known as a "data provider" in Agent Connect.
- This documentation is WIP.
---
## Running Locally
#### Prerequisites
- **Agent Connect Stack**: Ensure the local Agent Connect stack is running. A solid understanding of its configuration and operation is recommended (advanced level).
- **People Stack**: Make sure the People stack is up and running.
- **Ngrok**: Install and set up Ngrok for secure tunneling.
---
### Update People's configurations
#### Environment variables
Agent Connect includes two pre-configured mocked data providers in its default stack (`bdd-fca-low`).
Use the client ID and client secret from one of these data providers. **Note:** Make sure to retrieve the decrypted secret, as it is stored encrypted in the database. You can find these values in the `dp.js` fixture file, where they are exposed in a comment.
Configure your environment with the following values from Agent Connect:
```
OIDC_RS_CLIENT_ID=<your-client-id-from-ac>
OIDC_RS_CLIENT_SECRET=<your-decrypted-client-secret-from-ac>
# In development, the resource server use insecure settings
OIDC_VERIFY_SSL=False
# Update the endpoints as follows
OIDC_OP_JWKS_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/jwks
OIDC_OP_INTROSPECTION_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/checktoken
OIDC_OP_URL=https://core-fca-low.docker.dev-franceconnect.fr/api/v2
```
#### Docker Network Configuration
To enable communication between the Docker networks for People and Agent Connect, update your docker-compose configuration. This setup is required because the Authorization Server and Resource Server will exchange requests over a back-channel, necessitating their accessibility to each other.
1. **Create a Network**: Define a new network to bridge the two Docker networks.
```yaml
networks:
authorization_server:
external: true
driver: bridge
name: "${DESK_NETWORK:-fc_public}"
```
2. **Update Network for `app-dev`**: Ensure your `app-dev` service is connected to both the default network and the new `authorization_server` network.
```yaml
app-dev:
...
networks:
- default
- authorization_server
```
#### Ngrok
To expose your local resource server through an HTTP tunnel, use Ngrok. This is necessary because, in the Agent Connect development stack, the resource server needs to be accessible to a user agent via a publicly accessible URL.
```
$ ngrok http 8071
```
---
### Update AgentConnect's configurations
Modify the AgentConnect configuration to include the local resource server settings.
**Update `fsa1-low` Environment File**:
Add your Ngrok URL and configure the `DATA_APIS` list with the appropriate values.
```env
App_DATA_APIS=[{"name":"Data Provider 1","url":"https://your-ngrok-url/api/v1.0/any-path","secret":"***"}, ...]
```
**Update Fixture in `dp.js`**:
Adjust the configuration for the data provider to match your local setup. Ensure that the `client_id`, `client_secret`, and other parameters are correctly set and aligned with your environment. This can be configured through environment variables.
```javascript
const dps = [
// Data Provider Configuration
{
uid: "6f21b751-ed06-48b6-a59c-36e1300a368a",
title: "Mock Data Provider - 1",
active: true,
slug: "DESK",
client_id: "***",
client_secret: "***",
// client_secret decrypted : ****
jwks_uri: "https://your-ngrok-url/api/v1.0/jwks", // Update this line
checktoken_signed_response_alg: "ES256",
checktoken_encrypted_response_alg: "RSA-OAEP",
checktoken_encrypted_response_enc: "A256GCM",
},
];
```
**Note**: Ensure that the `jwks_uri` and other cryptographic parameters (e.g., `checktoken_signed_response_alg`, `checktoken_encrypted_response_alg`, and `checktoken_encrypted_response_enc`) match your actual setup and are configured via environment variables where necessary.
---
### Usage
This section is a work in progress. Please note the following important points:
#### User `sub` Matching
Ensure that the `sub` (subject) field for users in AgentConnect matches the corresponding value in the People database. To synchronize this, you can run `make demo`, then edit the user's `sub` field to match the value returned by AgentConnect. For this, you'll need to update the editable field in Django Admin, specifically in `admin.py`. Adjust the `get_readonly_fields` method as follows:
```python
def get_readonly_fields(self, request, obj=None):
"""The 'sub' field should only be editable during creation, not for updates."""
if obj:
return self.readonly_fields
return self.readonly_fields + ["sub"] # update this line adding 'sub'
```
#### Scope for `groups`
Ensure that the `groups` scope is requested from the service provider during authentication with AgentConnect.
#### Resource Server Requests
By default, the `fsa1-low` environment calls the resource server using a POST request.
#### Testing
Most of the testing has been done using the `/users/me` endpoint. Update the `api/viewset.py` configuration to allow both GET and POST methods for this endpoint:
```python
@decorators.action(
detail=False,
methods=["get", "post"], # update this line adding 'post'
url_name="me",
url_path="me",
)
```
+28
View File
@@ -0,0 +1,28 @@
# ServiceProvider model
## Purpose
The `ServiceProvider` model represents a ... service provider, also known as "tools using some data from this project".
## Link with the `Organization` model
An organization can be linked to several service providers.
The goal here, is to allow users of an organization to have access, to a service provider or not.
The first implementation does not have any feature related, but the first feature will probably be
to list applications visible in the "all application menu" (aka "la gaufre").
## Link with the `Team` model
A team can be linked to several service providers.
This is used as a filter when a service provider calls the resource server, only the teams linked to
this service provider are returned. This is mandatory for data segregation: we don't want all service
providers to be able to list all data regarding other service providers.
## Limitations
There is currently no way to provision all the service providers automatically. So when a service provider
creates a team via the resource server, we create the `ServiceProvider` on the fly, without any understandable name.
This will need to be improved later.
+51
View File
@@ -0,0 +1,51 @@
# E2E tests
## Run E2E tests
``` bash
# you need the dockers to be up and running
make bootstrap
# you will need to have few accounts in the database
make demo FLUSH_ARGS='--no-input'
# run the tests
cd src/frontend/apps/e2e
yarn test:ui --workers=1
```
A new browser window will open and you will be able to run the tests.
## Available accounts
The `make demo` command creates the following accounts:
- `e2e.team-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
this account only belong to a team with the specified role.
- `e2e.mail-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
this account only have a mailbox with the specified role access.
- `e2e.team-<team_role>-mail-<domain_role>@example.com` with a combination of roles as for the
previous accounts.
For each account, the password is `password-e2e.<role>`, for instance `password-e2e.team-member`.
In the E2E tests you can use these accounts to benefit from there accesses,
using the `keyCloakSignIn(page, browserName, <account_name>)`. The account name is the
username without the prefix `e2e.`.
``` typescript jsx
await keyCloakSignIn(page, browserName, 'mail-owner');
```
The `keyCloakSignIn` function will sign in the user on Keycloak using the proper username and password.
.. note::
This only works because the OIDC setting is set to fallback on user email.
## Add a new account
In case you need to add a new account for specific tests you need:
- to create a new user with the same format in the backend database:
update `[create_demo.py](../src/backend/demo/management/commands/create_demo.py)`
- to create a new account in Keycloak: update [realm.json](../docker/auth/realm.json)
- if the keycloak was running locally, you need to destroy its database and
restart the database and the keycloak containers.
+32
View File
@@ -33,3 +33,35 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
OIDC_RS_CLIENT_ID=people
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RS_PRIVATE_KEY_STR="-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
MbyqKyC6DAzv4jEEhHaN7oY=
-----END PRIVATE KEY-----
"
+1 -7
View File
@@ -13,13 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"fetch-mock",
"node",
"node-fetch",
"i18next-parser",
"eslint"
]
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
}
]
}
+120
View File
@@ -0,0 +1,120 @@
# pylint: disable=line-too-long
import datetime
import os
import re
import sys
from utils import run_command
RELEASE_KINDS = {'p': 'patch', 'm': 'minor', 'mj': 'major'}
def update_files(version):
"""Update all files needed with new release version"""
# pyproject.toml
sys.stdout.write("Update pyproject.toml...\n")
path = "src/backend/pyproject.toml"
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if line.startswith("version = "):
lines[index] = re.sub(r'\"(.*?)\"', f'"{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
# 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 = []
filename = "package.json"
for root, _dir, files in os.walk("src/frontend"):
if filename in files and "node_modules" not in root and ".next" not in root:
files_to_modify.append(os.path.join(root, filename))
for path in files_to_modify:
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "version" in line:
lines[index] = re.sub(r'"version": \"(.*?)\"', f'"version": "{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
return
def update_changelog(path, version):
"""Update changelog file with release info
"""
sys.stdout.write("Update CHANGELOG...\n")
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "## [Unreleased]" in line:
today = datetime.date.today()
lines.insert(index + 1, f"\n## [{version}] - {today}\n")
if line.startswith("[unreleased]"):
last_version = lines[index + 1].split("]")[0][1:]
new_unreleased_line = line.replace(last_version, version)
new_release_line = lines[index + 1].replace(last_version, version)
lines[index] = new_unreleased_line
lines.insert(index + 1, new_release_line)
break
with open(path, 'w+') as file:
file.writelines(lines)
def prepare_release(version, kind):
sys.stdout.write('Let\'s go to create branch to release\n')
branch_to_release = f"release/{version}"
run_command(f"git checkout -b {branch_to_release}", shell=True)
run_command("git pull --rebase origin main", shell=True)
update_changelog("CHANGELOG.md", version)
update_files(version)
run_command("git add CHANGELOG.md", shell=True)
run_command("git add src/", shell=True)
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
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
--> Then do:
>> git checkout main
>> git pull
>> git tag v{version}
>> git push origin v{version}
--> Please check and wait for the docker image v{version} to be published here: https://hub.docker.com/r/lasuite/people-backend/tags
>> 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}
""")
if __name__ == "__main__":
version, kind = None, None
while not version:
version = input("Enter your release version:")
while kind not in RELEASE_KINDS:
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
prepare_release(version, kind)
+14
View File
@@ -0,0 +1,14 @@
import subprocess
import sys
def run_command(cmd, msg=None, shell=False, cwd='.', stdout=None):
if msg is None:
msg = f"# Running: {cmd}"
if stdout is not None:
stdout.write(msg + '\n')
if stdout != sys.stdout:
sys.stdout(msg)
subprocess.check_call(cmd, shell=shell, cwd=cwd, stdout=stdout, stderr=stdout)
if stdout is not None:
stdout.flush()
+1 -1
Submodule secrets updated: d7cfe7bcdc...b7ab5f1411
+2 -1
View File
@@ -363,7 +363,8 @@ name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# Ignore: private stuff and `Params` class from FactoryBoy
no-docstring-rgx=(^_|^Params$)
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
+1
View File
@@ -0,0 +1 @@
"""Root module."""
+1
View File
@@ -0,0 +1 @@
"""People custom admin site."""
+9
View File
@@ -0,0 +1,9 @@
"""Custom Django admin site application configuration."""
from django.contrib.admin.apps import AdminConfig
class PeopleAdminConfig(AdminConfig):
"""Declare our custom Django admin site."""
default_site = "admin.sites.PeopleAdminSite"
+15
View File
@@ -0,0 +1,15 @@
"""Custom Django admin site for the People app."""
from django.conf import settings
from django.contrib import admin
class PeopleAdminSite(admin.AdminSite):
"""People custom admin site."""
def each_context(self, request):
"""Add custom context to the admin site."""
return super().each_context(request) | {
"ADMIN_HEADER_BACKGROUND": settings.ADMIN_HEADER_BACKGROUND,
"ADMIN_HEADER_COLOR": settings.ADMIN_HEADER_COLOR,
}
@@ -0,0 +1,10 @@
{% 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 %}
}
</style>
{% endblock %}
+1
View File
@@ -0,0 +1 @@
"""Core root module."""
+99 -8
View File
@@ -4,6 +4,8 @@ from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from mailbox_manager.admin import MailDomainAccessInline
from . import models
@@ -16,6 +18,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."""
@@ -29,6 +40,7 @@ class TeamWebhookInline(admin.TabularInline):
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
autocomplete_fields = ["organization"]
fieldsets = (
(
None,
@@ -37,10 +49,11 @@ class UserAdmin(auth_admin.UserAdmin):
"id",
"sub",
"password",
"organization",
)
},
),
(_("Personal info"), {"fields": ("email", "language", "timezone")}),
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
(
_("Permissions"),
{
@@ -65,10 +78,10 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
)
inlines = (TeamAccessInline,)
inlines = (TeamAccessInline, MailDomainAccessInline, OrganizationAccessInline)
list_display = (
"sub",
"email",
"get_user",
"organization",
"created_at",
"updated_at",
"is_active",
@@ -79,7 +92,7 @@ class UserAdmin(auth_admin.UserAdmin):
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ["id", "created_at", "updated_at"]
search_fields = ("id", "email", "sub")
search_fields = ("id", "email", "sub", "name")
def get_readonly_fields(self, request, obj=None):
"""The sub should only be editable for a create, not for updates."""
@@ -87,15 +100,31 @@ class UserAdmin(auth_admin.UserAdmin):
return self.readonly_fields + ["sub"]
return self.readonly_fields
def get_user(self, obj):
"""Provide a nice display for user"""
return (
obj.name if obj.name else (obj.email if obj.email else f"[sub] {obj.sub}")
)
get_user.short_description = _("User")
class TeamServiceProviderInline(admin.TabularInline):
"""Inline admin class for service providers."""
can_delete = False
model = models.Team.service_providers.through
extra = 0
@admin.register(models.Team)
class TeamAdmin(admin.ModelAdmin):
"""Team admin interface declaration."""
inlines = (TeamAccessInline, TeamWebhookInline)
inlines = (TeamAccessInline, TeamWebhookInline, TeamServiceProviderInline)
exclude = ("service_providers",) # Handled by the inline
list_display = (
"name",
"slug",
"created_at",
"updated_at",
)
@@ -139,8 +168,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
@@ -153,5 +182,67 @@ 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.register(models.Organization)
class OrganizationAdmin(admin.ModelAdmin):
"""Admin interface for organizations."""
list_display = (
"name",
"created_at",
"updated_at",
)
search_fields = ("name",)
inlines = (OrganizationAccessInline, OrganizationServiceProviderInline)
exclude = ("service_providers",) # Handled by the inline
@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"]
read_only_fields = ["id", "name"]
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,7 +251,9 @@ 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
@@ -179,19 +262,33 @@ class TeamSerializer(serializers.ModelSerializer):
"name",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
"service_providers",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"slug",
"created_at",
"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 +296,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 +330,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,8 +1,7 @@
"""API endpoints"""
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.conf import settings
from django.db.models import OuterRef, Q, Subquery
from rest_framework import (
decorators,
@@ -12,12 +11,15 @@ from rest_framework import (
pagination,
response,
throttling,
views,
viewsets,
)
from rest_framework.permissions import AllowAny
from core import models
from . import permissions, serializers
from core.api import permissions
from core.api.client import serializers
from core.utils.raw_sql import gen_sql_filter_json_array
SIMILARITY_THRESHOLD = 0.04
@@ -82,16 +84,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):
@@ -127,39 +136,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)
@@ -172,21 +188,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
@@ -204,22 +263,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
@@ -235,9 +283,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(
@@ -256,14 +302,20 @@ class TeamViewSet(
ordering_fields = ["created_at"]
ordering = ["-created_at"]
queryset = models.Team.objects.all()
pagination_class = None
def get_queryset(self):
"""Custom queryset to get user related teams."""
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=OuterRef("pk")
).values("role")[:1]
return models.Team.objects.filter(accesses__user=self.request.user).annotate(
user_role=Subquery(user_role_query)
return (
models.Team.objects.prefetch_related("accesses", "service_providers")
.filter(
accesses__user=self.request.user,
)
.annotate(user_role=Subquery(user_role_query))
)
def perform_create(self, serializer):
@@ -337,6 +389,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
@@ -348,20 +401,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
@@ -491,3 +533,68 @@ class InvitationViewset(
.distinct()
)
return queryset
class ConfigView(views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = ["LANGUAGES", "FEATURES", "RELEASE", "COMMIT"]
dict_settings = {}
for setting in array_settings:
dict_settings[setting] = getattr(settings, setting)
return response.Response(dict_settings)
class 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
+2 -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):
@@ -0,0 +1 @@
"""People core resource server API endpoints"""
@@ -0,0 +1,46 @@
"""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",
"name",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"created_at",
"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,90 @@
"""Resource server API endpoints"""
from django.db.models import OuterRef, Prefetch, Subquery
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."""
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(
accesses__user=self.request.user,
service_providers__audience_id=service_provider_audience,
)
.annotate(user_role=Subquery(user_role_query))
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created team."""
team = serializer.save()
models.TeamAccess.objects.create(
team=team,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
@@ -0,0 +1 @@
"""Authentication module."""
+164 -23
View File
@@ -1,6 +1,11 @@
"""Authentication Backends for the People core app."""
import logging
from email.headerregistry import Address
from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -9,6 +14,25 @@ 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.
@@ -48,7 +72,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
"""Return a User based on userinfo. Create a new user if no match is found.
Parameters:
- access_token (str): The access token.
@@ -64,30 +88,69 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
user_info = self.get_userinfo(access_token, id_token, payload)
# Compute user name from OIDC name fields as defined in settings
names_list = [
user_info[field]
for field in settings.USER_OIDC_FIELDS_TO_NAME
if user_info.get(field)
]
user_info["name"] = " ".join(names_list) or None
sub = user_info.get("sub")
if sub is None:
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
try:
user = self.UserModel.objects.get(sub=sub)
except self.UserModel.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
else:
email = user_info.get("email")
name = user_info.get("name")
if email and email != user.email or name and name != user.name:
self.UserModel.objects.filter(sub=sub).update(email=email, name=name)
# 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 = {
"sub": sub,
"email": email,
"name": full_name,
}
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
)
# if sub is absent, try matching on email
user = self.get_existing_user(sub, email)
if user:
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(claims)
# Data cleaning, to be removed when user organization is null=False
# or all users have an organization.
# See https://github.com/numerique-gouv/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
@@ -98,10 +161,88 @@ 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."""
name_fields = settings.USER_OIDC_FIELDS_TO_NAME
full_name = " ".join(
user_info[field] for field in name_fields if user_info.get(field)
)
return full_name or None
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email)
except User.DoesNotExist:
pass
return None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
updated_claims = {}
for key in ["email", "name"]:
claim_value = claims.get(key)
if claim_value and claim_value != getattr(user, key):
updated_claims[key] = claim_value
if updated_claims:
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
+94 -2
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")
@@ -154,6 +215,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."""
@@ -186,3 +254,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)
+2 -2
View File
@@ -146,11 +146,11 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
constraint=models.CheckConstraint(condition=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
constraint=models.CheckConstraint(condition=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
),
migrations.AlterUniqueTogether(
name='contact',
@@ -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);"
)
]
+394 -38
View File
@@ -6,19 +6,22 @@ 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_lazy as _
from django.utils.translation import override
@@ -26,7 +29,9 @@ import jsonschema
from timezone_field import TimeZoneField
from core.enums import WebhookStatusChoices
from core.plugins.loader import organization_plugins_run_after_create
from core.utils.webhooks import scim_synchronizer
from core.validators import get_field_validators_from_setting
logger = getLogger(__name__)
@@ -36,7 +41,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 +49,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 +100,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 +114,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 +145,38 @@ class Contact(BaseModel):
ordering = ("full_name", "short_name")
verbose_name = _("contact")
verbose_name_plural = _("contacts")
unique_together = ("owner", "base")
unique_together = ("owner", "override")
constraints = [
models.CheckConstraint(
check=~models.Q(base__isnull=False, owner__isnull=True),
name="base_owner_constraint",
condition=~models.Q(override__isnull=False, owner__isnull=True),
name="override_owner_constraint",
violation_error_message="A contact overriding a base contact must be owned.",
),
models.CheckConstraint(
check=~models.Q(base=models.F("id")),
name="base_not_self",
violation_error_message="A contact cannot be based on itself.",
condition=~models.Q(override=models.F("id")),
name="override_not_self",
violation_error_message="A contact cannot override itself.",
),
# When a user is set, the owner must be the user
models.CheckConstraint(
condition=models.Q(user__isnull=True)
| models.Q(owner=models.F("user"), owner__isnull=False),
name="profile_contact_owner_constraint",
violation_error_message="Users can only declare as profile a contact they own.",
),
]
def __str__(self):
return self.full_name or self.short_name
return self.full_name
def clean(self):
"""Validate fields."""
super().clean()
# Check if the contact points to a base contact that itself points to another base contact
if self.base_id and self.base.base_id:
# Check if the contact overrides a contact that itself overrides another base contact
if self.override_id and self.override.override_id:
raise exceptions.ValidationError(
"A contact cannot point to a base contact that itself points to a base contact."
"A contact cannot override a contact that itself overrides a contact."
)
# Validate the content of the "data" field against our jsonschema definition
@@ -157,6 +188,234 @@ 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 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,13 +439,6 @@ 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)(),
@@ -218,6 +470,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 +507,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 +538,113 @@ 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).
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
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 and settings.FEATURES["TEAMS_DISPLAY"],
"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,
)
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 Team(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`.
"""
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100, unique=True, null=False, editable=False)
users = models.ManyToManyField(
User,
@@ -299,6 +652,18 @@ 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,
)
class Meta:
db_table = "people_team"
@@ -309,15 +674,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.
+1
View File
@@ -0,0 +1 @@
"""Core plugins package."""
+13
View File
@@ -0,0 +1,13 @@
"""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")
+32
View File
@@ -0,0 +1,32 @@
"""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)
@@ -0,0 +1 @@
"""Backend resource server module."""
@@ -0,0 +1,72 @@
"""Resource Server Authentication"""
import base64
import binascii
import logging
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from mozilla_django_oidc.contrib.drf import OIDCAuthentication
from .backend import ResourceServerBackend, ResourceServerImproperlyConfiguredBackend
from .clients import AuthorizationServerClient
logger = logging.getLogger(__name__)
class ResourceServerAuthentication(OIDCAuthentication):
"""Authenticate clients using the token received from the authorization server."""
def __init__(self):
"""Require authentication to be configured in order to instantiate."""
super().__init__()
try:
authorization_server_client = AuthorizationServerClient(
url=settings.OIDC_OP_URL,
verify_ssl=settings.OIDC_VERIFY_SSL,
timeout=settings.OIDC_TIMEOUT,
proxy=settings.OIDC_PROXY,
url_jwks=settings.OIDC_OP_JWKS_ENDPOINT,
url_introspection=settings.OIDC_OP_INTROSPECTION_ENDPOINT,
)
self.backend = ResourceServerBackend(authorization_server_client)
except ImproperlyConfigured as err:
message = "Resource Server authentication is disabled"
logger.debug("%s. Exception: %s", message, err)
self.backend = ResourceServerImproperlyConfiguredBackend()
def get_access_token(self, request):
"""Retrieve and decode the access token from the request.
This method overrides the 'get_access_token' method from the parent class,
to support service providers that would base64 encode the bearer token.
"""
access_token = super().get_access_token(request)
try:
access_token = base64.b64decode(access_token).decode("utf-8")
except (binascii.Error, TypeError):
pass
return access_token
def authenticate(self, request):
"""
Authenticate the request and return a tuple of (user, token) or None.
We override the 'authenticate' method from the parent class to store
the introspected token audience inside the request.
"""
result = super().authenticate(request) # Might raise AuthenticationFailed
if result is None: # Case when there is no access token
return None
# Note: at this stage, the request is a "drf_request" object
request.resource_server_token_audience = self.backend.token_origin_audience
return result
+240
View File
@@ -0,0 +1,240 @@
"""Resource Server Backend"""
import logging
from django.conf import settings
from django.contrib import auth
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from joserfc import jwe as jose_jwe
from joserfc import jwt as jose_jwt
from joserfc.errors import InvalidClaimError, InvalidTokenError
from requests.exceptions import HTTPError
from rest_framework.exceptions import AuthenticationFailed
from . import utils
logger = logging.getLogger(__name__)
class ResourceServerBackend:
"""Backend of an OAuth 2.0 resource server.
This backend is designed to authenticate resource owners to our API using the access token
they received from the authorization server.
In the context of OAuth 2.0, a resource server is a server that hosts protected resources and
is capable of accepting and responding to protected resource requests using access tokens.
The resource server verifies the validity of the access tokens issued by the authorization
server to ensure secure access to the resources.
For more information, visit: https://www.oauth.com/oauth2-servers/the-resource-server/
"""
# pylint: disable=too-many-instance-attributes
def __init__(self, authorization_server_client):
"""Require client_id, client_secret set and authorization_server_client provided."""
# pylint: disable=invalid-name
self.UserModel = auth.get_user_model()
self._client_id = settings.OIDC_RS_CLIENT_ID
self._client_secret = settings.OIDC_RS_CLIENT_SECRET
self._encryption_encoding = settings.OIDC_RS_ENCRYPTION_ENCODING
self._encryption_algorithm = settings.OIDC_RS_ENCRYPTION_ALGO
self._signing_algorithm = settings.OIDC_RS_SIGNING_ALGO
self._scopes = settings.OIDC_RS_SCOPES
if (
not self._client_id
or not self._client_secret
or not authorization_server_client
):
raise ImproperlyConfigured(
"Could not instantiate ResourceServerBackend, some parameters are missing."
)
self._authorization_server_client = authorization_server_client
self._claims_registry = jose_jwt.JWTClaimsRegistry(
iss={"essential": True, "value": self._authorization_server_client.url},
aud={"essential": True, "value": self._client_id},
token_introspection={"essential": True},
)
# Declare the token origin audience: to know where the token comes from
# and store it for further use in the application
self.token_origin_audience = None
# pylint: disable=unused-argument
def get_or_create_user(self, access_token, id_token, payload):
"""Maintain API compatibility with OIDCAuthentication class from mozilla-django-oidc
Params 'id_token', 'payload' won't be used, and our implementation will only
support 'get_user', not 'get_or_create_user'.
"""
return self.get_user(access_token)
def get_user(self, access_token):
"""Get user from an access token emitted by the authorization server.
This method will submit the access token to the authorization server for
introspection, to ensure its validity and obtain the associated metadata.
It follows the specifications outlined in RFC7662 https://www.rfc-editor.org/info/rfc7662,
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
In our eGovernment applications, the standard RFC 7662 doesn't provide sufficient security.
Its introspection response is a plain JSON object. Therefore, we use the draft RFC
that extends RFC 7662 by returning a signed and encrypted JWT for stronger assurance that
the authorization server issued the token introspection response.
"""
self.token_origin_audience = None # Reset the token origin audience
jwt = self._introspect(access_token)
claims = self._verify_claims(jwt)
user_info = self._verify_user_info(claims["token_introspection"])
sub = user_info.get("sub")
if sub is None:
message = "User info contained no recognizable user identification"
logger.debug(message)
raise SuspiciousOperation(message)
try:
user = self.UserModel.objects.get(sub=sub)
except self.UserModel.DoesNotExist:
logger.debug("Login failed: No user with %s found", sub)
return None
self.token_origin_audience = str(user_info["aud"])
return user
def _verify_user_info(self, introspection_response):
"""Verify the 'introspection_response' to get valid and relevant user info.
The 'introspection_response' should be still active, and while authenticating
the resource owner should have requested relevant scope to access her data in
our resource server.
Scope should be configured to match between the AS and the RS. The AS will filter
all the scopes the resource owner requested to expose only the relevant ones to
our resource server.
"""
active = introspection_response.get("active", None)
if not active:
message = "Introspection response is not active."
logger.debug(message)
raise SuspiciousOperation(message)
requested_scopes = introspection_response.get("scope", None).split(" ")
if set(self._scopes).isdisjoint(set(requested_scopes)):
message = "Introspection response contains any required scopes."
logger.debug(message)
raise SuspiciousOperation(message)
audience = introspection_response.get("aud", None)
if not audience:
raise SuspiciousOperation(
"Introspection response does not provide source audience."
)
return introspection_response
def _introspect(self, token):
"""Introspect an access token to the authorization server."""
try:
jwe = self._authorization_server_client.get_introspection(
self._client_id,
self._client_secret,
token,
)
except HTTPError as err:
message = "Could not fetch introspection"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
private_key = utils.import_private_key_from_settings()
jws = self._decrypt(jwe, private_key=private_key)
try:
public_key_set = self._authorization_server_client.import_public_keys()
except (TypeError, ValueError, AttributeError, HTTPError) as err:
message = "Could get authorization server JWKS"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
jwt = self._decode(jws, public_key_set)
return jwt
def _decrypt(self, encrypted_token, private_key):
"""Decrypt the token encrypted by the Authorization Server (AS).
Resource Server (RS)'s public key is used for encryption, and its private
key is used for decryption. The RS's public key is exposed to the AS via a JWKS endpoint.
Encryption Algorithm and Encoding should be configured to match between the AS
and the RS.
"""
try:
decrypted_token = jose_jwe.decrypt_compact(
encrypted_token,
private_key,
algorithms=[self._encryption_algorithm, self._encryption_encoding],
)
except Exception as err:
message = "Token decryption failed"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return decrypted_token
def _decode(self, encoded_token, public_key_set):
"""Decode the token signed by the Authorization Server (AS).
AS's private key is used for signing, and its public key is used for decoding.
The AS public key is exposed via a JWK endpoint.
Signing Algorithm should be configured to match between the AS and the RS.
"""
try:
token = jose_jwt.decode(
encoded_token.plaintext,
public_key_set,
algorithms=[self._signing_algorithm],
)
except ValueError as err:
message = "Token decoding failed"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return token
def _verify_claims(self, token):
"""Verify the claims of the token to ensure authentication security.
By verifying these claims, we ensure that the token was issued by a
trusted authorization server and is intended for this specific
resource server. This prevents various types of attacks, such as
token substitution or misuse of tokens issued for different clients.
"""
try:
self._claims_registry.validate(token.claims)
except (InvalidClaimError, InvalidTokenError) as err:
message = "Failed to validate token's claims"
logger.debug("%s. Exception:", message, exc_info=True)
raise SuspiciousOperation(message) from err
return token.claims
class ResourceServerImproperlyConfiguredBackend:
"""Fallback backend for improperly configured Resource Servers."""
token_origin_audience = None
def get_or_create_user(self, access_token, id_token, payload):
"""Indicate that the Resource Server is improperly configured."""
raise AuthenticationFailed("Resource Server is improperly configured")
@@ -0,0 +1,97 @@
"""Resource Server Clients classes"""
from django.core.exceptions import ImproperlyConfigured
import requests
from joserfc.jwk import KeySet
class AuthorizationServerClient:
"""Client for interacting with an OAuth 2.0 authorization server.
An authorization server issues access tokens to client applications after authenticating
and obtaining authorization from the resource owner. It also provides endpoints for token
introspection and JSON Web Key Sets (JWKS) to validate and decode tokens.
This client facilitates communication with the authorization server, including:
- Fetching token introspection responses.
- Fetching JSON Web Key Sets (JWKS) for token validation.
- Setting appropriate headers for secure communication as recommended by RFC drafts.
"""
# ruff: noqa: PLR0913 PLR0917
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-arguments
def __init__(
self,
url,
url_jwks,
url_introspection,
verify_ssl,
timeout,
proxy,
):
"""Require at a minimum url, url_jwks and url_introspection."""
if not url or not url_jwks or not url_introspection:
raise ImproperlyConfigured(
"Could not instantiate AuthorizationServerClient, some parameters are missing."
)
self.url = url
self._url_introspection = url_introspection
self._url_jwks = url_jwks
self._verify_ssl = verify_ssl
self._timeout = timeout
self._proxy = proxy
@property
def _introspection_headers(self):
"""Get HTTP header for the introspection request.
Notify the authorization server that we expect a signed and encrypted response
by setting the appropriate 'Accept' header.
This follows the recommendation from the draft RFC:
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
"""
return {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
}
def get_introspection(self, client_id, client_secret, token):
"""Retrieve introspection response about a token."""
response = requests.post(
self._url_introspection,
data={
"client_id": client_id,
"client_secret": client_secret,
"token": token,
},
headers=self._introspection_headers,
verify=self._verify_ssl,
timeout=self._timeout,
proxies=self._proxy,
)
response.raise_for_status()
return response.text
def get_jwks(self):
"""Retrieve Authorization Server JWKS."""
response = requests.get(
self._url_jwks,
verify=self._verify_ssl,
timeout=self._timeout,
proxies=self._proxy,
)
response.raise_for_status()
return response.json()
def import_public_keys(self):
"""Retrieve and import Authorization Server JWKS."""
jwks = self.get_jwks()
public_keys = KeySet.import_key_set(jwks)
return public_keys
@@ -0,0 +1,53 @@
"""
Mixins for resource server views.
"""
from rest_framework import exceptions as drf_exceptions
from .authentication import ResourceServerAuthentication
class ResourceServerMixin:
"""
Mixin for resource server views:
- Restrict the authentication class to ResourceServerAuthentication.
- Adds the Service Provider ID to the serializer context.
- Fetch the Service Provider ID from the OIDC introspected token stored
in the request.
This Mixin *must* be used in every view that should act as a resource server.
"""
authentication_classes = [ResourceServerAuthentication]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
# When used as a resource server, we need to know the audience to automatically:
# - add the Service Provider to the team "scope" on creation
context["from_service_provider_audience"] = (
self._get_service_provider_audience()
)
return context
def _get_service_provider_audience(self):
"""Return the audience of the Service Provider from the OIDC introspected token."""
if not isinstance(
self.request.successful_authenticator, ResourceServerAuthentication
):
# We could check request.resource_server_token_audience here, but it's
# more explicit to check the authenticator type and assert the attribute
# existence.
return None
# When used as a resource server, the request has a token audience
service_provider_audience = self.request.resource_server_token_audience
if not service_provider_audience: # should not happen
raise drf_exceptions.AuthenticationFailed(
"Resource server token audience not found in request"
)
return service_provider_audience
+9
View File
@@ -0,0 +1,9 @@
"""Resource Server URL Configuration"""
from django.urls import path
from .views import JWKSView
urlpatterns = [
path("jwks", JWKSView.as_view(), name="resource_server_jwks"),
]
+48
View File
@@ -0,0 +1,48 @@
"""Resource Server utils functions"""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from joserfc.jwk import JWKRegistry
def import_private_key_from_settings():
"""Import the private key used by the resource server when interacting with the OIDC provider.
This private key is crucial; its public components are exposed in the JWK endpoints,
while its private component is used for decrypting the introspection token retrieved
from the OIDC provider.
By default, we recommend using RSAKey for asymmetric encryption,
known for its strong security features.
Note:
- The function requires the 'OIDC_RS_PRIVATE_KEY_STR' setting to be configured.
- The 'OIDC_RS_ENCRYPTION_KEY_TYPE' and 'OIDC_RS_ENCRYPTION_ALGO' settings can be customized
based on the chosen key type.
Raises:
ImproperlyConfigured: If the private key setting is missing, empty, or incorrect.
Returns:
joserfc.jwk.JWK: The imported private key as a JWK object.
"""
private_key_str = getattr(settings, "OIDC_RS_PRIVATE_KEY_STR", None)
if not private_key_str:
raise ImproperlyConfigured(
"OIDC_RS_PRIVATE_KEY_STR setting is missing or empty."
)
private_key_pem = private_key_str.encode()
try:
private_key = JWKRegistry.import_key(
private_key_pem,
key_type=settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
parameters={"alg": settings.OIDC_RS_ENCRYPTION_ALGO, "use": "enc"},
)
except ValueError as err:
raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is wrong.") from err
return private_key
+40
View File
@@ -0,0 +1,40 @@
"""Resource Server views"""
from django.core.exceptions import ImproperlyConfigured
from joserfc.jwk import KeySet
from rest_framework.response import Response
from rest_framework.views import APIView
from . import utils
class JWKSView(APIView):
"""
API endpoint for retrieving a JSON Web Keys Set (JWKS).
Returns:
Response: JSON response containing the JWKS data.
"""
authentication_classes = [] # disable authentication
permission_classes = [] # disable permission
def get(self, request):
"""Handle GET requests to retrieve JSON Web Keys Set (JWKS).
Returns:
Response: JSON response containing the JWKS data.
"""
try:
private_key = utils.import_private_key_from_settings()
except (ImproperlyConfigured, ValueError) as err:
return Response({"error": str(err)}, status=500)
try:
jwk = KeySet([private_key]).as_dict(private=False)
except (TypeError, ValueError, AttributeError):
return Response({"error": "Could not load key"}, status=500)
return Response(jwk)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1 @@
"""Core template tags module."""
+1
View File
@@ -0,0 +1 @@
"""Core tests."""
@@ -0,0 +1 @@
"""Core authentication tests."""
@@ -1,5 +1,6 @@
"""Unit tests for the Authentication Backends."""
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import pytest
@@ -8,6 +9,7 @@ from core import factories, models
from core.authentication.backends import OIDCAuthenticationBackend
pytestmark = pytest.mark.django_db
User = get_user_model()
def test_authentication_getter_existing_user_no_email(
@@ -40,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 {
@@ -77,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 {
@@ -101,15 +106,79 @@ def test_authentication_getter_existing_user_change_fields(
assert user.name == f"{first_name:s} {last_name:s}"
def test_authentication_getter_new_user_no_email(monkeypatch):
def test_authentication_getter_existing_user_keep_fields(
django_assert_num_queries, 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.
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": "123"}
return {
"sub": user.sub,
"email": None,
"first_name": "",
"last_name": "",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# No field changed no more query
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == "john.doe@example.com"
assert user.name == "John Doe"
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email,
the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(with_organization=True)
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
@@ -117,11 +186,31 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
access_token="test-token", id_token=None, payload=None
)
# Since the sub doesn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
assert user.email is None
assert user.name is None
assert user.password == "!"
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should not be created without email
nor organization registration ID.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "123"} # No email, no organization registration ID
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
pytest.raises(
SuspiciousOperation,
match="Claims contained no recognizable organization identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
def test_authentication_getter_new_user_with_email(monkeypatch):
@@ -169,3 +258,198 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the sub but is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(1),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not matches the sub but match the email and is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": "random",
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(2),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_with_email_new_organization(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
If the corresponding organization doesn't exist, it should be created.
"""
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.organization is not None
assert user.organization.domain_list == ["example.com"]
assert user.organization.registration_id_list == []
@pytest.mark.parametrize(
"registration_id_setting,expected_registration_id_list,expected_domain_list",
[
(None, [], ["example.com"]),
("missing-claim", [], ["example.com"]),
("registration_number", ["12345678901234"], []),
],
)
def test_authentication_getter_new_user_with_registration_id_new_organization(
monkeypatch,
settings,
registration_id_setting,
expected_registration_id_list,
expected_domain_list,
):
"""
If no user matches the user's info sub, a user should be created.
If the corresponding organization doesn't exist, it should be created.
"""
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = registration_id_setting
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": email,
"first_name": "John",
"last_name": "Doe",
"registration_number": "12345678901234",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.organization is not None
assert user.organization.domain_list == expected_domain_list
assert user.organization.registration_id_list == expected_registration_id_list
def test_authentication_getter_existing_user_via_email_update_organization(
django_assert_num_queries, monkeypatch
):
"""
If an existing user already exists without organization, the organization must be updated.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", email="toto@my-domain.com")
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(9):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
assert user.organization is not None
assert user.organization.domain_list == ["my-domain.com"]
def test_authentication_getter_existing_user_with_registration_id(
monkeypatch,
settings,
):
"""
Authenticate a user who already exists with an organization registration ID.
This asserts the "update_user_if_needed" does not fail when the claim
contains the organization registration ID (or any value not present in the
User model).
"""
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "registration_number"
klass = OIDCAuthenticationBackend()
_existing_user = factories.UserFactory(
sub="123",
name="John Doe",
email="people@example.com",
)
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": "people@example.com",
"first_name": "John",
"last_name": "Doe",
"registration_number": "12345678901234",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.organization is not None
assert user.organization.registration_id_list == ["12345678901234"]
@@ -0,0 +1 @@
"""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()
dave = factories.BaseContactFactory(full_name="David Bowman")
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
frank = factories.BaseContactFactory(full_name="Frank Poole")
factories.BaseContactFactory(full_name="Heywood Floyd")
# 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
@@ -0,0 +1,447 @@
"""
Test for the Resource Server (RS) Backend.
"""
# pylint: disable=W0212
from logging import Logger
from unittest.mock import Mock, patch
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
from joserfc.errors import InvalidClaimError, InvalidTokenError
from joserfc.jwt import JWTClaimsRegistry
from requests.exceptions import HTTPError
from core.resource_server.backend import ResourceServerBackend
@pytest.fixture(name="mock_authorization_server")
def fixture_mock_authorization_server():
"""Mock an Authorization Server client."""
mock_server = Mock()
mock_server.url = "https://auth.server.com"
return mock_server
@pytest.fixture(name="mock_token")
def fixture_mock_token():
"""Mock a token"""
mock_token = Mock()
mock_token.claims = {"sub": "user123", "iss": "https://auth.server.com"}
return mock_token
@pytest.fixture(name="resource_server_backend")
def fixture_resource_server_backend(settings, mock_authorization_server):
"""Generate a Resource Server backend."""
settings.OIDC_RS_CLIENT_ID = "client_id"
settings.OIDC_RS_CLIENT_SECRET = "client_secret"
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
settings.OIDC_RS_SIGNING_ALGO = "ES256"
settings.OIDC_RS_SCOPES = ["groups"]
return ResourceServerBackend(mock_authorization_server)
@override_settings(OIDC_RS_CLIENT_ID="client_id")
@override_settings(OIDC_RS_CLIENT_SECRET="client_secret")
@override_settings(OIDC_RS_ENCRYPTION_ENCODING="A256GCM")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RSA-OAEP")
@override_settings(OIDC_RS_SIGNING_ALGO="RS256")
@override_settings(OIDC_RS_SCOPES=["scopes"])
@patch.object(auth, "get_user_model", return_value="foo")
def test_backend_initialization(mock_get_user_model, mock_authorization_server):
"""Test the ResourceServerBackend initialization."""
backend = ResourceServerBackend(mock_authorization_server)
mock_get_user_model.assert_called_once()
assert backend.UserModel == "foo"
assert backend._client_id == "client_id"
assert backend._client_secret == "client_secret"
assert backend._encryption_encoding == "A256GCM"
assert backend._encryption_algorithm == "RSA-OAEP"
assert backend._signing_algorithm == "RS256"
assert backend._scopes == ["scopes"]
assert backend._authorization_server_client == mock_authorization_server
assert isinstance(backend._claims_registry, JWTClaimsRegistry)
assert backend._claims_registry.options == {
"iss": {"essential": True, "value": "https://auth.server.com"},
"aud": {"essential": True, "value": "client_id"},
"token_introspection": {"essential": True},
}
@patch.object(ResourceServerBackend, "get_user", return_value="user")
def test_get_or_create_user(mock_get_user, resource_server_backend):
"""Test 'get_or_create_user' method."""
access_token = "access_token"
res = resource_server_backend.get_or_create_user(access_token, None, None)
mock_get_user.assert_called_once_with(access_token)
assert res == "user"
def test_verify_claims_success(resource_server_backend, mock_token):
"""Test '_verify_claims' method with a successful response."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
resource_server_backend._verify_claims(mock_token)
mock_validate.assert_called_once_with(mock_token.claims)
def test_verify_claims_invalid_claim_error(resource_server_backend, mock_token):
"""Test '_verify_claims' method with an invalid claim error."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
mock_validate.side_effect = InvalidClaimError("claim_name")
expected_message = "Failed to validate token's claims"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_claims(mock_token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_verify_claims_invalid_token_error(resource_server_backend, mock_token):
"""Test '_verify_claims' method with an invalid token error."""
with patch.object(
resource_server_backend._claims_registry, "validate"
) as mock_validate:
mock_validate.side_effect = InvalidTokenError
expected_message = "Failed to validate token's claims"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_claims(mock_token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_decode_success(resource_server_backend):
"""Test '_decode' method with a successful response."""
encoded_token = Mock()
encoded_token.plaintext = "valid_encoded_token"
public_key_set = Mock()
expected_decoded_token = {"sub": "user123"}
with patch(
"joserfc.jwt.decode", return_value=expected_decoded_token
) as mock_decode:
decoded_token = resource_server_backend._decode(encoded_token, public_key_set)
mock_decode.assert_called_once_with(
"valid_encoded_token", public_key_set, algorithms=["ES256"]
)
assert decoded_token == expected_decoded_token
def test_decode_failure(resource_server_backend):
"""Test '_decode' method with a ValueError"""
encoded_token = Mock()
encoded_token.plaintext = "invalid_encoded_token"
public_key_set = Mock()
with patch("joserfc.jwt.decode", side_effect=ValueError):
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match="Token decoding failed"):
resource_server_backend._decode(encoded_token, public_key_set)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", "Token decoding failed", exc_info=True
)
def test_decrypt_success(resource_server_backend):
"""Test '_decrypt' method with a successful response."""
encrypted_token = "valid_encrypted_token"
private_key = "private_key"
expected_decrypted_token = {"sub": "user123"}
with patch(
"joserfc.jwe.decrypt_compact", return_value=expected_decrypted_token
) as mock_decrypt:
decrypted_token = resource_server_backend._decrypt(encrypted_token, private_key)
mock_decrypt.assert_called_once_with(
encrypted_token, private_key, algorithms=["RSA-OAEP", "A256GCM"]
)
assert decrypted_token == expected_decrypted_token
def test_decrypt_failure(resource_server_backend):
"""Test '_decrypt' method with an Exception."""
encrypted_token = "invalid_encrypted_token"
private_key = "private_key"
with patch(
"joserfc.jwe.decrypt_compact", side_effect=Exception("Decryption error")
):
expected_message = "Token decryption failed"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._decrypt(encrypted_token, private_key)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
@patch(
"core.resource_server.utils.import_private_key_from_settings",
return_value="private_key",
)
# pylint: disable=unused-argument
def test_introspect_success(
mock_import_private_key_from_settings, resource_server_backend
):
"""Test '_introspect' method with a successful response."""
token = "valid_token"
jwe = "valid_jwe"
jws = "valid_jws"
jwt = {"sub": "user123"}
resource_server_backend._authorization_server_client.get_introspection = Mock(
return_value=jwe
)
resource_server_backend._decrypt = Mock(return_value=jws)
resource_server_backend._authorization_server_client.import_public_keys = Mock(
return_value="public_key_set"
)
resource_server_backend._decode = Mock(return_value=jwt)
result = resource_server_backend._introspect(token)
assert result == jwt
resource_server_backend._authorization_server_client.get_introspection.assert_called_once_with(
"client_id", "client_secret", token
)
resource_server_backend._decrypt.assert_called_once_with(
jwe, private_key="private_key"
)
resource_server_backend._authorization_server_client.import_public_keys.assert_called_once()
resource_server_backend._decode.assert_called_once_with(jws, "public_key_set")
def test_introspect_introspection_failure(resource_server_backend):
"""Test '_introspect' method when introspection to the AS fails."""
token = "invalid_token"
resource_server_backend._authorization_server_client.get_introspection.side_effect = HTTPError(
"Introspection error"
)
with patch.object(Logger, "debug") as mock_logger_debug:
expected_message = "Could not fetch introspection"
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._introspect(token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
@patch(
"core.resource_server.utils.import_private_key_from_settings",
return_value="private_key",
)
# pylint: disable=unused-argument
def test_introspect_public_key_import_failure(
mock_import_private_key_from_settings, resource_server_backend
):
"""Test '_introspect' method when fetching AS's jwks fails."""
token = "valid_token"
jwe = "valid_jwe"
jws = "valid_jws"
resource_server_backend._authorization_server_client.get_introspection = Mock(
return_value=jwe
)
resource_server_backend._decrypt = Mock(return_value=jws)
resource_server_backend._authorization_server_client.import_public_keys.side_effect = HTTPError(
"Public key error"
)
with patch.object(Logger, "debug") as mock_logger_debug:
expected_message = "Could get authorization server JWKS"
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._introspect(token)
mock_logger_debug.assert_called_once_with(
"%s. Exception:", expected_message, exc_info=True
)
def test_verify_user_info_success(resource_server_backend):
"""Test '_verify_user_info' with a successful response."""
introspection_response = {"active": True, "scope": "groups", "aud": "123"}
result = resource_server_backend._verify_user_info(introspection_response)
assert result == introspection_response
def test_verify_user_info_inactive(resource_server_backend):
"""Test '_verify_user_info' with an inactive introspection response."""
introspection_response = {"active": False, "scope": "groups"}
expected_message = "Introspection response is not active."
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_user_info(introspection_response)
mock_logger_debug.assert_called_once_with(expected_message)
def test_verify_user_info_wrong_scopes(resource_server_backend):
"""Test '_verify_user_info' with wrong requested scopes."""
introspection_response = {"active": True, "scope": "wrong-scopes"}
expected_message = "Introspection response contains any required scopes."
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend._verify_user_info(introspection_response)
mock_logger_debug.assert_called_once_with(expected_message)
def test_get_user_success(resource_server_backend):
"""Test '_get_user' with a successful response."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {"sub": "user123", "aud": "123"}}
mock_user = Mock()
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get = Mock(return_value=mock_user)
user = resource_server_backend.get_user(access_token)
assert user == mock_user
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_called_once_with(
mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get.assert_called_once_with(sub="user123")
def test_get_user_could_not_introspect(resource_server_backend):
"""Test '_get_user' with introspection failing."""
access_token = "valid_access_token"
resource_server_backend._introspect = Mock(
side_effect=SuspiciousOperation("Invalid jwt")
)
resource_server_backend._verify_claims = Mock()
resource_server_backend._verify_user_info = Mock()
with pytest.raises(SuspiciousOperation, match="Invalid jwt"):
resource_server_backend.get_user(access_token)
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_not_called()
resource_server_backend._verify_user_info.assert_not_called()
def test_get_user_invalid_introspection_response(resource_server_backend):
"""Test '_get_user' with an invalid introspection response."""
access_token = "valid_access_token"
mock_jwt = Mock()
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(
side_effect=SuspiciousOperation("Invalid claims")
)
resource_server_backend._verify_user_info = Mock()
with pytest.raises(SuspiciousOperation, match="Invalid claims"):
resource_server_backend.get_user(access_token)
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_not_called()
def test_get_user_user_not_found(resource_server_backend):
"""Test '_get_user' if the user is not found."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {"sub": "user123"}}
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get = Mock(
side_effect=resource_server_backend.UserModel.DoesNotExist
)
with patch.object(Logger, "debug") as mock_logger_debug:
user = resource_server_backend.get_user(access_token)
assert user is None
resource_server_backend._introspect.assert_called_once_with(access_token)
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
resource_server_backend._verify_user_info.assert_called_once_with(
mock_claims["token_introspection"]
)
resource_server_backend.UserModel.objects.get.assert_called_once_with(
sub="user123"
)
mock_logger_debug.assert_called_once_with(
"Login failed: No user with %s found", "user123"
)
def test_get_user_no_user_identification(resource_server_backend):
"""Test '_get_user' if the response miss a user identification."""
access_token = "valid_access_token"
mock_jwt = Mock()
mock_claims = {"token_introspection": {}}
resource_server_backend._introspect = Mock(return_value=mock_jwt)
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
resource_server_backend._verify_user_info = Mock(
return_value=mock_claims["token_introspection"]
)
expected_message = "User info contained no recognizable user identification"
with patch.object(Logger, "debug") as mock_logger_debug:
with pytest.raises(SuspiciousOperation, match=expected_message):
resource_server_backend.get_user(access_token)
mock_logger_debug.assert_called_once_with(expected_message)
@@ -0,0 +1,187 @@
"""
Test for the Resource Server (RS) clients classes.
"""
# pylint: disable=W0212
from unittest.mock import MagicMock, patch
import pytest
from joserfc.jwk import KeySet, RSAKey
from requests.exceptions import HTTPError
from core.resource_server.clients import AuthorizationServerClient
@pytest.fixture(name="client")
def fixture_client():
"""Generate an Authorization Server client."""
return AuthorizationServerClient(
url="https://auth.example.com/api/v2",
url_jwks="https://auth.example.com/api/v2/jwks",
url_introspection="https://auth.example.com/api/v2/introspect",
verify_ssl=True,
timeout=5,
proxy=None,
)
def test_authorization_server_client_initialization():
"""Test the AuthorizationServerClient initialization."""
new_client = AuthorizationServerClient(
url="https://auth.example.com/api/v2",
url_jwks="https://auth.example.com/api/v2/jwks",
url_introspection="https://auth.example.com/api/v2/checktoken/foo",
verify_ssl=True,
timeout=5,
proxy=None,
)
assert new_client.url == "https://auth.example.com/api/v2"
assert (
new_client._url_introspection
== "https://auth.example.com/api/v2/checktoken/foo"
)
assert new_client._url_jwks == "https://auth.example.com/api/v2/jwks"
assert new_client._verify_ssl is True
assert new_client._timeout == 5
assert new_client._proxy is None
def test_introspection_headers(client):
"""Test the introspection headers to ensure they match the expected values."""
assert client._introspection_headers == {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
}
@patch("requests.post")
def test_get_introspection_success(mock_post, client):
"""Test 'get_introspection' method with a successful response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.text = "introspection response"
mock_post.return_value = mock_response
result = client.get_introspection("client_id", "client_secret", "token")
assert result == "introspection response"
mock_post.assert_called_once_with(
"https://auth.example.com/api/v2/introspect",
data={
"client_id": "client_id",
"client_secret": "client_secret",
"token": "token",
},
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/token-introspection+jwt",
},
verify=True,
timeout=5,
proxies=None,
)
@patch("requests.post", side_effect=HTTPError())
# pylint: disable=(unused-argument
def test_get_introspection_error(mock_post, client):
"""Test 'get_introspection' method with an HTTPError."""
with pytest.raises(HTTPError):
client.get_introspection("client_id", "client_secret", "token")
@patch("requests.get")
def test_get_jwks_success(mock_get, client):
"""Test 'get_jwks' method with a successful response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"jwks": "foo"}
mock_get.return_value = mock_response
result = client.get_jwks()
assert result == {"jwks": "foo"}
mock_get.assert_called_once_with(
"https://auth.example.com/api/v2/jwks",
verify=client._verify_ssl,
timeout=client._timeout,
proxies=client._proxy,
)
@patch("requests.get")
def test_get_jwks_error(mock_get, client):
"""Test 'get_jwks' method with an HTTPError."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = HTTPError(
response=MagicMock(status=500)
)
mock_get.return_value = mock_response
with pytest.raises(HTTPError):
client.get_jwks()
@patch("requests.get")
def test_import_public_keys_valid(mock_get, client):
"""Test 'import_public_keys' method with a successful response."""
mocked_key = RSAKey.generate_key(2048)
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": [mocked_key.as_dict()]}
mock_get.return_value = mock_response
response = client.import_public_keys()
assert isinstance(response, KeySet)
assert response.as_dict() == KeySet([mocked_key]).as_dict()
@patch("requests.get")
def test_import_public_keys_http_error(mock_get, client):
"""Test 'import_public_keys' method with an HTTPError."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = HTTPError(
response=MagicMock(status=500)
)
mock_get.return_value = mock_response
with pytest.raises(HTTPError):
client.import_public_keys()
@patch("requests.get")
def test_import_public_keys_empty_jwks(mock_get, client):
"""Test 'import_public_keys' method with empty keys response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": []}
mock_get.return_value = mock_response
response = client.import_public_keys()
assert isinstance(response, KeySet)
assert response.as_dict() == {"keys": []}
@patch("requests.get")
def test_import_public_keys_invalid_jwks(mock_get, client):
"""Test 'import_public_keys' method with invalid keys response."""
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"keys": [{"foo": "foo"}]}
mock_get.return_value = mock_response
with pytest.raises(ValueError):
client.import_public_keys()
@@ -0,0 +1,88 @@
"""
Test for the Resource Server (RS) utils functions.
"""
from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
import pytest
from joserfc.jwk import ECKey, RSAKey
from core.resource_server.utils import import_private_key_from_settings
PRIVATE_KEY_STR_MOCKED = """-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
MbyqKyC6DAzv4jEEhHaN7oY=
-----END PRIVATE KEY-----
"""
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@pytest.mark.parametrize("mocked_private_key", [None, ""])
def test_import_private_key_from_settings_missing_or_empty_key(
settings, mocked_private_key
):
"""Should raise an exception if the settings 'OIDC_RS_PRIVATE_KEY_STR' is missing or empty."""
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
with pytest.raises(
ImproperlyConfigured,
match="OIDC_RS_PRIVATE_KEY_STR setting is missing or empty.",
):
import_private_key_from_settings()
@pytest.mark.parametrize("mocked_private_key", ["123", "foo", "invalid_key"])
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
def test_import_private_key_from_settings_incorrect_key(settings, mocked_private_key):
"""Should raise an exception if the setting 'OIDC_RS_PRIVATE_KEY_STR' has an incorrect value."""
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
with pytest.raises(
ImproperlyConfigured, match="OIDC_RS_PRIVATE_KEY_STR setting is wrong."
):
import_private_key_from_settings()
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
def test_import_private_key_from_settings_success_rsa_key():
"""Should import private key string as an RSA key."""
private_key = import_private_key_from_settings()
assert isinstance(private_key, RSAKey)
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="EC")
@override_settings(OIDC_RS_ENCRYPTION_ALGO="ES256")
def test_import_private_key_from_settings_success_ec_key():
"""Should import private key string as an EC key."""
private_key = import_private_key_from_settings()
assert isinstance(private_key, ECKey)
@@ -0,0 +1,70 @@
"""
Tests for the Resource Server (RS) Views.
"""
from unittest import mock
from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse
import pytest
from joserfc.jwk import RSAKey
from rest_framework.test import APIClient
pytestmark = pytest.mark.django_db
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_valid_public_key(mock_import_private_key_from_settings):
"""JWKs endpoint should return a set of valid Json Web Key"""
mocked_key = RSAKey.generate_key(2048)
mock_import_private_key_from_settings.return_value = mocked_key
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 200
assert response["Content-Type"] == "application/json"
jwks = response.json()
assert jwks == {"keys": [mocked_key.as_dict(private=False)]}
# Security checks to make sure no details from the private key are exposed
private_details = ["d", "p", "q", "dp", "dq", "qi", "oth", "r", "t"]
assert all(
private_detail not in jwks["keys"][0].keys()
for private_detail in private_details
)
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_invalid_private_key(mock_import_private_key_from_settings):
"""JWKS endpoint should return a proper exception when loading keys fails."""
mock_import_private_key_from_settings.return_value = "wrong_key"
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 500
assert response.json() == {"error": "Could not load key"}
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
def test_view_jwks_missing_private_key(mock_import_private_key_from_settings):
"""JWKS endpoint should return a proper exception when private key is missing."""
mock_import_private_key_from_settings.side_effect = ImproperlyConfigured("foo.")
url = reverse("resource_server_jwks")
response = APIClient().get(url)
mock_import_private_key_from_settings.assert_called_once()
assert response.status_code == 500
assert response.json() == {"error": "foo."}
@@ -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,163 @@
"""
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),
"name": "my team",
"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,184 @@
"""
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(4): # 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"),
"id": str(team_1.pk),
"name": team_1.name,
"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"),
"id": str(team_2.pk),
"name": team_2.name,
"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"),
"id": str(team_3.pk),
"name": team_3.name,
"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"),
"id": str(team_4.pk),
"name": team_4.name,
"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"
@@ -0,0 +1,99 @@
"""
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 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() == {
"id": str(team.id),
"name": team.name,
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"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
@@ -0,0 +1,246 @@
"""
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
@@ -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."""
@@ -223,7 +223,7 @@ def test_api_team_accesses__list_find_members_by_email():
Authenticated users should be able to search users access with a case-insensitive and
partial query on the email.
"""
user = factories.UserFactory(name=None)
user = factories.UserFactory(name=None, email="alicia@example.com")
# set all names to None to match only on emails
colleague1 = factories.UserFactory(name=None, email="prudence_crandall@edu.us")

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