Compare commits
471 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f85e66abe | |||
| 6ae195b90c | |||
| be64abb22f | |||
| ca56eb0cac | |||
| 99ca34719f | |||
| 014fb62f95 | |||
| 99433a6722 | |||
| 5ebc88bcff | |||
| a65e61bd96 | |||
| b8beb56135 | |||
| 37e5b5b346 | |||
| 31201fbd59 | |||
| eb0683ffe0 | |||
| 54219d25b8 | |||
| 89e7703d53 | |||
| 7d44e54913 | |||
| 01583ba94f | |||
| 5feee53bdd | |||
| 9c62efc9f8 | |||
| d2ef9e0beb | |||
| bc1cbef168 | |||
| 8ab1b2e2ef | |||
| f498e3b6d2 | |||
| e47573e22f | |||
| c7aab6f5b5 | |||
| 2144ad5da1 | |||
| f00deeebe9 | |||
| 30aea9b350 | |||
| e01b422c13 | |||
| 040f949e5e | |||
| befcecf33c | |||
| c5edabf3b0 | |||
| 3a2b5a6428 | |||
| 15337a2226 | |||
| 0f7e312eb6 | |||
| 23561cd0e0 | |||
| 53d0336755 | |||
| b79e12b4be | |||
| c237bb4b10 | |||
| 64068efff4 | |||
| 6866babb32 | |||
| 80caa062e9 | |||
| 513dbe6f83 | |||
| 490fdd2ed4 | |||
| fa7d24545f | |||
| e5938e144e | |||
| 39af4313cf | |||
| abfe32569e | |||
| 964981bbfb | |||
| 33faa5f224 | |||
| 99967b450e | |||
| 71a7bf688f | |||
| 302671bc69 | |||
| 4262f469d6 | |||
| b24cb23a83 | |||
| 608f8c6988 | |||
| 2ddfef59d8 | |||
| 6b8cb16bd5 | |||
| 8b89a1112d | |||
| 230ff21220 | |||
| a37c2a8e83 | |||
| 80da8bea74 | |||
| 76f4bf36c7 | |||
| 78a5d907ca | |||
| 0ad60ab292 | |||
| ac443d3b6f | |||
| 48ff0e9f3a | |||
| 675e719a22 | |||
| 0875fae87b | |||
| 668a296142 | |||
| f1892b7049 | |||
| 1bfad507ef | |||
| 72e73bff45 | |||
| e45cf8dd8b | |||
| 79f8e5276a | |||
| 4f97685204 | |||
| 935058582e | |||
| 636b27321b | |||
| 2fdddb3e12 | |||
| f7a97e11e8 | |||
| a56f86965f | |||
| 89674ad718 | |||
| 4e592382dc | |||
| 1fdd2e052b | |||
| 25918830ff | |||
| b47282430e | |||
| ebd3f467c4 | |||
| 0f4ec63a9f | |||
| fdec3b4196 | |||
| b78723ecce | |||
| df0cea225c | |||
| 3b73cca2f5 | |||
| 06d4d5c9e8 | |||
| 405e5fda90 | |||
| 8a434448dd | |||
| 5b5bd312b4 | |||
| 99bf301a9c | |||
| 3dc11c9b52 | |||
| 5327baacbd | |||
| 709628a26a | |||
| d2bf267160 | |||
| 10dcdfc8c2 | |||
| f30398fbc7 | |||
| cc39ed5298 | |||
| 7bebf13d88 | |||
| e64a34f167 | |||
| 3379d6d499 | |||
| 213656fc2e | |||
| 4dfd682cb6 | |||
| 160c45bf35 | |||
| 3fdd8a230c | |||
| b47246826e | |||
| 5429354261 | |||
| 86c98cc426 | |||
| 0bbce9ffc8 | |||
| ce66645294 | |||
| 485eb88dd1 | |||
| a8b08c4b6d | |||
| 23f5a13ccc | |||
| 1245c54c61 | |||
| 95f63fa56d | |||
| 4c3891047b | |||
| fce9b1e490 | |||
| 83bec33bdb | |||
| 8fed2606d6 | |||
| c7eb86eaa9 | |||
| be1513e106 | |||
| 8e85f303ec | |||
| ee564ff6ba | |||
| b0355059b7 | |||
| 6e792986be | |||
| fe9fb67fed | |||
| 91fbef9066 | |||
| 4f3c9abe62 | |||
| bd43e4620d | |||
| 8c67d4a004 | |||
| 78cb3e693c | |||
| cec8e87edd | |||
| 35a700d522 | |||
| c786ddbb82 | |||
| cb198a9d04 | |||
| 3d9645b561 | |||
| 21cbeded18 | |||
| f0c609ef0b | |||
| aa3d90b686 | |||
| 4a08a9ec92 | |||
| 5544a40f5f | |||
| e274c309cd | |||
| 560998083d | |||
| 2d56c57102 | |||
| b5d86967ff | |||
| 08559d856d | |||
| 8b17a5470d | |||
| 141c4e7f61 | |||
| a5473f62b7 | |||
| 889291c7f3 | |||
| a8d20bacb0 | |||
| c4a81cf76a | |||
| 0a241f0e03 | |||
| b389927653 | |||
| 056a4bd7ac | |||
| 6721328b2d | |||
| ab5d8c74d8 | |||
| 4c14f967b6 | |||
| b42cd483c6 | |||
| 3735b699cc | |||
| 0220875c70 | |||
| 7a1fc6b626 | |||
| 99d7b23dc9 | |||
| 1a52cd63bf | |||
| 8691f1846d | |||
| edbf77c525 | |||
| 140d099fce | |||
| 133688324b | |||
| a7b3cd42bc | |||
| ceebf8f7aa | |||
| 8ef2cc9a37 | |||
| e2d362bc77 | |||
| 594d3af0d0 | |||
| 855e20d407 | |||
| f60bfc2676 | |||
| b4de7fda92 | |||
| a009f3ccb7 | |||
| 2f1843e0e8 | |||
| 3a044e6b02 | |||
| 7c569a3ca3 | |||
| e23d236614 | |||
| 61c3b6ac6b | |||
| 1eb9dffa48 | |||
| d0854851a2 | |||
| 6eae92d9e5 | |||
| b02146e4eb | |||
| dd43483ce6 | |||
| 838d1267b2 | |||
| fbe3aa54d0 | |||
| e4e9a121a4 | |||
| 3173e096d9 | |||
| 4420bab073 | |||
| 8cbedeb76e | |||
| 28fdee868d | |||
| 4ced342062 | |||
| 2502ff0c99 | |||
| dc33493739 | |||
| 81030004e9 | |||
| 339831f090 | |||
| 5178e460c4 | |||
| feb5d7154b | |||
| 7dac39034a | |||
| 660fc7c291 | |||
| 27fd43b164 | |||
| e63c31f960 | |||
| 6b2ca88ff2 | |||
| b771f614e2 | |||
| 889a495ea3 | |||
| f21716dc68 | |||
| 7e1f0b31f9 | |||
| 666cafe220 | |||
| 1ec98f0948 | |||
| f0258bbde7 | |||
| 773f8cfb4b | |||
| 4003f66243 | |||
| 5cd8f79f1e | |||
| 9c451e74a6 | |||
| f35d17628d | |||
| fa6cfefcd9 | |||
| 46ef6eca78 | |||
| 9439f454de | |||
| db3185e16b | |||
| 4c033d7262 | |||
| 74655ba378 | |||
| 859efa26dc | |||
| d31b79aaad | |||
| 495245a752 | |||
| 03600f243e | |||
| d5eb736343 | |||
| 9f1c1ea7dd | |||
| 803b2c1930 | |||
| e1193bc26d | |||
| 285647a8a9 | |||
| c4dd4ae3fd | |||
| 06f1695071 | |||
| 34783d0557 | |||
| 5cc8108e7b | |||
| 59633d6543 | |||
| a6f7c07052 | |||
| 67f8bc32fa | |||
| 0b290d9a5e | |||
| 68ed5e4d55 | |||
| 48264a0b40 | |||
| e3bf1d76fa | |||
| f64a592648 | |||
| 7ce5b28af4 | |||
| 3aaddc0493 | |||
| 07ff093b18 | |||
| 319a9b18d8 | |||
| 403fea94bb | |||
| 5730b9ea5e | |||
| 7f75efacf8 | |||
| 305e2438c5 | |||
| ebc2b02d22 | |||
| 185b87da40 | |||
| 701aeca763 | |||
| 7a128393f6 | |||
| 21993b3272 | |||
| 63ec61c465 | |||
| 67d9b6462f | |||
| ea1f06f6cc | |||
| b063f690f6 | |||
| ae92ab5dd8 | |||
| 22419d4779 | |||
| 91389181f0 | |||
| 45bafe04de | |||
| c90a74b362 | |||
| 54df9af179 | |||
| 2224acf12d | |||
| 9ee1ef5ba0 | |||
| 7ea381c88a | |||
| 680e05b4a7 | |||
| 6e7ebc76d0 | |||
| d5b154fbe0 | |||
| ef4c1da78c | |||
| 4060006a22 | |||
| 8b56d97037 | |||
| 5fd6579d3c | |||
| b4ab36fc0e | |||
| 160ce92e54 | |||
| a7ab2142f9 | |||
| cf4b435c63 | |||
| fd8e0e08c3 | |||
| 68550f6f7e | |||
| db6cdadd72 | |||
| 8faa049046 | |||
| a386c61729 | |||
| 99cc4d00d5 | |||
| 55b7d1adbd | |||
| 7d3f10a4b6 | |||
| 5f294b8436 | |||
| db338231b7 | |||
| 21033dedbd | |||
| 2dfa97749f | |||
| 7ccd8e3035 | |||
| 8d0fbdfecd | |||
| a83fb25f6d | |||
| 11c7af205b | |||
| f23f1eabd6 | |||
| 7025a0787f | |||
| 70d22eecfa | |||
| 7379d70321 | |||
| 29d0bbb692 | |||
| cdb766b0e0 | |||
| 38de864d68 | |||
| cad065da84 | |||
| 3893fdf4d7 | |||
| d29b5141b1 | |||
| 110fc82250 | |||
| 95f19f7c6c | |||
| ab03cd9db9 | |||
| a811431070 | |||
| d23ac76f36 | |||
| 9377a96e87 | |||
| 25313d3e84 | |||
| 9cd1b42c3d | |||
| d08198e44d | |||
| e55468862d | |||
| fbb4797f29 | |||
| 0f290df24a | |||
| a2b2c71448 | |||
| e1594493a7 | |||
| e903c5d4ca | |||
| 0f80d5f2db | |||
| 4cb695c2bf | |||
| a68f8171cb | |||
| 471f69d4ec | |||
| dc938d3159 | |||
| 87907d57de | |||
| 57fd15100e | |||
| 34c9dc6cd7 | |||
| 1be30496be | |||
| 0ca5fa5318 | |||
| 4d3901b35d | |||
| 961bceb64e | |||
| d7957547f8 | |||
| 418db6194a | |||
| c2899f66af | |||
| ba3f6a504f | |||
| 3de495a489 | |||
| e297a025c3 | |||
| 351c696ef8 | |||
| 579dbdee10 | |||
| b4a877381a | |||
| 92753082c7 | |||
| 4df4172151 | |||
| e7af1fd591 | |||
| 8a2b0d0a76 | |||
| d48a3ff677 | |||
| 4fb5d87df9 | |||
| 6274764f90 | |||
| 7f0e231474 | |||
| 2c15609c1e | |||
| cd94dc5091 | |||
| 9d9216cf39 | |||
| 7dd9eae5d9 | |||
| 914319c366 | |||
| c34ad00fae | |||
| 289879962b | |||
| cd88799943 | |||
| 4011c8e8ed | |||
| b98281fa72 | |||
| 04bd154bad | |||
| 227ecd0700 | |||
| 6d618b4aff | |||
| 3a2b2b8a53 | |||
| f969b118cb | |||
| c48957612b | |||
| e09e1d1b80 | |||
| 615d5894c9 | |||
| 914554e45c | |||
| 3f990e93b7 | |||
| 9de20a496e | |||
| 870ef424f5 | |||
| 0e92c1cafa | |||
| defc18ea40 | |||
| 4ccea4655b | |||
| 45fd10fd2d | |||
| 201864db3a | |||
| 182f9c1d17 | |||
| cff3d5c123 | |||
| 32a576bbe9 | |||
| 010d3674de | |||
| 80976e3761 | |||
| b848f9eca6 | |||
| cd7135da00 | |||
| 0a795f6e6f | |||
| 6c8329405d | |||
| ea3a45ea87 | |||
| 86451df8b4 | |||
| 76fc789eb6 | |||
| 632a78c339 | |||
| 20cc173e93 | |||
| d495ef3e19 | |||
| 4def80214c | |||
| 2428229dbb | |||
| 9b9aa2aa37 | |||
| a28bb5e2a2 | |||
| 6962953625 | |||
| b0b718e657 | |||
| 0abfd49fee | |||
| 5d2e63fc18 | |||
| 94a443525e | |||
| 42d7d00772 | |||
| 5ec65b1202 | |||
| 9ba30843c5 | |||
| 4de589a7e8 | |||
| 151f030582 | |||
| a58152b9a9 | |||
| 8fd55a61c5 | |||
| 2435a59078 | |||
| fd2c90f50d | |||
| 469014ac41 | |||
| e60bae4321 | |||
| 2e7f224e30 | |||
| 8865e250a0 | |||
| fa114b1064 | |||
| 38369a8312 | |||
| d3940f6d09 | |||
| 6061a65e44 | |||
| 4a763396e2 | |||
| fcc63f9b82 | |||
| 0f4db82b02 | |||
| bcdc57905b | |||
| 30832a20e1 | |||
| a021e9eec6 | |||
| fa80edfaa8 | |||
| 38a5f158b5 | |||
| 6e14c2e61f | |||
| dd9a905dc0 | |||
| 2f380b49d0 | |||
| 9953dd2111 | |||
| 5d84e226b7 | |||
| 6fde76fb46 | |||
| 7154a491f4 | |||
| 019ce99a86 | |||
| 579aac264e | |||
| c54f010989 | |||
| 841a6c27ba | |||
| b060bbea51 | |||
| ac24dd91a7 | |||
| 867c89a2e6 | |||
| 54120eb179 | |||
| cf155dc033 | |||
| e623c32c9a | |||
| a1892d2693 | |||
| 632a96ccf4 | |||
| d7c654e1aa | |||
| 99b6181944 | |||
| 514414de16 | |||
| f1956f4e86 | |||
| f13e2c18b9 | |||
| 4ba1901355 | |||
| e821163269 | |||
| fc9499e211 | |||
| 9dde2fe7e2 | |||
| 5dad700c1c | |||
| f759d318b9 | |||
| 60ab61d125 | |||
| 625f122ad5 | |||
| 6d5d0f7ebb | |||
| b7908801d4 | |||
| 0785c3a5bc | |||
| 51eb3474e4 | |||
| 10be01f6af |
@@ -0,0 +1,24 @@
|
||||
# Set the default behavior for all files
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary files (should not be modified)
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.mov binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
*.flv binary
|
||||
*.fla binary
|
||||
*.swf binary
|
||||
*.gz binary
|
||||
*.zip binary
|
||||
*.7z binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
|
||||
@@ -9,9 +9,9 @@ We primarily use GitHub as an issue tracker. If however you're encountering an i
|
||||
|
||||
---
|
||||
|
||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/people).
|
||||
Please make sure you have read our [main Readme](https://github.com/suitenumerique/people).
|
||||
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/people/issues).
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/people/issues).
|
||||
|
||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Download translations from Crowdin
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Create empty source files
|
||||
run: |
|
||||
touch src/backend/locale/django.pot
|
||||
mkdir -p src/frontend/packages/i18n/locales/desk/
|
||||
touch src/frontend/packages/i18n/locales/desk/translations-crowdin.json
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
create_pull_request: false
|
||||
push_translations: false
|
||||
push_sources: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate translations files
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:deploy
|
||||
# Create a new PR
|
||||
- name: Create a new Pull Request with new translated strings
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: |
|
||||
🌐(i18n) update translated strings
|
||||
|
||||
Update translated files with new translations
|
||||
title: 🌐(i18n) update translated strings
|
||||
body: |
|
||||
## Purpose
|
||||
|
||||
update translated strings
|
||||
|
||||
## Proposal
|
||||
|
||||
- [x] update translated strings
|
||||
branch: i18n/update-translations
|
||||
labels: i18n
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Update crowdin sources
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
needs: install-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# Backend i18n
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .
|
||||
working-directory: src/backend
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: Install gettext
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc
|
||||
- name: generate pot files
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate source translation file
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:extract
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Dependency reusable workflow
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '22.x'
|
||||
type: string
|
||||
with-front-dependencies-installation:
|
||||
type: boolean
|
||||
default: false
|
||||
with-build_mails:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
front-dependencies-installation:
|
||||
if: ${{ inputs.with-front-dependencies-installation == true }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
- name: Setup Node.js
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
build-mails:
|
||||
if: ${{ inputs.with-build_mails == true }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
|
||||
- name: Install yarn
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Install node dependencies
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build mails
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn build
|
||||
|
||||
- name: Cache mail templates
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
@@ -11,26 +11,9 @@ jobs:
|
||||
notify-argocd:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
|
||||
@@ -19,20 +19,9 @@ 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 }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -51,42 +40,28 @@ jobs:
|
||||
name: Run trivy scan (frontend)
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target frontend-production -f Dockerfile'
|
||||
docker-build-args: '--target frontend-production -f src/frontend/Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
|
||||
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/people-backend
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: create-version-json
|
||||
id: create-version-json
|
||||
uses: jsdaniell/create-json@v1.2.3
|
||||
@@ -108,32 +83,15 @@ jobs:
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/people-frontend
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
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
|
||||
@@ -144,12 +102,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
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
@@ -163,29 +125,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
id: notify
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
|
||||
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "people,secrets"
|
||||
@@ -9,6 +9,14 @@ on:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
# Call the reusable workflow to install dependencies
|
||||
dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
lint-git:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
@@ -20,14 +28,18 @@ jobs:
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep "print("
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep -E "(\bprint\(|\bpprint\()"
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
if: always()
|
||||
run: pip install --user requests gitlint
|
||||
- name: Lint commit messages added to main
|
||||
if: always()
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
check-changelog:
|
||||
@@ -41,7 +53,7 @@ jobs:
|
||||
with:
|
||||
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
|
||||
run: git log --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
|
||||
|
||||
lint-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -56,39 +68,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install-front:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
build-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -111,7 +93,7 @@ jobs:
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -128,7 +110,7 @@ jobs:
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -145,7 +127,7 @@ jobs:
|
||||
|
||||
test-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-mails, build-front]
|
||||
needs: [dependencies, build-front]
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -161,11 +143,13 @@ jobs:
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Download mails' templates
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
@@ -187,6 +171,8 @@ jobs:
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
run: |
|
||||
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
make update-keycloak-realm-app
|
||||
make add-dev-rsa-private-key-to-env
|
||||
make run
|
||||
|
||||
- name: Apply DRF migrations
|
||||
@@ -197,9 +183,6 @@ jobs:
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
|
||||
@@ -230,30 +213,6 @@ jobs:
|
||||
if: ${{ contains(needs.*.result, 'failure') }}
|
||||
run: exit 1
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
- name: Install node dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build mails
|
||||
run: yarn build
|
||||
- name: Persist mails' templates
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -265,7 +224,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.13'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
@@ -277,7 +236,7 @@ jobs:
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
needs: dependencies
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
@@ -312,15 +271,17 @@ jobs:
|
||||
run: |
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
- name: Download mails' templates
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.13'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install gettext (required to compile messages)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Release Chart
|
||||
run-name: Release Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
rm -rf ./src/helm/extra
|
||||
rm -rf ./src/helm/dimail
|
||||
rm -rf ./src/helm/maildev
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
linting: on
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -58,6 +58,9 @@ src/frontend/tsclient
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Celery beat
|
||||
src/backend/celerybeat-schedule
|
||||
|
||||
# Test & lint
|
||||
.coverage
|
||||
coverage.json
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
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
|
||||
+345
-15
@@ -8,6 +8,310 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(invitations) can delete domain invitations
|
||||
|
||||
## [1.22.2] - 2026-01-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(aliases) authorize special domain devnull in alias destinations #1029
|
||||
|
||||
## [1.22.1] - 2026-01-21
|
||||
|
||||
- 🔒️(organization) the first user is not admin #776
|
||||
- 🐛(admin) fix broken alias import #1021
|
||||
|
||||
## [1.22.0] - 2026-01-19
|
||||
|
||||
### Added
|
||||
- ✨(front) create, manage & delete aliases
|
||||
- ✨(domains) alias sorting and admin
|
||||
- ✨(aliases) delete all aliases in one call #1002
|
||||
|
||||
### Fixed
|
||||
- 🔒️(security) upgrade python version to fix vulnerability #1010
|
||||
- 🐛(dimail) ignore oxadmin when importing mailboxes from dimail #986
|
||||
- ✨(aliases) fix deleting single aliases #1002
|
||||
|
||||
### Changed
|
||||
- 🐛(dimail) allow mailboxes and aliases to have the same local part #986
|
||||
|
||||
### Removed
|
||||
- 🔥(plugins) remove CommuneCreation plugin
|
||||
|
||||
## [1.21.0] - 2025-12-05
|
||||
|
||||
- ✨(aliases) import existing aliases from dimail
|
||||
- 🛂(permissions) return 404 to users with no access to domain #985
|
||||
- ✨(aliases) can create, list and delete aliases #974
|
||||
|
||||
## [1.20.0] - 2025-10-22
|
||||
|
||||
- ✨(models) impose uniqueness on display name, to match ox's constraint
|
||||
- 🐛(dimail) catch duplicate displayname error
|
||||
- ✨(mailbox) synchronize password of newly created mailbox with Dimail's
|
||||
|
||||
## [1.19.1] - 2025-09-19
|
||||
|
||||
- 🐛(fix) add enabled update your mailbox
|
||||
|
||||
## [1.19.0] - 2025-09-03
|
||||
|
||||
- ✨(front) add modal update mailboxes #954
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) update mailboxes #934
|
||||
- ✨(api) give update rights to domain viewer on own mailbox #934
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(dimail) grab duplicate displayname error #961
|
||||
|
||||
### Changed
|
||||
|
||||
- 💥(sentry) remove `DJANGO_` before Sentry DSN env variable #957
|
||||
|
||||
## [1.18.2] - 2025-07-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(front) fix missing pagination mail domains #950
|
||||
|
||||
## [1.18.1] - 2025-07-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(front) fix missing pagination on mail domains #946
|
||||
|
||||
## [1.18.0] - 2025-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- 🐛(front) fix missing pagination mail domains
|
||||
- 🐛(front) fix button add mail domain
|
||||
- ✨(teams) add matrix webhook for teams #904
|
||||
- ✨(resource-server) add SCIM /Me endpoint #895
|
||||
- 🔧(git) set LF line endings for all text files #928
|
||||
|
||||
### Changed
|
||||
|
||||
- 🧑💻(docker) split frontend to another file #924
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(webhook) handle user on different home server than room server
|
||||
|
||||
## [1.17.0] - 2025-06-11
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add crisp script #914
|
||||
- ⚡️(fix) add error when mailbox create failed
|
||||
- ✨(mailbox) allow to reset password on mailboxes #834
|
||||
|
||||
## [1.16.0] - 2025-05-05
|
||||
|
||||
### Added
|
||||
|
||||
- 🔧(sentry) add Celery beat task integration #892
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(uiv2) change mail domains
|
||||
- 🛂(dimail) simplify interop with dimail
|
||||
- ✨(mailbox) remove secondary email as required field
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(drf) disable browsable HTML API renderer #897
|
||||
|
||||
## [1.15.0] - 2025-04-04
|
||||
|
||||
### Added
|
||||
|
||||
- 🧱(helm) add la-suite ingress path
|
||||
- ➕(backend) add django-lasuite dependency #858
|
||||
- ✨(plugins) add endpoint to list siret of active organizations #771
|
||||
- ✨(core) create AccountServiceAuthentication backend #771
|
||||
- ✨(core) create AccountService model #771
|
||||
- 🧱(helm) disable createsuperuser job by setting #863
|
||||
- 🔒️(passwords) add validators for production #850
|
||||
- ✨(domains) allow to re-run check on domain if status is failed
|
||||
- ✨(organization) add `is_active` field
|
||||
- ✨(domains) notify support when domain status changes #668
|
||||
- ✨(domains) define domain check interval as a settings
|
||||
- ✨(oidc) add simple introspection backend #832
|
||||
- 🧑💻(tasks) run management commands #814
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(plugins) rewrite plugin system as django app #844
|
||||
- 🔒️(users) restrict listable users to same organization #846
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(dimail) enhance sentry log
|
||||
- 🐛(oauth2) force JWT signed for /userinfo #804
|
||||
- 🐛(front) disable retries in useQuery and useInfiniteQuery #818
|
||||
|
||||
## [1.14.1] - 2025-03-17
|
||||
|
||||
## [1.14.0] - 2025-03-17
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(domains) enhance required action modal content
|
||||
- ✨(domains) add periodic tasks to fetch domain status
|
||||
- 🧑💻(docker) add celery beat to manage periodic tasks
|
||||
- ✨(organization) add metadata field #790
|
||||
- ⬆️(nginx) bump nginx-unprivileged to 1.27 #797
|
||||
- ✨(teams) allow broadly available teams #796
|
||||
- ✨(teams) update and enhance team invitation email
|
||||
- ✨(api) define dimail timeout as a setting
|
||||
- ✨(frontend) feature modal add new access role to domain
|
||||
- ✨(api) allow invitations for domain management #708
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(oauth2) force JWT signed for /userinfo #804
|
||||
- 🐛(oauth2) add ProConnect scopes #802
|
||||
- 🐛(domains) use a dedicated mail to invite user to manage domain
|
||||
- 🐛(mailbox) fix mailbox creation email language
|
||||
|
||||
## [1.13.1] - 2025-03-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(mailbox) fix migration to fill dn_email field
|
||||
|
||||
## [1.13.0] - 2025-03-04
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(oidc) people as an identity provider #638
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💄(domains) improve user experience and avoid repeat fix_domain operations
|
||||
- 👽️(dimail) increase timeout value for check domain API call
|
||||
- 🧱(helm) add resource-server ingress path #743
|
||||
- 🌐(backend) synchronize translations with crowdin again
|
||||
|
||||
## [1.12.1] - 2025-02-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- 👽️(dimail) increase timeout value for API calls
|
||||
|
||||
## [1.12.0] - 2025-02-18
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(domains) allow user to re-run all fetch domain data from dimail
|
||||
- ✨(domains) display DNS config expected for domain with required actions
|
||||
- ✨(domains) check status after creation
|
||||
- ✨(domains) display required actions to do on domain
|
||||
- ✨(plugin) add CommuneCreation plugin with domain provisioning #658
|
||||
- ✨(frontend) display action required status on domain
|
||||
- ✨(domains) store last health check details on MailDomain
|
||||
- ✨(domains) add support email field on domain
|
||||
|
||||
## [1.11.0] - 2025-02-07
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add count mailboxes to MailDomain serializer
|
||||
- ✨(dimail) manage 'action required' status for MailDomain
|
||||
- ✨(domains) add action required status on MailDomain
|
||||
- ✨(dimail) send pending mailboxes upon domain activation
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✨(auth) fix empty names from ProConnect #687
|
||||
- 🚑️(teams) do not display add button when disallowed #676
|
||||
- 🚑️(plugins) fix name from SIRET specific case #674
|
||||
- 🐛(api) restrict mailbox sync to enabled domains
|
||||
|
||||
## [1.10.1] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(dimail) management command to fetch domain status
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(scripts) adapts release script after moving the deployment part
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(dimail) fix imported mailboxes should be enabled instead of pending #659
|
||||
- ⚡️(api) add missing cache for stats endpoint
|
||||
|
||||
## [1.10.0] - 2025-01-21
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) create stats endpoint
|
||||
- ✨(teams) add Team dependencies #560
|
||||
- ✨(organization) add admin action for plugin #640
|
||||
- ✨(anct) fetch and display organization names of communes #583
|
||||
- ✨(frontend) display email if no username #562
|
||||
- 🧑💻(oidc) add ability to pull registration ID (e.g. SIRET) from OIDC #577
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) improve e2e tests avoiding race condition from mocks #641
|
||||
- 🐛(backend) fix flaky test with search contact #605
|
||||
- 🐛(backend) fix flaky test with team access #646
|
||||
- 🧑💻(dimail) remove 'NoneType: None' log in debug mode
|
||||
- 🐛(frontend) fix flaky e2e test #636
|
||||
- 🐛(frontend) fix disable mailbox button display #631
|
||||
- 🐛(backend) fix dimail call despite mailbox creation failure on our side
|
||||
- 🧑💻(user) fix the User.language infinite migration #611
|
||||
|
||||
## [1.9.1] - 2024-12-18
|
||||
|
||||
## [1.9.0] - 2024-12-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) fix manage roles on domain admin view
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add admin action to check domain health
|
||||
- ✨(dimail) check domain health
|
||||
- ✨(frontend) disable mailbox and allow to create pending mailbox
|
||||
- ✨(organizations) add siret to name conversion #584
|
||||
- 💄(frontend) redirect home according to abilities #588
|
||||
- ✨(maildomain_access) add API endpoint to search users #508
|
||||
|
||||
## [1.8.0] - 2024-12-12
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(contacts) add "abilities" to API endpoint data #585
|
||||
- ✨(contacts) allow filter list API with email
|
||||
- ✨(contacts) return profile contact from same organization
|
||||
- ✨(dimail) automate allows requests to dimail
|
||||
- ✨(contacts) add notes & force full_name #565
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(contacts) move user profile to contact #572
|
||||
- ♻️(contacts) split api test module in actions #573
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) fix manage roles on domain admin view
|
||||
- 🐛(mailbox) fix activate mailbox feature
|
||||
- 🔧(helm) fix the configuration environment #579
|
||||
|
||||
## [1.7.1] - 2024-11-28
|
||||
|
||||
## [1.7.0] - 2024-11-28
|
||||
|
||||
### Added
|
||||
@@ -186,18 +490,44 @@ and this project adheres to
|
||||
- ✨(domains) create and manage domains on admin + API
|
||||
- ✨(domains) mailbox creation + link to email provisioning API
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.7.0...main
|
||||
[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
|
||||
[unreleased]: https://github.com/suitenumerique/people/compare/v1.22.2...main
|
||||
[1.22.2]: https://github.com/suitenumerique/people/releases/v1.22.2
|
||||
[1.22.1]: https://github.com/suitenumerique/people/releases/v1.22.1
|
||||
[1.22.0]: https://github.com/suitenumerique/people/releases/v1.22.0
|
||||
[1.21.0]: https://github.com/suitenumerique/people/releases/v1.21.0
|
||||
[1.20.0]: https://github.com/suitenumerique/people/releases/v1.20.0
|
||||
[1.19.1]: https://github.com/suitenumerique/people/releases/v1.19.1
|
||||
[1.19.0]: https://github.com/suitenumerique/people/releases/v1.19.0
|
||||
[1.18.2]: https://github.com/suitenumerique/people/releases/v1.18.2
|
||||
[1.18.1]: https://github.com/suitenumerique/people/releases/v1.18.1
|
||||
[1.18.0]: https://github.com/suitenumerique/people/releases/v1.18.0
|
||||
[1.17.0]: https://github.com/suitenumerique/people/releases/v1.17.0
|
||||
[1.16.0]: https://github.com/suitenumerique/people/releases/v1.16.0
|
||||
[1.15.0]: https://github.com/suitenumerique/people/releases/v1.15.0
|
||||
[1.14.1]: https://github.com/suitenumerique/people/releases/v1.14.1
|
||||
[1.14.0]: https://github.com/suitenumerique/people/releases/v1.14.0
|
||||
[1.13.1]: https://github.com/suitenumerique/people/releases/v1.13.1
|
||||
[1.13.0]: https://github.com/suitenumerique/people/releases/v1.13.0
|
||||
[1.12.1]: https://github.com/suitenumerique/people/releases/v1.12.1
|
||||
[1.12.0]: https://github.com/suitenumerique/people/releases/v1.12.0
|
||||
[1.11.0]: https://github.com/suitenumerique/people/releases/v1.11.0
|
||||
[1.10.1]: https://github.com/suitenumerique/people/releases/v1.10.1
|
||||
[1.10.0]: https://github.com/suitenumerique/people/releases/v1.10.0
|
||||
[1.9.1]: https://github.com/suitenumerique/people/releases/v1.9.1
|
||||
[1.9.0]: https://github.com/suitenumerique/people/releases/v1.9.0
|
||||
[1.8.0]: https://github.com/suitenumerique/people/releases/v1.8.0
|
||||
[1.7.1]: https://github.com/suitenumerique/people/releases/v1.7.1
|
||||
[1.7.0]: https://github.com/suitenumerique/people/releases/v1.7.0
|
||||
[1.6.1]: https://github.com/suitenumerique/people/releases/v1.6.1
|
||||
[1.6.0]: https://github.com/suitenumerique/people/releases/v1.6.0
|
||||
[1.5.0]: https://github.com/suitenumerique/people/releases/v1.5.0
|
||||
[1.4.1]: https://github.com/suitenumerique/people/releases/v1.4.1
|
||||
[1.4.0]: https://github.com/suitenumerique/people/releases/v1.4.0
|
||||
[1.3.1]: https://github.com/suitenumerique/people/releases/v1.3.1
|
||||
[1.3.0]: https://github.com/suitenumerique/people/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/suitenumerique/people/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/suitenumerique/people/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/suitenumerique/people/releases/v1.1.0
|
||||
[1.0.2]: https://github.com/suitenumerique/people/releases/v1.0.2
|
||||
[1.0.1]: https://github.com/suitenumerique/people/releases/v1.0.1
|
||||
[1.0.0]: https://github.com/suitenumerique/people/releases/v1.0.0
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# Contributing to the Project
|
||||
|
||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
|
||||
|
||||
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
||||
|
||||
## Help us with translations
|
||||
|
||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-people).
|
||||
Your language is not there? Request it on our Crowdin page 😊.
|
||||
|
||||
## Creating an Issue
|
||||
|
||||
When creating an issue, please provide the following details:
|
||||
|
||||
1. **Title**: A concise and descriptive title for the issue.
|
||||
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
|
||||
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
|
||||
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
|
||||
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
|
||||
|
||||
## Selecting an issue
|
||||
|
||||
We use a [GitHub Project](https://github.com/orgs/suitenumerique/projects/1) in order to prioritize our workload.
|
||||
|
||||
Please check in priority the issues that are in the **TODO cette semaine** column.
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
All commit messages must adhere to the following format:
|
||||
|
||||
`<gitmoji>(type) title description`
|
||||
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change, starting with a lowercase character.
|
||||
* **description**: Include additional details about what was changed and why.
|
||||
|
||||
### Example Commit Message
|
||||
|
||||
```
|
||||
✨(frontend) add user authentication logic
|
||||
|
||||
Implemented login and signup features, and integrated OAuth2 for social login.
|
||||
```
|
||||
|
||||
## Changelog Update
|
||||
|
||||
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
|
||||
|
||||
### Example Changelog Message
|
||||
```
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(frontend) add AI to the project #321
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||
|
||||
### Don't forget to:
|
||||
- check your commits
|
||||
- check the linting: `make lint && make frontend-lint`
|
||||
- check the tests: `make test`
|
||||
- add a changelog entry
|
||||
- squash your commits
|
||||
- rebase your branch on the latest `main` branch before pushing your changes `git pull --rebase origin main`
|
||||
|
||||
### Process to have a nice commit history
|
||||
|
||||
In the life time of your PR, you may need to add commits to fix things or add new features.
|
||||
Commit after commit, your PR will be full of commits but you have to clean it up with the following commands before merging on `main`:
|
||||
|
||||
Gradually you can use `--fixup` to add commits to some of previous commit ( for example 1234567890).
|
||||
```
|
||||
git commit --fixup=1234567890
|
||||
```
|
||||
Then, you can squash your commits with the following command:
|
||||
```
|
||||
git rebase --autosquash -i -r HEAD~<number-of-commits>
|
||||
```
|
||||
|
||||
Or you can use:
|
||||
```
|
||||
git rebase -i HEAD~<number-of-commits>
|
||||
```
|
||||
and move, squash and/or rename your commits manually. You can squash them with previous commit replacing the `pick` by `f`. You can rename them with replacing the `pick` by `r`.
|
||||
Tada! You have a clean commit history.
|
||||
|
||||
Once all the required tests have passed, you can request a review from the project maintainers.
|
||||
|
||||
## Code Style
|
||||
|
||||
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
|
||||
|
||||
## Tests
|
||||
|
||||
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
|
||||
|
||||
## Asking for Help
|
||||
|
||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||
|
||||
Thank you for your contributions! 👍
|
||||
+3
-52
@@ -1,64 +1,15 @@
|
||||
# Django People
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.12.6-alpine3.20 AS base
|
||||
FROM python:3.13.11-alpine AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
### ---- Front-end dependencies image ----
|
||||
FROM node:20 AS frontend-deps
|
||||
|
||||
WORKDIR /deps
|
||||
|
||||
COPY ./src/frontend/package.json ./package.json
|
||||
COPY ./src/frontend/yarn.lock ./yarn.lock
|
||||
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
|
||||
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
|
||||
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
|
||||
|
||||
RUN yarn --frozen-lockfile
|
||||
|
||||
### ---- Front-end builder dev image ----
|
||||
FROM node:20 AS frontend-builder-dev
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
COPY --from=frontend-deps /deps/node_modules ./node_modules
|
||||
COPY ./src/frontend .
|
||||
|
||||
WORKDIR ./apps/desk
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-builder-dev AS frontend-builder
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
COPY --from=frontend-builder \
|
||||
/builder/apps/desk/out \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
|
||||
@@ -72,7 +23,7 @@ RUN mkdir /install && \
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 AS mail-builder
|
||||
FROM node:22 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
|
||||
@@ -41,11 +41,9 @@ DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm --no-deps
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
WAIT_KC_DB = $(COMPOSE_RUN) dockerize -wait tcp://kc_postgresql:5432 -timeout 60s
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
@@ -72,29 +70,45 @@ data/static:
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/france \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql
|
||||
.PHONY: create-env-files
|
||||
|
||||
add-dev-rsa-private-key-to-env: ## Add a generated RSA private key to the env file
|
||||
@echo "Generating RSA private key PEM for development..."
|
||||
@mkdir -p env.d/development/rsa
|
||||
@openssl genrsa -out env.d/development/rsa/private.pem 2048
|
||||
@echo -n "\nOAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY=\"" >> env.d/development/common
|
||||
@openssl rsa -in env.d/development/rsa/private.pem -outform PEM >> env.d/development/common
|
||||
@echo "\"" >> env.d/development/common
|
||||
@rm -rf env.d/development/rsa
|
||||
.PHONY: add-dev-rsa-private-key-to-env
|
||||
|
||||
update-keycloak-realm-app: ## Create the Keycloak realm for the project
|
||||
@echo "$(BOLD)Creating Keycloak realm for 'app'$(RESET)"
|
||||
@sed -i 's|http://app-dev:8000|http://app:8000|g' ./docker/auth/realm.json
|
||||
.PHONY: update-keycloak-realm-app
|
||||
|
||||
bootstrap: ## Prepare Docker images for the project and install frontend dependencies
|
||||
bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
run \
|
||||
run-dev \
|
||||
migrate \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build \
|
||||
dimail-setup-db \
|
||||
install-front-desk
|
||||
dimail-setup-db
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev
|
||||
@$(COMPOSE) build dimail
|
||||
.PHONY: build
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -105,17 +119,14 @@ logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@$(COMPOSE) up --force-recreate -d app-dev
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d keycloak
|
||||
@$(COMPOSE) up -d dimail
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_KC_DB)
|
||||
@$(WAIT_DB)
|
||||
run: ## start the wsgi (production) and servers with production Docker images
|
||||
@$(COMPOSE) up --force-recreate --detach app frontend celery celery-beat nginx maildev
|
||||
.PHONY: run
|
||||
|
||||
run-dev: ## start the servers in development mode (watch) Docker images
|
||||
@$(COMPOSE) up --force-recreate --detach app-dev dimail frontend-dev celery-dev celery-beat-dev nginx maildev
|
||||
.PHONY: run-dev
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE) ps
|
||||
.PHONY: status
|
||||
@@ -129,6 +140,7 @@ stop: ## stop the development server using Docker
|
||||
demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MAKE) resetdb
|
||||
@$(MANAGE) create_demo
|
||||
@$(MAKE) dimail-setup-db
|
||||
.PHONY: demo
|
||||
|
||||
|
||||
@@ -155,6 +167,14 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
||||
bin/pylint --diff-only=origin/main
|
||||
.PHONY: lint-pylint
|
||||
|
||||
lint-front: ## lint front-end sources with eslint
|
||||
cd $(PATH_FRONT) && yarn lint
|
||||
.PHONY: lint-front
|
||||
|
||||
lint-front-fix: ## fix front-end sources with eslint
|
||||
cd $(PATH_FRONT) && yarn lint-fix
|
||||
.PHONY: lint-front-fix
|
||||
|
||||
test: ## run project tests
|
||||
@$(MAKE) test-back-parallel
|
||||
.PHONY: test
|
||||
@@ -175,22 +195,16 @@ test-coverage: ## compute, display and save test coverage
|
||||
|
||||
makemigrations: ## run django makemigrations for the people project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) makemigrations $(ARGS)
|
||||
.PHONY: makemigrations
|
||||
|
||||
migrate: ## run django migrations for the people project.
|
||||
@echo "$(BOLD)Running migrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) migrate $(ARGS)
|
||||
.PHONY: migrate
|
||||
|
||||
showmigrations: ## run django showmigrations for the people project.
|
||||
@echo "$(BOLD)Running showmigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) showmigrations $(ARGS)
|
||||
.PHONY: showmigrations
|
||||
|
||||
@@ -205,6 +219,7 @@ back-i18n-compile: ## compile the gettext files
|
||||
.PHONY: back-i18n-compile
|
||||
|
||||
back-i18n-generate: ## create the .pot files used for i18n
|
||||
back-i18n-generate: mails-build crowdin-download-sources
|
||||
@$(MANAGE) makemessages -a --keep-pot
|
||||
.PHONY: back-i18n-generate
|
||||
|
||||
@@ -228,6 +243,9 @@ resetdb: ## flush database and create a superuser "admin"
|
||||
env.d/development/common:
|
||||
cp -n env.d/development/common.dist env.d/development/common
|
||||
|
||||
env.d/development/france:
|
||||
cp -n env.d/development/france.dist env.d/development/france
|
||||
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
@@ -259,6 +277,7 @@ i18n-compile: \
|
||||
|
||||
i18n-generate: ## create the .pot files and extract frontend messages
|
||||
i18n-generate: \
|
||||
crowdin-download-sources \
|
||||
back-i18n-generate \
|
||||
frontend-i18n-generate
|
||||
.PHONY: i18n-generate
|
||||
@@ -277,19 +296,21 @@ i18n-generate-and-upload: \
|
||||
|
||||
# -- INTEROPERABILTY
|
||||
# -- Dimail configuration
|
||||
|
||||
recreate-dimail-container:
|
||||
@$(COMPOSE) up --force-recreate -d dimail
|
||||
.PHONY: recreate-dimail-container
|
||||
|
||||
dimail-setup-db:
|
||||
@$(COMPOSE) up --force-recreate -d dimail
|
||||
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
|
||||
@$(MANAGE) setup_dimail_db
|
||||
@$(MANAGE) setup_dimail_db --populate-from-people
|
||||
.PHONY: dimail-setup-db
|
||||
|
||||
# -- Mail generator
|
||||
|
||||
mails-build: ## Convert mjml files to html and text
|
||||
mails-clean-templates: ## Clean the generated mail templates directory
|
||||
@echo "$(BOLD)Cleaning mail templates directory$(RESET)"
|
||||
@rm -rf ./src/backend/core/templates/mail
|
||||
@mkdir -p ./src/backend/core/templates/mail
|
||||
.PHONY: mails-clean-templates
|
||||
|
||||
mails-build: mails-clean-templates ## Convert mjml files to html and text
|
||||
@$(MAIL_YARN) build
|
||||
.PHONY: mails-build
|
||||
|
||||
@@ -328,7 +349,7 @@ help:
|
||||
|
||||
# Front
|
||||
install-front-desk: ## Install the frontend dependencies of app Desk
|
||||
cd $(PATH_FRONT_DESK) && yarn
|
||||
cd $(PATH_FRONT_DESK) && yarn && yarn playwright install chromium
|
||||
.PHONY: install-front-desk
|
||||
|
||||
run-front-desk: ## Start app Desk
|
||||
@@ -354,10 +375,66 @@ start-kind: ## Create the kubernetes cluster
|
||||
./bin/start-kind.sh
|
||||
.PHONY: start-kind
|
||||
|
||||
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
||||
./bin/install-external-secrets.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
tilt-up: ## start tilt - k8s local development
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: tilt-up
|
||||
|
||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
release: ## helper for release and deployment
|
||||
python scripts/release.py
|
||||
.PHONY: release
|
||||
|
||||
install-secret: ## install the kubernetes secrets from Vaultwarden
|
||||
if kubectl -n desk get secrets bitwarden-cli-desk; then \
|
||||
echo "Secret already present"; \
|
||||
else \
|
||||
echo "Please provide the following information:"; \
|
||||
read -p "Enter your vaultwarden email login: " LOGIN; \
|
||||
read -p "Enter your vaultwarden password: " PASSWORD; \
|
||||
read -p "Enter your vaultwarden server url: " URL; \
|
||||
echo "\nCreate vaultwarden secret"; \
|
||||
echo "apiVersion: v1" > /tmp/secret.yaml; \
|
||||
echo "kind: Secret" >> /tmp/secret.yaml; \
|
||||
echo "metadata:" >> /tmp/secret.yaml; \
|
||||
echo " name: bitwarden-cli-desk" >> /tmp/secret.yaml; \
|
||||
echo " namespace: desk" >> /tmp/secret.yaml; \
|
||||
echo "type: Opaque" >> /tmp/secret.yaml; \
|
||||
echo "stringData:" >> /tmp/secret.yaml; \
|
||||
echo " BW_HOST: $$URL" >> /tmp/secret.yaml; \
|
||||
echo " BW_PASSWORD: $$PASSWORD" >> /tmp/secret.yaml; \
|
||||
echo " BW_USERNAME: $$LOGIN" >> /tmp/secret.yaml; \
|
||||
kubectl -n desk apply -f /tmp/secret.yaml;\
|
||||
rm -f /tmp/secret.yaml; \
|
||||
fi; \
|
||||
if kubectl get ns external-secrets; then \
|
||||
echo "External secret already deployed"; \
|
||||
else \
|
||||
helm repo add external-secrets https://charts.external-secrets.io; \
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n external-secrets \
|
||||
--create-namespace \
|
||||
--set installCRDs=true; \
|
||||
fi
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
fetch-domain-status:
|
||||
@$(MANAGE) fetch_domain_status
|
||||
.PHONY: fetch-domain-status
|
||||
|
||||
# -- Keycloak related
|
||||
create-new-client: ## run the add-keycloak-client.sh script for keycloak.
|
||||
@echo "$(BOLD)Running add-keycloak-client.sh$(RESET)"
|
||||
@$(COMPOSE_RUN) \
|
||||
-v ./scripts/keycloak/add-keycloak-client.sh:/tmp/add-keycloak-client.sh \
|
||||
--entrypoint="/tmp/add-keycloak-client.sh" \
|
||||
keycloak \
|
||||
$(filter-out $@,$(MAKECMDGOALS))
|
||||
.PHONY: create-new-client
|
||||
|
||||
@@ -33,7 +33,7 @@ The easiest way to start working on the project is to use GNU Make:
|
||||
$ make bootstrap
|
||||
```
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs
|
||||
This command builds the `app-dev` container, installs dependencies, performs
|
||||
database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
dependency-related or migration-related issues.
|
||||
@@ -46,6 +46,12 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||
$ make run
|
||||
```
|
||||
|
||||
or if you want to run them in development mode (with live reloading):
|
||||
|
||||
```bash
|
||||
$ make run-dev
|
||||
```
|
||||
|
||||
You can check all available Make rules using:
|
||||
|
||||
```bash
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Security is very important to us.
|
||||
|
||||
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at support-regie@numerique.gouv.fr
|
||||
|
||||
We appreciate your effort to make People more secure.
|
||||
|
||||
## Vulnerability disclosure policy
|
||||
|
||||
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
|
||||
|
||||
1. The Maintainers team will handle the fix as usual (Pull Request,
|
||||
release).
|
||||
2. In the release notes, we will include the identification numbers from the
|
||||
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
|
||||
and Exposures (CVE) identifier for the vulnerability.
|
||||
3. Once this grace period has passed, we will publish the vulnerability.
|
||||
|
||||
By adhering to this security policy, we aim to address security concerns
|
||||
effectively and responsibly in our open source software project.
|
||||
+46
-1
@@ -29,7 +29,9 @@ docker_build(
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n desk -e dev template .'))
|
||||
# helmfile in docker mount the current working directory and the helmfile.yaml
|
||||
# requires the keycloak config in another directory
|
||||
k8s_yaml(local('cd .. && helmfile -n desk -e ${DEV_ENV:-dev} template --file ./src/helm/helmfile.yaml'))
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
@@ -56,3 +58,46 @@ cmd_button('Migrate db',
|
||||
icon_name='developer_board',
|
||||
text='Run database migration',
|
||||
)
|
||||
|
||||
# Command to reset DB
|
||||
reset_db = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py flush --no-input
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py createsuperuser --username admin@example.com --password admin
|
||||
'''
|
||||
cmd_button('Reset DB',
|
||||
argv=['sh', '-c', reset_db],
|
||||
resource='desk-backend',
|
||||
icon_name='developer_board',
|
||||
text='Reset DB',
|
||||
)
|
||||
|
||||
# Command to create demo data
|
||||
populate_people_with_demo_data = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py create_demo --force
|
||||
'''
|
||||
cmd_button('Populate with demo data',
|
||||
argv=['sh', '-c', populate_people_with_demo_data],
|
||||
resource='desk-backend',
|
||||
icon_name='developer_board',
|
||||
text='Populate with demo data',
|
||||
)
|
||||
|
||||
# Command to created domain/users/access from people to dimail
|
||||
populate_dimail_from_people = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py setup_dimail_db --populate-from-people
|
||||
'''
|
||||
cmd_button('Populate dimail from people',
|
||||
argv=['sh', '-c', populate_dimail_from_people],
|
||||
resource='desk-backend',
|
||||
icon_name='developer_board',
|
||||
text='Populate dimail from people',
|
||||
)
|
||||
|
||||
@@ -64,6 +64,23 @@ function _dc_run() {
|
||||
_docker_compose run --rm $user_args "$@"
|
||||
}
|
||||
|
||||
# _dc_run_no_deps: wrap docker compose run command without dependencies
|
||||
#
|
||||
# usage: _dc_run_no_deps [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose run command options
|
||||
# ARGS : docker compose run command arguments
|
||||
function _dc_run_no_deps() {
|
||||
_set_user
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose run --no-deps --rm $user_args "$@"
|
||||
}
|
||||
|
||||
# _dc_exec: wrap docker compose exec command
|
||||
#
|
||||
# usage: _dc_exec [options] [ARGS...]
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
NAMESPACE=${1:-desk}
|
||||
SECRET_NAME=${2:-bitwarden-cli-desk}
|
||||
TEMP_SECRET_FILE=$(mktemp)
|
||||
|
||||
|
||||
cleanup() {
|
||||
rm -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
# Check if kubectl is available
|
||||
check_prerequisites() {
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
echo "Error: kubectl is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if secret already exists
|
||||
check_secret_exists() {
|
||||
kubectl -n "${NAMESPACE}" get secrets "${SECRET_NAME}" &> /dev/null
|
||||
}
|
||||
|
||||
|
||||
# Collect user input securely
|
||||
get_user_input() {
|
||||
echo "Please provide the following information:"
|
||||
read -p "Enter your Vaultwarden email login: " LOGIN
|
||||
read -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||
echo
|
||||
read -p "Enter your Vaultwarden server url: " URL
|
||||
}
|
||||
|
||||
# Create and apply the secret
|
||||
create_secret() {
|
||||
cat > "${TEMP_SECRET_FILE}" << EOF
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${SECRET_NAME}
|
||||
namespace: ${NAMESPACE}
|
||||
type: Opaque
|
||||
stringData:
|
||||
BW_HOST: ${URL}
|
||||
BW_PASSWORD: ${PASSWORD}
|
||||
BW_USERNAME: ${LOGIN}
|
||||
EOF
|
||||
|
||||
kubectl -n "${NAMESPACE}" apply -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
|
||||
# Install external-secrets using Helm
|
||||
install_external_secrets() {
|
||||
if ! kubectl get ns external-secrets &>/dev/null; then
|
||||
echo "Installing external-secrets…"
|
||||
helm repo add external-secrets https://charts.external-secrets.io
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n external-secrets \
|
||||
--create-namespace \
|
||||
--set installCRDs=true
|
||||
else
|
||||
echo "External secrets already deployed"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
check_prerequisites
|
||||
|
||||
if check_secret_exists; then
|
||||
echo "Secret '${SECRET_NAME}' already present in namespace '${NAMESPACE}'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e ${TEMP_SECRET_FILE}
|
||||
|
||||
get_user_input
|
||||
echo -e "\nCreating Vaultwarden secret…"
|
||||
create_secret
|
||||
install_external_secrets
|
||||
|
||||
echo "Secret installation completed successfully"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+1
-1
@@ -35,4 +35,4 @@ fi
|
||||
|
||||
# Fix docker vs local path when project sources are mounted as a volume
|
||||
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
|
||||
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
|
||||
_dc_run_no_deps app-dev pylint "${paths[@]}" "${args[@]}"
|
||||
|
||||
Regular → Executable
+2
-101
@@ -1,102 +1,3 @@
|
||||
#!/bin/sh
|
||||
set -o errexit
|
||||
#!/usr/bin/env bash
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
|
||||
# 0. Create ca
|
||||
echo "0. Create ca"
|
||||
mkcert -install
|
||||
cd /tmp
|
||||
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
|
||||
cd $CURRENT_DIR
|
||||
|
||||
# 1. Create registry container unless it already exists
|
||||
echo "1. Create registry container unless it already exists"
|
||||
reg_name='kind-registry'
|
||||
reg_port='5001'
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
|
||||
docker run \
|
||||
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
|
||||
registry:2
|
||||
fi
|
||||
|
||||
# 2. Create kind cluster with containerd registry config dir enabled
|
||||
echo "2. Create kind cluster with containerd registry config dir enabled"
|
||||
# TODO: kind will eventually enable this by default and this patch will
|
||||
# be unnecessary.
|
||||
#
|
||||
# See:
|
||||
# https://github.com/kubernetes-sigs/kind/issues/2875
|
||||
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
|
||||
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
|
||||
cat <<EOF | kind create cluster --config=-
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: kindest/node:v1.27.3
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: InitConfiguration
|
||||
nodeRegistration:
|
||||
kubeletExtraArgs:
|
||||
node-labels: "ingress-ready=true"
|
||||
extraPortMappings:
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
EOF
|
||||
|
||||
# 3. Add the registry config to the nodes
|
||||
echo "3. Add the registry config to the nodes"
|
||||
#
|
||||
# This is necessary because localhost resolves to loopback addresses that are
|
||||
# network-namespace local.
|
||||
# In other words: localhost in the container is not localhost on the host.
|
||||
#
|
||||
# We want a consistent name that works from both ends, so we tell containerd to
|
||||
# alias localhost:${reg_port} to the registry container when pulling images
|
||||
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
|
||||
for node in $(kind get nodes); do
|
||||
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
|
||||
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
|
||||
[host."http://${reg_name}:5000"]
|
||||
EOF
|
||||
done
|
||||
|
||||
# 4. Connect the registry to the cluster network if not already connected
|
||||
echo "4. Connect the registry to the cluster network if not already connected"
|
||||
# This allows kind to bootstrap the network but ensures they're on the same network
|
||||
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
|
||||
docker network connect "kind" "${reg_name}"
|
||||
fi
|
||||
|
||||
# 5. Document the local registry
|
||||
echo "5. Document the local registry"
|
||||
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: local-registry-hosting
|
||||
namespace: kube-public
|
||||
data:
|
||||
localRegistryHosting.v1: |
|
||||
host: "localhost:${reg_port}"
|
||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||
EOF
|
||||
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
||||
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- desk
|
||||
|
||||
+153
-33
@@ -1,16 +1,22 @@
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
platform: linux/amd64
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "15432:5432"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
|
||||
mailcatcher:
|
||||
image: sj26/mailcatcher:latest
|
||||
maildev:
|
||||
image: maildev/maildev:latest
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
@@ -22,11 +28,13 @@ services:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-development
|
||||
pull_policy: never
|
||||
environment:
|
||||
- PYLINTHOME=/app/.pylint.d
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/france
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "8071:8000"
|
||||
@@ -35,11 +43,67 @@ services:
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- dimail
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
maildev:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: frontend-dev
|
||||
image: people:frontend-development
|
||||
pull_policy: never
|
||||
volumes:
|
||||
- ./src/frontend:/home/frontend
|
||||
- /home/frontend/node_modules
|
||||
- /home/frontend/apps/desk/node_modules
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
target: backend-production
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-production
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/france
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "8071:8000"
|
||||
volumes:
|
||||
- ./data/media:/data/media
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8071"
|
||||
image: people:frontend-production
|
||||
pull_policy: build
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-development
|
||||
@@ -54,26 +118,29 @@ services:
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- app-dev
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
app-dev:
|
||||
condition: service_started
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
target: backend-production
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
celery-beat-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-production
|
||||
image: people:backend-development
|
||||
command: ["celery", "-A", "people.celery_app", "beat", "-l", "DEBUG"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -85,7 +152,29 @@ services:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
depends_on:
|
||||
- app
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
app:
|
||||
condition: service_started
|
||||
|
||||
celery-beat:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-production
|
||||
command: ["celery", "-A", "people.celery_app", "beat", "-l", "INFO"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
@@ -94,14 +183,12 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- app
|
||||
- keycloak
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:3.16.0
|
||||
image: crowdin/cli:4.6.1
|
||||
volumes:
|
||||
- ".:/app"
|
||||
env_file:
|
||||
@@ -110,7 +197,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:18
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
@@ -118,16 +205,23 @@ services:
|
||||
- ".:/app"
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
@@ -137,6 +231,8 @@ services:
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
@@ -147,17 +243,41 @@ services:
|
||||
KC_DB_USERNAME: people
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready" ]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
dimail:
|
||||
entrypoint: /opt/dimail-api/start-dev.sh
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.0.13
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.5.2
|
||||
pull_policy: always
|
||||
environment:
|
||||
DIMAIL_MODE: FAKE
|
||||
DIMAIL_JWT_SECRET: fake_jwt_secret
|
||||
ports:
|
||||
- "8001:8000"
|
||||
|
||||
flower-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-development
|
||||
command: ["celery", "-A", "people.celery_app", "flower", "--port=5555"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "5555:5555"
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
depends_on:
|
||||
- celery-dev
|
||||
- redis
|
||||
|
||||
+289
-54
@@ -26,7 +26,7 @@
|
||||
"oauth2DeviceCodeLifespan": 600,
|
||||
"oauth2DevicePollingInterval": 5,
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": true,
|
||||
"registrationEmailAsUsername": false,
|
||||
"rememberMe": true,
|
||||
@@ -50,6 +50,9 @@
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -58,12 +61,32 @@
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.marie",
|
||||
"email": "marie.varzy@gmail.com",
|
||||
"firstName": "Marie",
|
||||
"lastName": "Delamairie",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "21510339100011"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.marie"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
"email": "user-e2e-chromium@example.org",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -74,10 +97,13 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.e2e",
|
||||
"email": "user-e2e-webkit@example.org",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -88,10 +114,13 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.e2e",
|
||||
"email": "user-e2e-firefox@example.org",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -101,211 +130,256 @@
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member",
|
||||
"email": "jean.team-member@example.com",
|
||||
"username": "e2e.team-member",
|
||||
"email": "e2e.team-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member"
|
||||
"value": "password-e2e.team-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator",
|
||||
"email": "jean.team-administrator@example.com",
|
||||
"username": "e2e.team-administrator",
|
||||
"email": "e2e.team-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator"
|
||||
"value": "password-e2e.team-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner",
|
||||
"email": "jean.team-owner@example.com",
|
||||
"username": "e2e.team-owner",
|
||||
"email": "e2e.team-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner"
|
||||
"value": "password-e2e.team-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-member",
|
||||
"email": "jean.mail-member@example.com",
|
||||
"username": "e2e.mail-member",
|
||||
"email": "e2e.mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Member",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-member"
|
||||
"value": "password-e2e.mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-administrator",
|
||||
"email": "jean.mail-administrator@example.com",
|
||||
"username": "e2e.mail-administrator",
|
||||
"email": "e2e.mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-administrator"
|
||||
"value": "password-e2e.mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-owner",
|
||||
"email": "jean.mail-owner@example.com",
|
||||
"username": "e2e.mail-owner",
|
||||
"email": "e2e.mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-owner"
|
||||
"value": "password-e2e.mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-member",
|
||||
"email": "jean.team-member-mail-member@example.com",
|
||||
"username": "e2e.team-member-mail-member",
|
||||
"email": "e2e.team-member-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Member",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-member"
|
||||
"value": "password-e2e.team-member-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-administrator",
|
||||
"email": "jean.team-member-mail-administrator@example.com",
|
||||
"username": "e2e.team-member-mail-administrator",
|
||||
"email": "e2e.team-member-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-administrator"
|
||||
"value": "password-e2e.team-member-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-owner",
|
||||
"email": "jean.team-member-mail-owner@example.com",
|
||||
"username": "e2e.team-member-mail-owner",
|
||||
"email": "e2e.team-member-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Owner",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-owner"
|
||||
"value": "password-e2e.team-member-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-member",
|
||||
"email": "jean.team-administrator-mail-member@example.com",
|
||||
"username": "e2e.team-administrator-mail-member",
|
||||
"email": "e2e.team-administrator-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Member",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-member"
|
||||
"value": "password-e2e.team-administrator-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-administrator",
|
||||
"email": "jean.team-administrator-mail-administrator@example.com",
|
||||
"username": "e2e.team-administrator-mail-administrator",
|
||||
"email": "e2e.team-administrator-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-administrator"
|
||||
"value": "password-e2e.team-administrator-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-owner",
|
||||
"email": "jean.team-administrator-mail-owner@example.com",
|
||||
"username": "e2e.team-administrator-mail-owner",
|
||||
"email": "e2e.team-administrator-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Owner",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-owner"
|
||||
"value": "password-e2e.team-administrator-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-member",
|
||||
"email": "jean.team-owner-mail-member@example.com",
|
||||
"username": "e2e.team-owner-mail-member",
|
||||
"email": "e2e.team-owner-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Member",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-member"
|
||||
"value": "password-e2e.team-owner-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-administrator",
|
||||
"email": "jean.team-owner-mail-administrator@example.com",
|
||||
"username": "e2e.team-owner-mail-administrator",
|
||||
"email": "e2e.team-owner-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-administrator"
|
||||
"value": "password-e2e.team-owner-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-owner",
|
||||
"email": "jean.team-owner-mail-owner@example.com",
|
||||
"username": "e2e.team-owner-mail-owner",
|
||||
"email": "e2e.team-owner-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-owner"
|
||||
"value": "password-e2e.team-owner-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
@@ -695,9 +769,17 @@
|
||||
"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false,
|
||||
"webAuthnPolicyPasswordlessAcceptableAaguids": [],
|
||||
"scopeMappings": [
|
||||
{
|
||||
"clientScope": "siret",
|
||||
"roles": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientScope": "offline_access",
|
||||
"roles": ["offline_access"]
|
||||
"roles": [
|
||||
"offline_access"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopeMappings": {
|
||||
@@ -947,6 +1029,7 @@
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"siret",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
@@ -1107,6 +1190,35 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eb220fbb-02ac-4105-95a3-727954f6565d",
|
||||
"name": "siret",
|
||||
"description": "siret",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "false",
|
||||
"gui.order": ""
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"id": "333a4e89-9363-4c36-b56f-79c6b019c6c6",
|
||||
"name": "siret",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"aggregate.attrs": "false",
|
||||
"userinfo.token.claim": "true",
|
||||
"multivalued": "false",
|
||||
"user.attribute": "siret",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "siret"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "af52ccc3-4ecb-49b4-9a67-5d4172f16070",
|
||||
"name": "role_list",
|
||||
@@ -1573,7 +1685,8 @@
|
||||
"email",
|
||||
"roles",
|
||||
"web-origins",
|
||||
"acr"
|
||||
"acr",
|
||||
"siret"
|
||||
],
|
||||
"defaultOptionalClientScopes": [
|
||||
"offline_access",
|
||||
@@ -1596,8 +1709,130 @@
|
||||
"enabledEventTypes": [],
|
||||
"adminEventsEnabled": false,
|
||||
"adminEventsDetailsEnabled": false,
|
||||
"identityProviders": [],
|
||||
"identityProviderMappers": [],
|
||||
"identityProviders": [
|
||||
{
|
||||
"alias": "oidc-people-local",
|
||||
"displayName": "People OIDC (local)",
|
||||
"internalId": "47aa6d7c-8ac5-4178-934e-66f78e510ee4",
|
||||
"providerId": "oidc",
|
||||
"enabled": true,
|
||||
"updateProfileFirstLoginMode": "on",
|
||||
"trustEmail": false,
|
||||
"storeToken": false,
|
||||
"addReadTokenRoleOnCreate": false,
|
||||
"authenticateByDefault": false,
|
||||
"linkOnly": false,
|
||||
"firstBrokerLoginFlowAlias": "first broker login",
|
||||
"config": {
|
||||
"hideOnLoginPage": "false",
|
||||
"userInfoUrl": "http://app-dev:8000/o/userinfo/",
|
||||
"validateSignature": "true",
|
||||
"acceptsPromptNoneForwardFromClient": "false",
|
||||
"clientId": "people-idp",
|
||||
"tokenUrl": "http://app-dev:8000/o/token/",
|
||||
"uiLocales": "false",
|
||||
"jwksUrl": "http://app-dev:8000/o/.well-known/jwks.json",
|
||||
"backchannelSupported": "false",
|
||||
"issuer": "http://app-dev:8000/o",
|
||||
"useJwksUrl": "true",
|
||||
"loginHint": "true",
|
||||
"pkceEnabled": "true",
|
||||
"pkceMethod": "S256",
|
||||
"authorizationUrl": "http://localhost:8071/o/authorize/",
|
||||
"clientAuthMethod": "client_secret_post",
|
||||
"disableUserInfo": "false",
|
||||
"syncMode": "IMPORT",
|
||||
"clientSecret": "local-tests-only",
|
||||
"passMaxAge": "false",
|
||||
"defaultScope": "openid given_name usual_name email siret",
|
||||
"allowedClockSkew": "0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"identityProviderMappers": [
|
||||
{
|
||||
"id": "e55dc88c-7bb5-46fb-95ad-1df701a96282",
|
||||
"name": "Sub",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "oidc-username-idp-mapper",
|
||||
"config": {
|
||||
"template": "${CLAIM.sub}",
|
||||
"are.claim.values.regex": "false",
|
||||
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
|
||||
"syncMode": "FORCE",
|
||||
"attributes": "[]",
|
||||
"target": "BROKER_ID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7e489676-8cba-49e4-aa1e-dcd1462d33f7",
|
||||
"name": "given_name",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "hardcoded-attribute-idp-mapper",
|
||||
"config": {
|
||||
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
|
||||
"syncMode": "FORCE",
|
||||
"are.claim.values.regex": "false",
|
||||
"attributes": "[]",
|
||||
"attribute": "firstName"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "30b6b3bc-5738-4936-bf88-c540b8805998",
|
||||
"name": "usual_name",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
||||
"config": {
|
||||
"template": "${ALIAS}.${CLAIM.preferred_username}",
|
||||
"are.claim.values.regex": "false",
|
||||
"claims": "[{\"key\":\"profile\",\"value\":\"lastName\"}]",
|
||||
"syncMode": "FORCE",
|
||||
"claim": "profile",
|
||||
"user.attribute": "lastName",
|
||||
"attributes": "[]",
|
||||
"target": "LOCAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b67caa26-4571-4cfe-9c15-68e022645fc5",
|
||||
"name": "Username",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "oidc-username-idp-mapper",
|
||||
"config": {
|
||||
"template": "${CLAIM.email | lowercase}",
|
||||
"are.claim.values.regex": "false",
|
||||
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
|
||||
"syncMode": "FORCE",
|
||||
"attributes": "[]",
|
||||
"target": "BROKER_USERNAME"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "4eef21ce-b5f7-4753-bd58-4e50eb2b5f31",
|
||||
"name": "Email",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
||||
"config": {
|
||||
"are.claim.values.regex": "false",
|
||||
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
|
||||
"syncMode": "FORCE",
|
||||
"claim": "email",
|
||||
"user.attribute": "email",
|
||||
"attributes": "[]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "084cdd0e-0794-4388-8474-84c9a7c1b9c8",
|
||||
"name": "siret",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
||||
"config": {
|
||||
"syncMode": "FORCE",
|
||||
"claim": "siret",
|
||||
"user.attribute": "siret"
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
|
||||
{
|
||||
@@ -2139,7 +2374,7 @@
|
||||
"authenticatorConfig": "review profile config",
|
||||
"authenticator": "idp-review-profile",
|
||||
"authenticatorFlow": false,
|
||||
"requirement": "REQUIRED",
|
||||
"requirement": "DISABLED",
|
||||
"priority": 10,
|
||||
"autheticatorFlow": false,
|
||||
"userSetupAllowed": false
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Internationalization (i18n)
|
||||
|
||||
The backend and the frontend of the application are internationalized.
|
||||
This means that the application can be translated into different languages.
|
||||
The application is currently available in English and French.
|
||||
|
||||
## Local setup
|
||||
|
||||
To be able to upload/retrieve translation files to/from Crowdin you need to
|
||||
setup your local environment with the following environment variables:
|
||||
|
||||
```bash
|
||||
cp ./env.d/development/crowdin.dist ./env.d/development/crowdin
|
||||
```
|
||||
|
||||
Then fill "CROWDIN_API_TOKEN" with your token and "CROWDIN_PROJECT_ID" with the project id
|
||||
in the `./env.d/development/crowdin` file.
|
||||
|
||||
NB: If you don't have a personal Crowdin API token, go to Crowdin interface to generate it:
|
||||
Settings -> API -> Personal Access Tokens -> New token
|
||||
|
||||
This configuration file will be loaded by the crowdin docker instance.
|
||||
|
||||
|
||||
## How to update the translations
|
||||
|
||||
### First way (safe way)
|
||||
|
||||
1/ Generate the translation files for both frontend and backend
|
||||
```bash
|
||||
make i18n-generate
|
||||
```
|
||||
Check locally everything is ok before uploading the files to the translation platform.
|
||||
|
||||
2/ Upload the files to the translation platform (crowdin):
|
||||
```bash
|
||||
make crowdin-upload
|
||||
```
|
||||
|
||||
3/ Fill the missing translations on the translation platform:
|
||||
|
||||
=> [https://crowdin.com/project/lasuite-people](https://crowdin.com/project/lasuite-people)
|
||||
|
||||
4/ Download the translated files from the translation platform and compile them:
|
||||
```bash
|
||||
make i18n-download-and-compile
|
||||
```
|
||||
|
||||
### Second way (faster way)
|
||||
|
||||
1/ Generate the translation files for both frontend and backend and upload them to the translation platform (crowdin):
|
||||
```bash
|
||||
make i18n-generate-and-upload
|
||||
```
|
||||
|
||||
2/ Fill the missing translations on the translation platform:
|
||||
|
||||
=> [https://crowdin.com/project/lasuite-people](https://crowdin.com/project/lasuite-people)
|
||||
|
||||
3/ Download the translated files from the translation platform and compile them:
|
||||
```bash
|
||||
make i18n-download-and-compile
|
||||
```
|
||||
@@ -0,0 +1,100 @@
|
||||
# People as an Identity Provider
|
||||
|
||||
The people project can be configured to act as an Identity Provider for an OIDC federation.
|
||||
|
||||
The model containing the "identity" in `people` is the `Mailbox` model.
|
||||
|
||||
The connection workflow looks like:
|
||||
|
||||
- The user tries to reach a service provider.
|
||||
- The service provider redirects the user to OIDC federation (in dev we use Keycloak).
|
||||
- The user asks (or is redirected) to the Identity Provider (people).
|
||||
- The user enter their email and password.
|
||||
- If successful, the user is redirected to the OIDC federation with a token.
|
||||
- The federation make the same Authorization Flow as usual to authenticate on the Service Provider.
|
||||
|
||||
|
||||
## Technical aspects
|
||||
|
||||
### The `Mailbox` model
|
||||
|
||||
The `Mailbox` model can behave like a usual Django user:
|
||||
|
||||
- It has a `password` field (and `last_login`)
|
||||
- It is considered as `authenticated`.
|
||||
- To be `active` it must have an `enabled` status.
|
||||
- To be able to log in, it must also have a `MailboxDomain` with an `enabled` status and an associated `Organization`.
|
||||
|
||||
### The `MailboxModelBackend` authentication backend
|
||||
|
||||
This authentication backend only allow authentication with `email` and `password` with a `Mailbox` user.
|
||||
This backend is combined with the `one_time_email_authenticated_session` middleware to restrict
|
||||
the session to the OIDC login process.
|
||||
A user connecting with this backend will only be able to access the `/o/authorize/` URL.
|
||||
|
||||
### The OIDC validators
|
||||
|
||||
There are two validators available:
|
||||
|
||||
- `BaseValidator`: This validator retrieves simple claims
|
||||
- `ProConnectValidator`: This validator is a custom validator to allow ProConnect specific requirements.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
The keycloak configuration is not described here, but you may find information in the code base.
|
||||
|
||||
### Django settings
|
||||
|
||||
The following settings are required to enable the OIDC Identity Provider feature:
|
||||
|
||||
- OAUTH2_PROVIDER_OIDC_ENABLED: True
|
||||
- OAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY: ...
|
||||
|
||||
Optional:
|
||||
|
||||
- OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
|
||||
|
||||
If the `OAUTH2_PROVIDER_VALIDATOR_CLASS` is set to the `ProConnectValidator`, the claims will be
|
||||
automatically defined to match the ProConnect requirements.
|
||||
|
||||
### Django OIDC application
|
||||
|
||||
To enable an OIDC client to use people, you must declare it.
|
||||
|
||||
```python
|
||||
from oauth2_provider.models import Application
|
||||
|
||||
application = Application(
|
||||
client_id="people-idp",
|
||||
client_secret="local-tests-only",
|
||||
client_type=Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
|
||||
name="People Identity Provider",
|
||||
algorithm=Application.RS256_ALGORITHM,
|
||||
redirect_uris="http://localhost:8083/realms/people/broker/oidc-people-local/endpoint",
|
||||
skip_authorization=True,
|
||||
)
|
||||
application.clean()
|
||||
application.save()
|
||||
```
|
||||
|
||||
## Security concerns
|
||||
|
||||
### Failed login cooldown
|
||||
|
||||
To prevent brute force attacks, 5 failed login will trigger a cooldown of 5 minutes before being able to log in again.
|
||||
|
||||
### Password strength
|
||||
|
||||
There is currently no constraint on the password strength as it can only be defined by Django administrators,
|
||||
and later we will generate it.
|
||||
If at some point the user is allowed to set their password, we will need to enforce a password strength policy.
|
||||
|
||||
|
||||
## What is missing (next steps)
|
||||
|
||||
- `Mailbox` password can only be set through Django Admin panel (or shell), the next step will be to generate
|
||||
a secure password and send it or display it to the end user.
|
||||
- `MailboxDomain` organization can only be set through Django Admin panel (or shell), we need to provide a
|
||||
way to auto-select it or allow an administrator to do so.
|
||||
@@ -2,44 +2,49 @@
|
||||
|
||||
## What is dimail ?
|
||||
|
||||
The mailing solution provided in La Suite is [Open-XChange](https://www.open-xchange.com/) (OX).
|
||||
OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
|
||||
The mailing solution provided in La Suite is [La Messagerie](https://webmail.numerique.gouv.fr/), using [Open-XChange](https://www.open-xchange.com/) (OX). OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
|
||||
|
||||
API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.fr/docs#/).
|
||||
|
||||
## Architectural links of dimail
|
||||
## Use of dimail container
|
||||
|
||||
As dimail's primary goal is to act as an interface between People and OX, its architecture is similar to that of People. A series of requests are sent from People to dimail upon creating domains, users, permissions and mailboxes.
|
||||
To ease local development, dimail provides a container that we embark in our docker-compose. In "FAKE" mode, it simulates all responses from Open Exchange.
|
||||
|
||||
Bootstraping with command `make bootstrap` creates a container and initializes its database.
|
||||
|
||||
Additional commands :
|
||||
- Reset and populate the database with all the content of your People database with `dimail-setup-db`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Domains
|
||||
|
||||
Upon creating a domain on People, the same domain is created on dimail and will undergo a series of checks. When all checks have passed, the domain is considered enabled.
|
||||
|
||||
Domains in OX have a field called "context". Context is a shared space between domains, allowing users to discover users not only on their domain but on their entire context.
|
||||
Domains in OX have a field called "context". "Contexts" are shared spaces between domains, allowing users to discover users not only on their domain but on their entire context.
|
||||
> [!NOTE]
|
||||
> Contexts are only implemented in La Messagerie and are not currently in use in People. Domains created via People are in their own context.
|
||||
|
||||
People users can have 3 levels of permissions on a domain:
|
||||
- Viewers can
|
||||
- see the domain's information
|
||||
- list its mailboxes and managers
|
||||
- Administrators can
|
||||
- create mailboxes
|
||||
- invite collaborators to manage domain
|
||||
- change role of a viewer to administrators
|
||||
- all of viewers permissions
|
||||
- Owners can
|
||||
- promote administrators owners and demote owners
|
||||
- all of viewers and administrators' permissions
|
||||
> [!NOTE]
|
||||
> Contexts are only implemented in La Messagerie and are not currently in use in People. Domains created via People are in their own context.
|
||||
|
||||
|
||||
### Mailboxes
|
||||
|
||||
Mailboxes can be created by a domain owners or administrators in People's domain tab.
|
||||
|
||||
On enabled domains, mailboxes are created at the same time on dimail (and a confirmation email is sent to the secondary email).
|
||||
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's activation.
|
||||
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's (re)activation.
|
||||
On disabled domains, mailboxes creation is not allowed.
|
||||
|
||||
### Users
|
||||
|
||||
The ones issuing requests. Dimail users will reflect domains owners and administrators on People, for logging purposes and to allow direct use of dimail, if desired. User reconciliation is made on user uuid provided by ProConnect.
|
||||
|
||||
### Permissions
|
||||
|
||||
As for People, an access - a permissions (or an "allow" in dimail) - grants an user permission to create objects on a domain.
|
||||
|
||||
Permissions requests are sent automatically upon :
|
||||
- dimail database initialisation:
|
||||
+ permission for dimail user People to create users and domains
|
||||
- domain creation :
|
||||
+ permission for dimail user People to manage domain
|
||||
+ permission for new owner on new domain
|
||||
- user creation:
|
||||
+ permission for People to manage user
|
||||
- access creation, if owner or admin:
|
||||
+ permission for this user to manage domain
|
||||
@@ -0,0 +1,75 @@
|
||||
# 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.
|
||||
|
||||
This is particularly useful when working on integrations or to test your helm charts.
|
||||
Otherwise, you can use your good old docker configuration as described in README.md.
|
||||
|
||||
## 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)
|
||||
|
||||
[Install_prereq script](https://github.com/numerique-gouv/dk8s/blob/main/scripts/install-prereq.sh) (not tested).
|
||||
|
||||
### Helmfile in Docker
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
|
||||
docker run --rm --net=host \
|
||||
-v "${HOME}/.kube:/root/.kube" \
|
||||
-v "${HOME}/.config/helm:/root/.config/helm" \
|
||||
-v "${HOME}/.minikube:/${HOME}/.minikube" \
|
||||
-v "${PWD}:/wd" \
|
||||
-e KUBECONFIG=/root/.kube/config \
|
||||
--workdir /wd ghcr.io/helmfile/helmfile:v0.150.0 helmfile "$@"
|
||||
```
|
||||
|
||||
## Create the kubernetes cluster
|
||||
|
||||
Run the following command to create a kubernetes cluster using kind:
|
||||
|
||||
```bash
|
||||
make start-kind
|
||||
|
||||
# import your secrets from credentials manager
|
||||
# ! don't forget "https" before your url
|
||||
make install-external-secrets
|
||||
```
|
||||
|
||||
That's it! You should now have a local development environment for Kubernetes.
|
||||
|
||||
## Start the application
|
||||
|
||||
```bash
|
||||
# You can either start :
|
||||
# ProConnect stack (but secrets must be set on your local cluster)
|
||||
make tilt-up
|
||||
|
||||
# or standalone environment with keycloak
|
||||
make start-tilt-keycloak
|
||||
```
|
||||
|
||||
Access your 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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Releasing a new version
|
||||
|
||||
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
|
||||
|
||||
1. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
2. The next steps are automated in the `scripts/release.py`, you can run it using:
|
||||
|
||||
```bash
|
||||
make release
|
||||
```
|
||||
|
||||
This script will ask you for the version you want to release and the kind of release (patch, minor, major). It will then:
|
||||
|
||||
- Create a new branch named: `release/4.18.1`.
|
||||
|
||||
- Bump the release number for backend and frontend project.
|
||||
|
||||
- Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
|
||||
|
||||
- Commit your changes with the following format: 🔖 release emoji, type of release (patch/minor/patch) and release version:
|
||||
|
||||
```text
|
||||
🔖(minor) release version 4.18.1
|
||||
```
|
||||
|
||||
- Triggers Crowdin translation action and open related PR
|
||||
|
||||
- Open release PR. Wait for an approval from your peers and merge it.
|
||||
|
||||
> [!NOTE]
|
||||
> It also open the PR for pre-prod deployment, see following section.
|
||||
|
||||
3. Following release script instructions,
|
||||
|
||||
- merge translation and release pulls requests
|
||||
|
||||
- tag and push your commit:
|
||||
|
||||
```bash
|
||||
git tag v4.18.1 && git push origin tag v4.18.1
|
||||
```
|
||||
|
||||
This triggers the CI building new Docker images. You can ensure images were successfully built on Docker Hub [here for back-end](https://hub.docker.com/r/lasuite/people-backend/tags) and [here for front-end](https://hub.docker.com/r/lasuite/people-frontend/tags).
|
||||
|
||||
|
||||
# Deploying
|
||||
|
||||
## Staging
|
||||
|
||||
The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
|
||||
## Pre-prod and production
|
||||
|
||||
If you used the release script and had permission to push on [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement), a deployment branch has been created. You can skip step 1.
|
||||
|
||||
Otherwise, for manual preprod and for production deployments :
|
||||
1. Bump tag version for both front-end and back-end images in the `.gotmpl` file located in `manifests/<your-product>/env.d/<your-environment>/`,
|
||||
2. Add optional new secrets and variables, if applicable
|
||||
3. Create a pull request
|
||||
4. Submit to approval and merge PR
|
||||
|
||||
The release is now done! 🎉
|
||||
+5
-5
@@ -19,18 +19,18 @@ 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:
|
||||
- `jean.team-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
- `e2e.team-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only belong to a team with the specified role.
|
||||
- `jean.mail-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
- `e2e.mail-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only have a mailbox with the specified role access.
|
||||
- `jean.team-<team_role>-mail-<domain_role>@example.com` with a combination of roles as for the
|
||||
- `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-<username>`, for instance `password-e2e-jean.team-member`.
|
||||
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 `jean.`.
|
||||
username without the prefix `e2e.`.
|
||||
|
||||
``` typescript jsx
|
||||
await keyCloakSignIn(page, browserName, 'mail-owner');
|
||||
|
||||
@@ -3,6 +3,7 @@ DJANGO_ALLOWED_HOSTS=*
|
||||
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=people.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
|
||||
# Python
|
||||
PYTHONPATH=/app
|
||||
@@ -10,7 +11,7 @@ PYTHONPATH=/app
|
||||
# People settings
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_HOST="maildev"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
|
||||
# Backend url
|
||||
@@ -65,3 +66,6 @@ IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
|
||||
MbyqKyC6DAzv4jEEhHaN7oY=
|
||||
-----END PRIVATE KEY-----
|
||||
"
|
||||
|
||||
# INTEROP
|
||||
MAIL_PROVISIONING_API_CREDENTIALS="bGFfcmVnaWU6cGFzc3dvcmQ=" # Dev and test key for dimail interop
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# For the CI job test-e2e
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
|
||||
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD="siret"
|
||||
|
||||
OAUTH2_PROVIDER_OIDC_ENABLED=True
|
||||
OAUTH2_PROVIDER_VALIDATOR_CLASS="mailbox_oauth2.validators.ProConnectValidator"
|
||||
|
||||
INSTALLED_PLUGINS=plugins.la_suite
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
INSTALLED_PLUGINS=plugins.la_suite
|
||||
+6
-1
@@ -13,7 +13,12 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint", "@hookform/resolvers"]
|
||||
},
|
||||
{
|
||||
"groupName": "docker-compose dependencies",
|
||||
"matchManagers": ["docker-compose"],
|
||||
"matchPackageNames": ["dimail-api"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||
|
||||
cat <<'EOF' >$PRE_COMMIT_FILE
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# directories containing potential secrets
|
||||
DIRS="."
|
||||
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to add a new client to Keycloak using the kcadm.sh CLI
|
||||
# Usage: ./add-keycloak-client.sh [client_id] [client_secret]
|
||||
|
||||
# Default values
|
||||
CLIENT_ID=${1:-"some-client-id"}
|
||||
CLIENT_SECRET=${2:-"ThisIsAnExampleKeyForDevPurposeOnly"}
|
||||
KEYCLOAK_URL=${KEYCLOAK_URL:-"http://keycloak:8080"}
|
||||
KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-"admin"}
|
||||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-"admin"}
|
||||
REALM=${REALM:-"people"}
|
||||
|
||||
# Check for kcadm.sh in common locations
|
||||
KCADM_LOCATIONS=(
|
||||
"/opt/keycloak/bin/kcadm.sh"
|
||||
"/opt/jboss/keycloak/bin/kcadm.sh"
|
||||
"/usr/local/bin/kcadm.sh"
|
||||
"./bin/kcadm.sh"
|
||||
"$(which kcadm.sh 2>/dev/null)"
|
||||
)
|
||||
|
||||
KCADM=""
|
||||
for loc in "${KCADM_LOCATIONS[@]}"; do
|
||||
if [ -x "$loc" ]; then
|
||||
KCADM="$loc"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$KCADM" ]; then
|
||||
echo "Error: kcadm.sh not found. Please specify its location manually."
|
||||
echo "You can set the KCADM environment variable to the path of kcadm.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using Keycloak Admin CLI at: $KCADM"
|
||||
echo "Logging in to Keycloak at $KEYCLOAK_URL..."
|
||||
|
||||
# Login to Keycloak
|
||||
$KCADM config credentials \
|
||||
--server $KEYCLOAK_URL \
|
||||
--realm master \
|
||||
--user $KEYCLOAK_ADMIN \
|
||||
--password $KEYCLOAK_ADMIN_PASSWORD
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to login to Keycloak. Please check your credentials and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Successfully logged in to Keycloak."
|
||||
echo "Creating new client '$CLIENT_ID' in realm '$REALM'..."
|
||||
|
||||
# Create a temporary JSON file with client configuration
|
||||
CLIENT_JSON=$(mktemp)
|
||||
cat > "$CLIENT_JSON" << EOF
|
||||
{
|
||||
"clientId": "$CLIENT_ID",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"rootUrl": "",
|
||||
"adminUrl": "",
|
||||
"baseUrl": "",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "$CLIENT_SECRET",
|
||||
"redirectUris": [
|
||||
"http://localhost:8070/*",
|
||||
"http://localhost:8071/*",
|
||||
"http://localhost:3200/*",
|
||||
"http://localhost:8088/*",
|
||||
"http://localhost:3000/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:3200",
|
||||
"http://localhost:8088",
|
||||
"http://localhost:8070",
|
||||
"http://localhost:3000"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": true,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "$(date +%s)",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"tls-client-certificate-bound-access-tokens": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"acr.loa.map": "{}",
|
||||
"require.pushed.authorization.requests": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"client.session.idle.timeout": "-1",
|
||||
"token.response.type.bearer.lower-case": "false"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
# Create the client using kcadm.sh
|
||||
$KCADM create clients -r "$REALM" -f "$CLIENT_JSON"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to create client. Check the error message above."
|
||||
rm "$CLIENT_JSON"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Client '$CLIENT_ID' created successfully!"
|
||||
echo " Client ID: $CLIENT_ID"
|
||||
echo " Client Secret: $CLIENT_SECRET"
|
||||
|
||||
# Clean up temporary file
|
||||
rm "$CLIENT_JSON"
|
||||
|
||||
# Display the created client
|
||||
echo "Client details:"
|
||||
$KCADM get clients -r "$REALM" --query "clientId=$CLIENT_ID"
|
||||
+73
-27
@@ -24,18 +24,6 @@ def update_files(version):
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
# helm files
|
||||
sys.stdout.write("Update helm files...\n")
|
||||
for env in ["preprod", "production"]:
|
||||
path = f"src/helm/env.d/{env}/values.desk.yaml.gotmpl"
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "tag:" in line:
|
||||
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
# frontend package.json
|
||||
sys.stdout.write("Update package.json...\n")
|
||||
files_to_modify = []
|
||||
@@ -55,6 +43,17 @@ def update_files(version):
|
||||
return
|
||||
|
||||
|
||||
def update_helm_files(path):
|
||||
sys.stdout.write("Update helm files...\n")
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "tag:" in line:
|
||||
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def update_changelog(path, version):
|
||||
"""Update changelog file with release info
|
||||
"""
|
||||
@@ -77,7 +76,46 @@ def update_changelog(path, version):
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def prepare_release(version, kind):
|
||||
def deployment_part(version):
|
||||
""" Update helm file of preprod on deployment repository numerique-gouv/lasuite-deploiement
|
||||
"""
|
||||
# Move to lasuite-deploiement repository
|
||||
try:
|
||||
os.chdir('../lasuite-deploiement')
|
||||
except FileNotFoundError:
|
||||
sys.stdout.write("\033[1;31m[Error] You must have a clone of https://github.com/numerique-gouv/lasuite-deploiement\x1b[0m")
|
||||
sys.stdout.write("\033[0;32mPlease clone it and re-run script with option --only-deployment-part\x1b[0m")
|
||||
sys.stdout.write(" >>> python scripts/release.py --only-deployment-part \x1b[0m")
|
||||
else:
|
||||
run_command("git checkout main", shell=True)
|
||||
run_command("git pull", shell=True)
|
||||
deployment_branch = f"regie/{version}/preprod"
|
||||
run_command(f"git checkout -b {deployment_branch}", shell=True)
|
||||
run_command("git pull --rebase origin main", shell=True)
|
||||
path = f"manifests/regie/env.d/preprod/values.desk.yaml.gotmpl"
|
||||
update_helm_files(path)
|
||||
run_command(f"git add {path}", shell=True)
|
||||
message = f"""🔖(regie) bump preprod version to {version}"""
|
||||
run_command(f"git commit -m '{message}'", shell=True)
|
||||
confirm = input(f"""\033[0;32m
|
||||
### DEPLOYMENT ###
|
||||
NEXT COMMAND on lasuite-deploiement repository:
|
||||
>> git push origin {deployment_branch}
|
||||
Continue ? (y,n)
|
||||
\x1b[0m""").strip()
|
||||
if confirm == 'y':
|
||||
run_command(f"git push origin {deployment_branch}", shell=True)
|
||||
sys.stdout.write(f"""\033[1;34m
|
||||
PLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Check if you need to bump chart version in the manifests/regie/helmfile.yaml file
|
||||
and settings in the manifests/regie/env.d/preprod/values.desk.yaml.gotmpl file
|
||||
add commits into {deployment_branch} branch to do it if needed, then...
|
||||
--> Please submit PR {deployment_branch} and merge code to main [https://github.com/numerique-gouv/lasuite-deploiement]
|
||||
\x1b[0m""")
|
||||
|
||||
|
||||
def project_part(version, kind):
|
||||
"""Manage only la regie part with update of CHANGELOG, version files and tag"""
|
||||
sys.stdout.write('Let\'s go to create branch to release\n')
|
||||
branch_to_release = f"release/{version}"
|
||||
run_command(f"git checkout -b {branch_to_release}", shell=True)
|
||||
@@ -90,31 +128,39 @@ def prepare_release(version, kind):
|
||||
run_command("git add src/", shell=True)
|
||||
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
|
||||
|
||||
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
|
||||
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
|
||||
run_command(f"git commit -m '{message}'", shell=True)
|
||||
confirm = input(f"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
|
||||
confirm = input(f"""\033[0;32m
|
||||
### RELEASE ###
|
||||
NEXT COMMAND on current repository:
|
||||
>> git push origin {branch_to_release}
|
||||
Continue ? (y,n)
|
||||
\x1b[0m""").strip()
|
||||
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:
|
||||
\033[1;34mPLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> A github action will download last translations from crowdin and create a PR (i18n/update-translations) to merge into the release branch.
|
||||
Please wait for this, approve and merge this translations PR into the release branch {branch_to_release}.
|
||||
--> Then please submit PR {branch_to_release} and merge code to main [https://github.com/suitenumerique/people/]
|
||||
--> Then do:
|
||||
>> git checkout main
|
||||
>> git pull
|
||||
>> 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}
|
||||
""")
|
||||
--> Please check and wait for the docker image v{version} to be published here:
|
||||
- https://hub.docker.com/r/lasuite/people-backend/tags
|
||||
- https://hub.docker.com/r/lasuite/people-frontend/tags
|
||||
--> Now please generate release on github interface for the current tag v{version}
|
||||
\x1b[0m""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
version, kind = None, None
|
||||
while not version:
|
||||
version = input("Enter your release version:")
|
||||
version = input("Enter your release version:").strip()
|
||||
while kind not in RELEASE_KINDS:
|
||||
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
|
||||
prepare_release(version, kind)
|
||||
kind = input("Enter kind of release (p:patch, m:minor, mj:major):").strip()
|
||||
if "--only-deployment-part" not in sys.argv:
|
||||
project_part(version, kind)
|
||||
deployment_part(version)
|
||||
|
||||
-1
Submodule secrets deleted from b7ab5f1411
@@ -6,5 +6,8 @@ html[data-theme="light"], :root {
|
||||
{% if ADMIN_HEADER_BACKGROUND %}--header-bg: {{ ADMIN_HEADER_BACKGROUND }};{% endif %}
|
||||
{% if ADMIN_HEADER_COLOR %}--header-color: {{ ADMIN_HEADER_COLOR }};{% endif %}
|
||||
}
|
||||
ul.messagelist li.info {
|
||||
background-color: #EEEEEE;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Global fixtures for the backend tests."""
|
||||
|
||||
import pytest
|
||||
from urllib3.connectionpool import HTTPConnectionPool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_http_requests(monkeypatch):
|
||||
"""
|
||||
Prevents HTTP requests from being made during tests.
|
||||
This is useful for tests that do not require actual HTTP requests
|
||||
and helps to avoid network-related issues.
|
||||
|
||||
Credits: https://blog.jerrycodes.com/no-http-requests/
|
||||
"""
|
||||
|
||||
allowed_hosts = {"localhost"}
|
||||
original_urlopen = HTTPConnectionPool.urlopen
|
||||
|
||||
def urlopen_mock(self, method, url, *args, **kwargs):
|
||||
if self.host in allowed_hosts:
|
||||
return original_urlopen(self, method, url, *args, **kwargs)
|
||||
|
||||
raise RuntimeError(
|
||||
f"The test was about to {method} {self.scheme}://{self.host}{url}"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"urllib3.connectionpool.HTTPConnectionPool.urlopen", urlopen_mock
|
||||
)
|
||||
@@ -1,12 +1,17 @@
|
||||
"""Admin classes and registrations for People's core app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.conf import settings
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from treebeard.admin import TreeAdmin
|
||||
from treebeard.forms import movenodeform_factory
|
||||
|
||||
from mailbox_manager.admin import MailDomainAccessInline
|
||||
|
||||
from . import models
|
||||
from .plugins.registry import registry as plugin_hooks_registry
|
||||
|
||||
|
||||
class TeamAccessInline(admin.TabularInline):
|
||||
@@ -53,7 +58,17 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Personal info"),
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"email",
|
||||
"language",
|
||||
"timezone",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -118,9 +133,10 @@ class TeamServiceProviderInline(admin.TabularInline):
|
||||
|
||||
|
||||
@admin.register(models.Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
class TeamAdmin(TreeAdmin):
|
||||
"""Team admin interface declaration."""
|
||||
|
||||
form = movenodeform_factory(models.Team)
|
||||
inlines = (TeamAccessInline, TeamWebhookInline, TeamServiceProviderInline)
|
||||
exclude = ("service_providers",) # Handled by the inline
|
||||
list_display = (
|
||||
@@ -129,6 +145,7 @@ class TeamAdmin(admin.ModelAdmin):
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
readonly_fields = ("path", "depth", "numchild")
|
||||
|
||||
|
||||
@admin.register(models.TeamAccess)
|
||||
@@ -194,7 +211,7 @@ class ContactAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"full_name",
|
||||
"owner",
|
||||
"base",
|
||||
"override",
|
||||
)
|
||||
|
||||
|
||||
@@ -206,19 +223,41 @@ class OrganizationServiceProviderInline(admin.TabularInline):
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.action(description=_("Run post creation plugins"), permissions=["change"])
|
||||
def run_post_creation_plugins(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
"""Run the post creation plugins for the selected organizations."""
|
||||
for organization in queryset:
|
||||
plugin_hooks_registry.execute_hook("organization_created", organization)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_("Post creation plugins have been run for the selected organizations."),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for organizations."""
|
||||
|
||||
actions = [run_post_creation_plugins]
|
||||
list_display = (
|
||||
"name",
|
||||
"is_active",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("name",)
|
||||
inlines = (OrganizationAccessInline, OrganizationServiceProviderInline)
|
||||
exclude = ("service_providers",) # Handled by the inline
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Adapt actions list to the context."""
|
||||
actions = super().get_actions(request)
|
||||
if not plugin_hooks_registry.get_callbacks("organization_created"):
|
||||
actions.pop("run_post_creation_plugins", None)
|
||||
return actions
|
||||
|
||||
|
||||
@admin.register(models.OrganizationAccess)
|
||||
class OrganizationAccessAdmin(admin.ModelAdmin):
|
||||
@@ -246,3 +285,20 @@ class ServiceProviderAdmin(admin.ModelAdmin):
|
||||
)
|
||||
search_fields = ("name", "audience_id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(models.AccountService)
|
||||
class AccountServiceAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for account services."""
|
||||
|
||||
list_display = ("name", "created_at", "updated_at")
|
||||
readonly_fields = ("api_key", "created_at", "updated_at")
|
||||
|
||||
def get_form(self, request, obj=None, change=False, **kwargs):
|
||||
"""Add help text to the scopes field to provide list of available scopes."""
|
||||
form = super().get_form(request, obj, change, **kwargs)
|
||||
form.base_fields[
|
||||
"scopes"
|
||||
].help_text = f"Scopes define what the service can access. \
|
||||
Available scopes: {', '.join(settings.ACCOUNT_SERVICE_SCOPES)}"
|
||||
return form
|
||||
|
||||
@@ -10,23 +10,37 @@ 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):
|
||||
"""
|
||||
@@ -74,8 +88,8 @@ class UserOrganizationSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
fields = ["id", "name"]
|
||||
read_only_fields = ["id", "name"]
|
||||
fields = ["id", "name", "registration_id_list"]
|
||||
read_only_fields = ["id", "name", "registration_id_list"]
|
||||
|
||||
|
||||
class UserSerializer(DynamicFieldsModelSerializer):
|
||||
@@ -237,6 +251,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
is_visible_all_services = serializers.BooleanField(required=False, default=True)
|
||||
service_providers = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ServiceProvider.objects.all(), many=True, required=False
|
||||
)
|
||||
@@ -245,18 +260,25 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
model = models.Team
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"accesses",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"depth",
|
||||
"is_visible_all_services",
|
||||
"name",
|
||||
"numchild",
|
||||
"path",
|
||||
"service_providers",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"accesses",
|
||||
"created_at",
|
||||
"depth",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import datetime
|
||||
import operator
|
||||
from functools import reduce
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import OuterRef, Q, Subquery
|
||||
from django.db.models import OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
@@ -19,8 +27,10 @@ from rest_framework.permissions import AllowAny
|
||||
from core import models
|
||||
from core.api import permissions
|
||||
from core.api.client import serializers
|
||||
from core.utils.raw_sql import gen_sql_filter_json_array
|
||||
|
||||
SIMILARITY_THRESHOLD = 0.04
|
||||
from mailbox_manager import enums
|
||||
from mailbox_manager import models as domains_models
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
@@ -135,31 +145,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", "owner").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 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", ""):
|
||||
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)
|
||||
@@ -238,9 +263,10 @@ class UserViewSet(
|
||||
queryset = self.queryset
|
||||
|
||||
if self.action == "list":
|
||||
# Exclude inactive contacts
|
||||
# Filter active users
|
||||
# and users from same organization
|
||||
queryset = queryset.filter(
|
||||
is_active=True,
|
||||
is_active=True, organization_id=self.request.user.organization_id
|
||||
)
|
||||
|
||||
# Exclude all users already in the given team
|
||||
@@ -280,16 +306,24 @@ class TeamViewSet(
|
||||
):
|
||||
"""Team ViewSet"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
permission_classes = [permissions.TeamPermission, permissions.AccessPermission]
|
||||
serializer_class = serializers.TeamSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering_fields = ["created_at", "name", "path"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related teams."""
|
||||
teams_queryset = models.Team.objects.filter(
|
||||
accesses__user=self.request.user,
|
||||
)
|
||||
depth_path = teams_queryset.values("depth", "path")
|
||||
|
||||
if not depth_path:
|
||||
return models.Team.objects.none()
|
||||
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
@@ -297,9 +331,32 @@ class TeamViewSet(
|
||||
return (
|
||||
models.Team.objects.prefetch_related("accesses", "service_providers")
|
||||
.filter(
|
||||
accesses__user=self.request.user,
|
||||
reduce(
|
||||
operator.or_,
|
||||
(
|
||||
Q(
|
||||
# The team the user has access to
|
||||
depth=d["depth"],
|
||||
path=d["path"],
|
||||
)
|
||||
| Q(
|
||||
# The parent team the user has access to
|
||||
depth__lt=d["depth"],
|
||||
path__startswith=d["path"][: models.Team.steplen],
|
||||
organization_id=self.request.user.organization_id,
|
||||
)
|
||||
for d in depth_path
|
||||
),
|
||||
),
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role for the team
|
||||
# and if the user does not have access, it's ok to consider them as a member
|
||||
# because it's a parent team.
|
||||
.annotate(
|
||||
user_role=Coalesce(
|
||||
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
|
||||
)
|
||||
)
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -529,7 +586,13 @@ class ConfigView(views.APIView):
|
||||
GET /api/v1.0/config/
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = ["LANGUAGES", "FEATURES", "RELEASE", "COMMIT"]
|
||||
array_settings = [
|
||||
"LANGUAGES",
|
||||
"FEATURES",
|
||||
"RELEASE",
|
||||
"COMMIT",
|
||||
"CRISP_WEBSITE_ID",
|
||||
]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
@@ -537,6 +600,31 @@ class ConfigView(views.APIView):
|
||||
return response.Response(dict_settings)
|
||||
|
||||
|
||||
class StatView(views.APIView):
|
||||
"""API ViewSet for sharing some public metrics."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@method_decorator(cache_page(3600))
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/stats/
|
||||
Return a dictionary of public metrics.
|
||||
"""
|
||||
context = {
|
||||
"total_users": models.User.objects.count(),
|
||||
"mau": models.User.objects.filter(
|
||||
last_login__gte=timezone.now() - datetime.timedelta(30)
|
||||
).count(),
|
||||
"teams": models.Team.objects.count(),
|
||||
"active_domains": domains_models.MailDomain.objects.filter(
|
||||
status=enums.MailDomainStatusChoices.ENABLED
|
||||
).count(),
|
||||
"mailboxes": domains_models.Mailbox.objects.count(),
|
||||
}
|
||||
return response.Response(context)
|
||||
|
||||
|
||||
class ServiceProviderFilter(filters.BaseFilterBackend):
|
||||
"""
|
||||
Filter service providers.
|
||||
|
||||
@@ -4,6 +4,8 @@ from django.core import exceptions
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
@@ -53,3 +55,43 @@ class AccessPermission(IsAuthenticated):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
|
||||
class TeamPermission(IsAuthenticated):
|
||||
"""Permission class for team objects viewset."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check permission only when the user tries to create a new team."""
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
if request.method != "POST":
|
||||
return True
|
||||
|
||||
abilities = request.user.get_abilities()
|
||||
return abilities["teams"]["can_create"]
|
||||
|
||||
|
||||
class TeamInvitationCreationPermission(IsAuthenticated):
|
||||
"""Permission class that allows only team owners and admins to perform actions."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has required role for the team."""
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
# Only check roles for edition operations
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
team_id = view.kwargs.get("team_id")
|
||||
if not team_id:
|
||||
return False
|
||||
|
||||
team_access = models.TeamAccess.objects.filter(
|
||||
team_id=team_id,
|
||||
user=request.user,
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
).exists()
|
||||
|
||||
return team_access
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""SCIM compliant API for resource server"""
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Exceptions for SCIM API."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from core.api.resource_server.scim.response import ScimJsonResponse
|
||||
|
||||
|
||||
def scim_exception_handler(exc, _context):
|
||||
"""Handle SCIM exceptions and return them in the correct format."""
|
||||
data = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"status": str(exc.status_code),
|
||||
"detail": str(exc.detail) if settings.DEBUG else "",
|
||||
}
|
||||
|
||||
return ScimJsonResponse(data, status=exc.status_code)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""SCIM API response classes."""
|
||||
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
class ScimJsonResponse(Response):
|
||||
"""
|
||||
Custom JSON response class for SCIM API.
|
||||
|
||||
This class sets the content type to "application/json+scim" for SCIM
|
||||
responses.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""JSON response with enforced SCIM content type."""
|
||||
super().__init__(*args, content_type="application/json+scim", **kwargs)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""SCIM serializers for resource server API."""
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class SCIMUserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users in SCIM format."""
|
||||
|
||||
schemas = serializers.SerializerMethodField()
|
||||
userName = serializers.CharField(source="sub")
|
||||
displayName = serializers.CharField(source="name")
|
||||
emails = serializers.SerializerMethodField()
|
||||
meta = serializers.SerializerMethodField()
|
||||
groups = serializers.SerializerMethodField()
|
||||
active = serializers.BooleanField(source="is_active")
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"schemas",
|
||||
"active",
|
||||
"userName",
|
||||
"displayName",
|
||||
"emails",
|
||||
"groups",
|
||||
"meta",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"schemas",
|
||||
"active",
|
||||
"userName",
|
||||
"displayName",
|
||||
"emails",
|
||||
"groups",
|
||||
"meta",
|
||||
]
|
||||
|
||||
def get_schemas(self, _obj):
|
||||
"""Return the SCIM schemas for the user."""
|
||||
return ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||
|
||||
def get_emails(self, obj):
|
||||
"""Return the user's email as a list of email objects."""
|
||||
if obj.email:
|
||||
return [
|
||||
{
|
||||
"value": obj.email,
|
||||
"primary": True,
|
||||
"type": "work",
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
def get_groups(self, obj):
|
||||
"""
|
||||
Return the groups the user belongs to.
|
||||
|
||||
WARNING: you need to prefetch the team accesses in the
|
||||
viewset to avoid N+1 queries.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"value": str(team_access.team.external_id),
|
||||
"display": team_access.team.name,
|
||||
"type": "direct",
|
||||
}
|
||||
for team_access in obj.accesses.all()
|
||||
]
|
||||
|
||||
def get_meta(self, obj):
|
||||
"""Return metadata about the user."""
|
||||
request = self.context.get("request")
|
||||
location = (
|
||||
f"{request.build_absolute_uri('/').rstrip('/')}{reverse('scim-me-list')}"
|
||||
if request
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"resourceType": "User",
|
||||
"created": obj.created_at.isoformat(),
|
||||
"lastModified": obj.updated_at.isoformat(),
|
||||
"location": location,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Resource server SCIM API endpoints"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Prefetch, Q
|
||||
|
||||
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
|
||||
from rest_framework import (
|
||||
viewsets,
|
||||
)
|
||||
|
||||
from core.api import permissions
|
||||
from core.models import TeamAccess
|
||||
|
||||
from . import serializers
|
||||
from .exceptions import scim_exception_handler
|
||||
from .response import ScimJsonResponse
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class MeViewSet(ResourceServerMixin, viewsets.ViewSet):
|
||||
"""
|
||||
SCIM-compliant ViewSet for the /Me endpoint.
|
||||
|
||||
This endpoint provides information about the currently authenticated user
|
||||
in SCIM (System for Cross-domain Identity Management) format.
|
||||
|
||||
Features:
|
||||
- Returns user details in SCIM format.
|
||||
- Includes the user's teams, restricted to the audience.
|
||||
|
||||
Limitations:
|
||||
- Does not currently support managing Team hierarchies.
|
||||
|
||||
Endpoint:
|
||||
GET /resource-server/v1.0/scim/Me/
|
||||
Retrieves the authenticated user's details and associated teams.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = serializers.SCIMUserSerializer
|
||||
|
||||
def get_exception_handler(self):
|
||||
"""Override the default exception handler to use SCIM-specific handling."""
|
||||
return scim_exception_handler
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Return the current user's details in SCIM format."""
|
||||
service_provider_audience = self._get_service_provider_audience()
|
||||
|
||||
user = User.objects.prefetch_related(
|
||||
Prefetch(
|
||||
"accesses",
|
||||
queryset=TeamAccess.objects.select_related("team").filter(
|
||||
Q(team__service_providers__audience_id=service_provider_audience)
|
||||
| Q(team__is_visible_all_services=True)
|
||||
),
|
||||
)
|
||||
).get(pk=request.user.pk)
|
||||
|
||||
serializer = self.serializer_class(user, context={"request": request})
|
||||
return ScimJsonResponse(serializer.data)
|
||||
@@ -12,13 +12,20 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
model = models.Team
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"created_at",
|
||||
"depth",
|
||||
"is_visible_all_services",
|
||||
"name",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"depth",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@@ -44,3 +51,28 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"service_providers": [service_provider],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize invitations."""
|
||||
|
||||
class Meta:
|
||||
model = models.Invitation
|
||||
fields = ["id", "created_at", "email", "team", "role", "issuer", "is_expired"]
|
||||
read_only_fields = ["id", "created_at", "team", "issuer", "is_expired"]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Fill team and issuer from request."""
|
||||
is_team_available_for_service_provider = models.Team.objects.filter(
|
||||
id=self.context["team_id"],
|
||||
service_providers__audience_id=self.context[
|
||||
"from_service_provider_audience"
|
||||
],
|
||||
).exists()
|
||||
if not is_team_available_for_service_provider:
|
||||
raise serializers.ValidationError({"team": "Team not found."})
|
||||
|
||||
attrs["team_id"] = self.context["team_id"]
|
||||
attrs["issuer"] = self.context["request"].user # User is authenticated
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""Resource server API endpoints"""
|
||||
|
||||
from django.db.models import OuterRef, Prefetch, Subquery
|
||||
import operator
|
||||
from functools import reduce
|
||||
|
||||
from django.db.models import OuterRef, Prefetch, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
|
||||
from rest_framework import (
|
||||
filters,
|
||||
mixins,
|
||||
@@ -11,7 +16,6 @@ from rest_framework import (
|
||||
from core import models
|
||||
from core.api import permissions
|
||||
from core.api.client.viewsets import Pagination
|
||||
from core.resource_server.mixins import ResourceServerMixin
|
||||
|
||||
from . import serializers
|
||||
|
||||
@@ -56,6 +60,14 @@ class TeamViewSet( # pylint: disable=too-many-ancestors
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related teams."""
|
||||
teams_queryset = models.Team.objects.filter(
|
||||
accesses__user=self.request.user,
|
||||
)
|
||||
depth_path = teams_queryset.values("depth", "path")
|
||||
|
||||
if not depth_path:
|
||||
return models.Team.objects.none()
|
||||
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
@@ -74,10 +86,34 @@ class TeamViewSet( # pylint: disable=too-many-ancestors
|
||||
service_provider_prefetch,
|
||||
)
|
||||
.filter(
|
||||
accesses__user=self.request.user,
|
||||
service_providers__audience_id=service_provider_audience,
|
||||
reduce(
|
||||
operator.or_,
|
||||
(
|
||||
Q(
|
||||
# The team the user has access to
|
||||
depth=d["depth"],
|
||||
path=d["path"],
|
||||
)
|
||||
| Q(
|
||||
# The parent team the user has access to
|
||||
depth__lt=d["depth"],
|
||||
path__startswith=d["path"][: models.Team.steplen],
|
||||
organization_id=self.request.user.organization_id,
|
||||
)
|
||||
for d in depth_path
|
||||
),
|
||||
),
|
||||
Q(service_providers__audience_id=service_provider_audience)
|
||||
| Q(is_visible_all_services=True),
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role for the team
|
||||
# and if the user does not have access, it's ok to consider them as a member
|
||||
# because it's a parent team.
|
||||
.annotate(
|
||||
user_role=Coalesce(
|
||||
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
|
||||
)
|
||||
)
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -88,3 +124,79 @@ class TeamViewSet( # pylint: disable=too-many-ancestors
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
|
||||
class InvitationViewset( # pylint: disable=too-many-ancestors
|
||||
ResourceServerMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""API ViewSet for user invitations to team via resource server.
|
||||
|
||||
GET /resource-server/v1.0/teams/<team_id>/invitations/:<invitation_id>/
|
||||
Return list of invitations related to that team or one
|
||||
team access if an id is provided.
|
||||
|
||||
POST /resource-server/v1.0/teams/<team_id>/invitations/ with expected data:
|
||||
- email: str
|
||||
- role: str [owner|admin|member]
|
||||
- issuer : User, automatically added from user making query, if allowed
|
||||
- team : Team, automatically added from requested URI
|
||||
Return newly created invitation
|
||||
|
||||
PUT / PATCH : Not permitted. Instead of updating your invitation,
|
||||
delete and create a new one.
|
||||
|
||||
DELETE /resource-server/v1.0/teams/<team_id>/invitations/<invitation_id>/
|
||||
Delete targeted invitation
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
serializer_class = serializers.InvitationSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set specific permissions based on the action."""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
elif self.action == "create":
|
||||
permission_classes = [permissions.TeamInvitationCreationPermission]
|
||||
else:
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["team_id"] = self.kwargs["team_id"]
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
service_provider_audience = self._get_service_provider_audience()
|
||||
|
||||
# Determine which role the logged-in user has in the team
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=self.kwargs["team_id"]
|
||||
).values("role")[:1]
|
||||
|
||||
queryset = (
|
||||
models.Invitation.objects.select_related("team")
|
||||
.filter(
|
||||
team=self.kwargs["team_id"],
|
||||
# The logged-in user should be part of a team to see its accesses
|
||||
team__accesses__user=self.request.user,
|
||||
# The team should be accessible by the service provider audience
|
||||
team__service_providers__audience_id=service_provider_audience,
|
||||
)
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -1,11 +1,69 @@
|
||||
"""People Core application"""
|
||||
# from django.apps import AppConfig
|
||||
# from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command, get_commands
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.utils.io import TeeStringIO
|
||||
|
||||
from people.celery_app import app as celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# class CoreConfig(AppConfig):
|
||||
# """Configuration class for the People core app."""
|
||||
def _register_management_commands_as_task():
|
||||
"""
|
||||
Register management command which are enabled via MANAGEMENT_COMMAND_AS_TASK setting.
|
||||
"""
|
||||
for command_name in settings.MANAGEMENT_COMMAND_AS_TASK:
|
||||
# Check if the command is a valid management command
|
||||
try:
|
||||
app_name = get_commands()[command_name]
|
||||
except KeyError:
|
||||
logging.error("Command %s is not a valid management command.", command_name)
|
||||
continue
|
||||
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("People core application")
|
||||
command_full_name = ".".join([app_name, command_name])
|
||||
|
||||
# Create a closure to capture the current value of command_full_name and command_name
|
||||
def create_task(cmd_name, cmd_full_name):
|
||||
@celery_app.task(name=cmd_full_name)
|
||||
def task_wrapper(*command_args, **command_options):
|
||||
stdout = TeeStringIO(logging.getLogger(cmd_full_name).info)
|
||||
stderr = TeeStringIO(logging.getLogger(cmd_full_name).error)
|
||||
|
||||
call_command(
|
||||
cmd_name,
|
||||
*command_args,
|
||||
no_color=True,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
**command_options,
|
||||
)
|
||||
|
||||
stdout.seek(0)
|
||||
stderr.seek(0)
|
||||
return {
|
||||
"stdout": str(stdout.read()),
|
||||
"stderr": str(stderr.read()),
|
||||
}
|
||||
|
||||
return task_wrapper
|
||||
|
||||
# Create the task with the current values
|
||||
create_task(command_name, command_full_name)
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
"""Configuration class for the People core app."""
|
||||
|
||||
name = "core"
|
||||
app_label = "core"
|
||||
verbose_name = _("People core application")
|
||||
|
||||
def ready(self):
|
||||
"""Run when the application is ready."""
|
||||
_register_management_commands_as_task()
|
||||
|
||||
@@ -1,126 +1,73 @@
|
||||
"""Authentication Backends for the People core app."""
|
||||
|
||||
import logging
|
||||
from email.headerregistry import Address
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
from lasuite.tools.email import get_domain_from_email
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from core.models import Organization, OrganizationAccess, OrganizationRoleChoices
|
||||
from core.models import (
|
||||
AccountService,
|
||||
Contact,
|
||||
Organization,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def get_domain_from_email(email: Optional[str]) -> Optional[str]:
|
||||
"""Extract domain from email."""
|
||||
try:
|
||||
return Address(addr_spec=email).domain
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User model, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
dict: A dictionary of extra claims.
|
||||
|
||||
"""
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Create a new user if no match is found.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
full_name = self.compute_full_name(user_info)
|
||||
email = user_info.get("email")
|
||||
|
||||
claims = {
|
||||
"sub": sub,
|
||||
"email": email,
|
||||
"name": full_name,
|
||||
}
|
||||
extra_claims = super().get_extra_claims(user_info)
|
||||
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
|
||||
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
extra_claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = (
|
||||
user_info.get(settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD)
|
||||
)
|
||||
return extra_claims
|
||||
|
||||
# if sub is absent, try matching on email
|
||||
user = self.get_existing_user(sub, email)
|
||||
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||
"""
|
||||
Post-processing after user creation or retrieval.
|
||||
|
||||
if user:
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
self.update_user_if_needed(user, claims)
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(claims)
|
||||
Args:
|
||||
user (User): The user instance.
|
||||
claims (dict): The claims dictionary.
|
||||
is_new_user (bool): Indicates if the user was newly created.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
|
||||
"""
|
||||
# Data cleaning, to be removed when user organization is null=False
|
||||
# or all users have an organization.
|
||||
# See https://github.com/numerique-gouv/people/issues/504
|
||||
# See https://github.com/suitenumerique/people/issues/504
|
||||
if not user.organization_id:
|
||||
organization_registration_id = claims.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
)
|
||||
domain = get_domain_from_email(email)
|
||||
domain = get_domain_from_email(claims["email"])
|
||||
try:
|
||||
organization, organization_created = (
|
||||
Organization.objects.get_or_create_from_user_claims(
|
||||
@@ -147,8 +94,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"User %s updated with organization %s", user.pk, organization
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, claims):
|
||||
"""Return a newly created User instance."""
|
||||
sub = claims.get("sub")
|
||||
@@ -160,8 +105,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
name = claims.get("name")
|
||||
|
||||
# Extract or create the organization from the data
|
||||
organization_registration_id = claims.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
organization_registration_id = claims.pop(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD,
|
||||
None,
|
||||
)
|
||||
domain = get_domain_from_email(email)
|
||||
try:
|
||||
@@ -181,50 +127,60 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
logger.info("Creating user %s / %s", sub, email)
|
||||
|
||||
user = self.UserModel.objects.create(
|
||||
organization=organization,
|
||||
password="!", # noqa: S106
|
||||
sub=sub,
|
||||
email=email,
|
||||
name=name,
|
||||
user = super().create_user(claims | {"organization": organization})
|
||||
|
||||
# Initiate the user's profile
|
||||
Contact.objects.create(
|
||||
owner=user,
|
||||
user=user,
|
||||
full_name=name or email,
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": email},
|
||||
],
|
||||
},
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def compute_full_name(self, user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
name_fields = settings.USER_OIDC_FIELDS_TO_NAME
|
||||
full_name = " ".join(
|
||||
user_info[field] for field in name_fields if user_info.get(field)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
class AccountServiceAuthentication(BaseAuthentication):
|
||||
"""Authentication backend for account services using Authorization header.
|
||||
The Authorization header is used to authenticate the request.
|
||||
The api key is stored in the AccountService model.
|
||||
|
||||
Header format:
|
||||
Authorization: ApiKey <api_key>
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Authenticate the request. Find the account service and check the api key.
|
||||
|
||||
Should return either:
|
||||
- a tuple of (account_service, api_key) if allowed,
|
||||
- None to pass on other authentication backends
|
||||
- raise an AuthenticationFailed exception to stop propagation.
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header:
|
||||
return None
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
auth_mode, api_key = auth_header.split(" ")
|
||||
except (IndexError, ValueError) as err:
|
||||
raise AuthenticationFailed(_("Invalid authorization header.")) from err
|
||||
if auth_mode.lower() != "apikey" or not api_key:
|
||||
raise AuthenticationFailed(_("Invalid authorization header."))
|
||||
try:
|
||||
account_service = AccountService.objects.get(api_key=api_key)
|
||||
except AccountService.DoesNotExist as err:
|
||||
logger.warning("Invalid api_key: %s", api_key)
|
||||
raise AuthenticationFailed(_("Invalid api key.")) from err
|
||||
return (account_service, account_service.api_key)
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
updated_claims = {}
|
||||
for key in ["email", "name"]:
|
||||
claim_value = claims.get(key)
|
||||
if claim_value and claim_value != getattr(user, key):
|
||||
updated_claims[key] = claim_value
|
||||
|
||||
if updated_claims:
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
def authenticate_header(self, request):
|
||||
"""
|
||||
Return a string to be used as the value of the `WWW-Authenticate`
|
||||
header in a `401 Unauthenticated` response, or `None` if the
|
||||
authentication scheme should return `403 Permission Denied` responses.
|
||||
"""
|
||||
return "apikey"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Authentication URLs for the People core app."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
urlpatterns = [
|
||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
||||
path(
|
||||
"logout-callback/",
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozzila_oidc_urls,
|
||||
]
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
from mozilla_django_oidc.utils import (
|
||||
absolutify,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
|
||||
Adds support for handling logout callbacks from the identity provider (OP)
|
||||
by initiating the logout flow if the user has an active session.
|
||||
|
||||
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
|
||||
This parameter is crucial for maintaining the integrity of the logout flow between this call
|
||||
and the subsequent callback.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def persist_state(request, state):
|
||||
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
|
||||
|
||||
This method is used to store the OIDC state parameter in the session, according to the
|
||||
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
|
||||
utility function.
|
||||
"""
|
||||
|
||||
if "oidc_states" not in request.session or not isinstance(
|
||||
request.session["oidc_states"], dict
|
||||
):
|
||||
request.session["oidc_states"] = {}
|
||||
|
||||
request.session["oidc_states"][state] = {}
|
||||
request.session.save()
|
||||
|
||||
def construct_oidc_logout_url(self, request):
|
||||
"""Create the redirect URL for interfacing with the OIDC provider.
|
||||
|
||||
Retrieves the necessary parameters from the session and constructs the URL
|
||||
required to initiate logout with the OpenID Connect provider.
|
||||
|
||||
If no ID token is found in the session, the logout flow will not be initiated,
|
||||
and the method will return the default redirect URL.
|
||||
|
||||
The 'state' parameter is generated randomly and persisted in the session to ensure
|
||||
its integrity during the subsequent callback.
|
||||
"""
|
||||
|
||||
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
|
||||
|
||||
if not oidc_logout_endpoint:
|
||||
return self.redirect_url
|
||||
|
||||
reverse_url = reverse("oidc_logout_callback")
|
||||
id_token = request.session.get("oidc_id_token", None)
|
||||
|
||||
if not id_token:
|
||||
return self.redirect_url
|
||||
|
||||
query = {
|
||||
"id_token_hint": id_token,
|
||||
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
|
||||
"post_logout_redirect_uri": absolutify(request, reverse_url),
|
||||
}
|
||||
|
||||
self.persist_state(request, query["state"])
|
||||
|
||||
return f"{oidc_logout_endpoint}?{urlencode(query)}"
|
||||
|
||||
def post(self, request):
|
||||
"""Handle user logout.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, constructs the OIDC logout URL and redirects the user to start
|
||||
the logout process.
|
||||
|
||||
If the user is redirected to the default logout URL, ensure her Django session
|
||||
is terminated.
|
||||
"""
|
||||
|
||||
logout_url = self.redirect_url
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logout_url = self.construct_oidc_logout_url(request)
|
||||
|
||||
# If the user is not redirected to the OIDC provider, ensure logout
|
||||
if logout_url == self.redirect_url:
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(logout_url)
|
||||
|
||||
|
||||
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
|
||||
|
||||
Handles the callback after logout from the identity provider (OP).
|
||||
Verifies the state parameter and performs necessary logout actions.
|
||||
|
||||
The Django session is maintained during the logout process to ensure the integrity
|
||||
of the logout flow initiated in the previous step.
|
||||
"""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request):
|
||||
"""Handle the logout callback.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, verifies the state parameter and performs necessary logout actions.
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
state = request.GET.get("state")
|
||||
|
||||
if state not in request.session.get("oidc_states", {}):
|
||||
msg = "OIDC callback state not found in session `oidc_states`!"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
del request.session["oidc_states"][state]
|
||||
request.session.save()
|
||||
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-ancestors
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
@@ -23,3 +24,10 @@ class WebhookStatusChoices(models.TextChoices):
|
||||
FAILURE = "failure", _("Failure")
|
||||
PENDING = "pending", _("Pending")
|
||||
SUCCESS = "success", _("Success")
|
||||
|
||||
|
||||
class WebhookProtocolChoices(models.TextChoices):
|
||||
"""Defines the possible protocols of webhook."""
|
||||
|
||||
SCIM = "scim"
|
||||
MATRIX = "matrix"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# ruff: noqa: S311
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
@@ -115,8 +114,27 @@ 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):
|
||||
@@ -180,7 +198,6 @@ class TeamFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
django_get_or_create = ("name",)
|
||||
skip_postgeneration_save = True
|
||||
|
||||
name = factory.Sequence(lambda n: f"team{n}")
|
||||
@@ -222,7 +239,7 @@ class TeamWebhookFactory(factory.django.DjangoModelFactory):
|
||||
model = models.TeamWebhook
|
||||
|
||||
team = factory.SubFactory(TeamFactory)
|
||||
url = factory.Sequence(lambda n: f"https://example.com/Groups/{n!s}")
|
||||
url = factory.Sequence(lambda n: f"https://nosuchdomain.xyz/Groups/{n!s}")
|
||||
|
||||
|
||||
class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
@@ -259,3 +276,13 @@ class ServiceProviderFactory(factory.django.DjangoModelFactory):
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.organizations.set(extracted)
|
||||
|
||||
|
||||
class AccountServiceFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create account services for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.AccountService
|
||||
|
||||
name = factory.Sequence(lambda n: f"Account Service {n!s}")
|
||||
api_key = factory.Faker("uuid4")
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Organization Metadata",
|
||||
"properties": {
|
||||
"is_public_service": {
|
||||
"type": "boolean",
|
||||
"title": "Is public service"
|
||||
},
|
||||
"is_commune": {
|
||||
"type": "boolean",
|
||||
"title": "Is commune"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Management commands for core app."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Management commands for core app."""
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Management command for filling organization metadata with default values.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core import models
|
||||
from core.utils.json_schema import generate_default_from_schema
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Management command to fill organization metadata with default values."""
|
||||
|
||||
help = "Fill organization metadata with default values"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Fill organizations metadata missing values with default values."""
|
||||
organization_metadata_schema = models.get_organization_metadata_schema()
|
||||
|
||||
if not organization_metadata_schema:
|
||||
message = "No organization metadata schema defined."
|
||||
self.stdout.write(self.style.ERROR(message))
|
||||
return
|
||||
|
||||
default_metadata = generate_default_from_schema(organization_metadata_schema)
|
||||
for organization in models.Organization.objects.all():
|
||||
organization.metadata = {**default_metadata, **organization.metadata}
|
||||
|
||||
# Save the organization with the updated metadata
|
||||
# We don't use bulk update because we want to trigger the clean method
|
||||
organization.save(update_fields=["metadata", "updated_at"])
|
||||
|
||||
message = "Organization metadata filled with default values."
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Management command to list all plugins and their hooks."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.plugins.registry import registry
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Management command to list all plugins and their hooks."""
|
||||
|
||||
help = "List all plugins and their hooks"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Print plugin information."""
|
||||
self.stdout.write(self.style.NOTICE("# Listing plugins\n"))
|
||||
for plugin_app in settings.INSTALLED_PLUGINS:
|
||||
self.stdout.write(self.style.NOTICE(f" - {plugin_app}\n"))
|
||||
|
||||
self.stdout.write(self.style.NOTICE("# Listing loaded hooks\n"))
|
||||
for hook_name, callbacks in registry.get_registered_hooks():
|
||||
self.stdout.write(self.style.NOTICE(f" - {hook_name}: {callbacks}\n"))
|
||||
@@ -48,7 +48,7 @@ class Migration(migrations.Migration):
|
||||
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
|
||||
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
|
||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('language', models.CharField(choices=settings.LANGUAGES, default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-28 09:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_add_serviceprovider'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, default='', verbose_name='notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contact',
|
||||
name='full_name',
|
||||
field=models.CharField(default='-', max_length=150, verbose_name='full name'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 10:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_contact_notes_alter_contact_full_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='contact',
|
||||
name='base_owner_constraint',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='contact',
|
||||
name='base_not_self',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='override',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='overridden_by', to='core.contact'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('override__isnull', False), ('owner__isnull', True), _negated=True), name='override_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('override', models.F('id')), _negated=True), name='override_not_self', violation_error_message='A contact cannot override itself.'),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contact',
|
||||
name='base',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
unique_together={('owner', 'override')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 10:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def init_profile_contact(apps, schema_editor):
|
||||
"""Create a Contact for each User to be used as their profile."""
|
||||
User = apps.get_model("core", "User")
|
||||
Contact = apps.get_model("core", "Contact")
|
||||
|
||||
for user in User.objects.all(): # This is a small table for now
|
||||
Contact.objects.create(
|
||||
owner=user,
|
||||
user=user,
|
||||
full_name=user.name or user.email,
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": user.email},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_rename_contact_base_to_override'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='profile_contact',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='user',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='profile_contact', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('user__isnull', True), models.Q(('owner', models.F('user')), ('owner__isnull', False)), _connector='OR'), name='profile_contact_owner_constraint', violation_error_message='Users can only declare as profile a contact they own.'),
|
||||
),
|
||||
migrations.RunPython(init_profile_contact, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-05 13:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_change_user_profile_to_contact'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# From 100ms to 78ms for 5 000 contacts on my computer
|
||||
# This is not a big improvement and may need further investigation
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX json_field_index_emails_value ON people_contact USING GIN ((data->'emails'->'value') jsonb_path_ops);"
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-26 13:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from treebeard.numconv import NumConv
|
||||
|
||||
|
||||
def update_team_paths(apps, schema_editor):
|
||||
Team = apps.get_model("core", "Team")
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
steplen = 5
|
||||
|
||||
# Initialize NumConv with the specified custom alphabet
|
||||
converter = NumConv(len(alphabet), alphabet)
|
||||
|
||||
nodes = Team.objects.all().order_by("created_at")
|
||||
for i, node in enumerate(nodes, 1):
|
||||
node.depth = 1 # root nodes are at depth 1
|
||||
|
||||
# Use NumConv to encode the index `i` to a base representation and
|
||||
# pad it to the specified step length using the custom alphabet
|
||||
node.path = converter.int2str(i).rjust(steplen, alphabet[0])
|
||||
|
||||
if nodes:
|
||||
Team.objects.bulk_update(nodes, ["depth", "path"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_contacts_add_index_on_data_emails'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='depth',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='numchild',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='path',
|
||||
field=models.CharField(default='', max_length=400, db_collation="C"),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(update_team_paths, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name="team",
|
||||
name="path",
|
||||
field=models.CharField(unique=True, max_length=400, db_collation="C"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-11 13:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_team_depth_team_numchild_team_path_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='is_visible_all_services',
|
||||
field=models.BooleanField(default=False, help_text='Whether this team is visible to all service providers.', verbose_name='is visible for all SP'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-10 14:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_team_is_visible_all_services'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='A JSON object containing the organization metadata', verbose_name='metadata'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-27 10:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_organization_metadata'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True, verbose_name='active'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-03 20:37
|
||||
|
||||
import core.models
|
||||
import django.contrib.postgres.fields
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_organization_is_active'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccountService',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('api_key', models.CharField(default='Y9-IThtCrKASOvRAKO2MUBd_XvZrnSTGNMMlv-Z1_o8', max_length=255, verbose_name='api key')),
|
||||
('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, validators=[core.models.validate_account_service_scope]), help_text='Allowed scopes for this service', size=None, verbose_name='allowed scopes')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Account service',
|
||||
'verbose_name_plural': 'Account services',
|
||||
'db_table': 'people_account_service',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-10 07:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_accountservice'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='accountservice',
|
||||
name='api_key',
|
||||
field=models.CharField(max_length=255, verbose_name='api key'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2 on 2025-06-12 09:58
|
||||
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def gen_team_uuid(apps, schema_editor):
|
||||
Team = apps.get_model("core", "Team")
|
||||
for row in Team.objects.all():
|
||||
row.external_id = uuid.uuid4()
|
||||
# Don't need to batch update here, as the table is likely small or empty
|
||||
row.save(update_fields=["external_id"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_alter_accountservice_api_key'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='external_id',
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=False, verbose_name='external_id'),
|
||||
),
|
||||
migrations.RunPython(gen_team_uuid, reverse_code=migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='external_id',
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=True, verbose_name='external_id'),
|
||||
),
|
||||
#
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='users',
|
||||
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', through_fields=('team', 'user'), to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2025-06-17 14:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0016_team_external_id_alter_team_users'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='teamwebhook',
|
||||
name='protocol',
|
||||
field=models.CharField(choices=[('scim', 'Scim'), ('matrix', 'Matrix')], default='scim'),
|
||||
),
|
||||
]
|
||||
+334
-93
@@ -1,15 +1,18 @@
|
||||
"""
|
||||
Declare and configure the models for the People core application
|
||||
"""
|
||||
# pylint: disable=too-many-lines import-outside-toplevel
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import smtplib
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
from logging import getLogger
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
@@ -18,28 +21,55 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.db import models
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.translation import gettext, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import override
|
||||
|
||||
import jsonschema
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
|
||||
from core.enums import WebhookStatusChoices
|
||||
from core.utils.webhooks import scim_synchronizer
|
||||
from core.enums import WebhookProtocolChoices, WebhookStatusChoices
|
||||
from core.plugins.registry import registry as plugin_hooks_registry
|
||||
from core.utils.webhooks import webhooks_synchronizer
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
contact_schema_path = os.path.join(current_dir, "jsonschema", "contact_data.json")
|
||||
with open(contact_schema_path, "r", encoding="utf-8") as contact_schema_file:
|
||||
contact_schema = json.load(contact_schema_file)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_organization_metadata_schema() -> Optional[dict]:
|
||||
"""Load the organization metadata schema from the settings."""
|
||||
if not settings.ORGANIZATION_METADATA_SCHEMA:
|
||||
logger.info("No organization metadata schema specified")
|
||||
return None
|
||||
|
||||
organization_metadata_schema_path = os.path.join(
|
||||
current_dir,
|
||||
"jsonschema",
|
||||
settings.ORGANIZATION_METADATA_SCHEMA,
|
||||
)
|
||||
with open(
|
||||
organization_metadata_schema_path,
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as organization_metadata_schema_file:
|
||||
organization_metadata_schema = json.load(organization_metadata_schema_file)
|
||||
|
||||
logger.info(
|
||||
"Loaded organization metadata schema from %s", organization_metadata_schema_path
|
||||
)
|
||||
return organization_metadata_schema
|
||||
|
||||
|
||||
class RoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
|
||||
"""Defines the possible roles a user can have in a team."""
|
||||
|
||||
@@ -99,10 +129,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,
|
||||
)
|
||||
@@ -113,11 +143,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"),
|
||||
@@ -136,31 +174,38 @@ class Contact(BaseModel):
|
||||
ordering = ("full_name", "short_name")
|
||||
verbose_name = _("contact")
|
||||
verbose_name_plural = _("contacts")
|
||||
unique_together = ("owner", "base")
|
||||
unique_together = ("owner", "override")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=~models.Q(base__isnull=False, owner__isnull=True),
|
||||
name="base_owner_constraint",
|
||||
condition=~models.Q(override__isnull=False, owner__isnull=True),
|
||||
name="override_owner_constraint",
|
||||
violation_error_message="A contact overriding a base contact must be owned.",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=~models.Q(base=models.F("id")),
|
||||
name="base_not_self",
|
||||
violation_error_message="A contact cannot be based on itself.",
|
||||
condition=~models.Q(override=models.F("id")),
|
||||
name="override_not_self",
|
||||
violation_error_message="A contact cannot override itself.",
|
||||
),
|
||||
# When a user is set, the owner must be the user
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(user__isnull=True)
|
||||
| models.Q(owner=models.F("user"), owner__isnull=False),
|
||||
name="profile_contact_owner_constraint",
|
||||
violation_error_message="Users can only declare as profile a contact they own.",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name or self.short_name
|
||||
return self.full_name
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
# Check if the contact points to a base contact that itself points to another base contact
|
||||
if self.base_id and self.base.base_id:
|
||||
# Check if the contact overrides a contact that itself overrides another base contact
|
||||
if self.override_id and self.override.override_id:
|
||||
raise exceptions.ValidationError(
|
||||
"A contact cannot point to a base contact that itself points to a base contact."
|
||||
"A contact cannot override a contact that itself overrides a contact."
|
||||
)
|
||||
|
||||
# Validate the content of the "data" field against our jsonschema definition
|
||||
@@ -172,6 +217,27 @@ 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):
|
||||
"""
|
||||
@@ -250,6 +316,32 @@ class OrganizationManager(models.Manager):
|
||||
|
||||
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)
|
||||
plugin_hooks_registry.execute_hook("organization_created", instance)
|
||||
return instance
|
||||
|
||||
|
||||
class OrganizationAccessManager(models.Manager):
|
||||
"""
|
||||
Custom manager for the OrganizationAccess model, to manage complexity/automation.
|
||||
"""
|
||||
|
||||
def create(self, **kwargs):
|
||||
"""
|
||||
Create an organization access with the given kwargs.
|
||||
|
||||
This method is overridden to call the Organization plugins.
|
||||
"""
|
||||
instance = super().create(**kwargs)
|
||||
plugin_hooks_registry.execute_hook("organization_access_granted", instance)
|
||||
return instance
|
||||
|
||||
|
||||
class Organization(BaseModel):
|
||||
"""
|
||||
@@ -290,11 +382,19 @@ class Organization(BaseModel):
|
||||
# list overlap validation is done in the validate_unique method
|
||||
)
|
||||
|
||||
metadata = models.JSONField(
|
||||
_("metadata"),
|
||||
help_text=_("A JSON object containing the organization metadata"),
|
||||
blank=True,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
service_providers = models.ManyToManyField(
|
||||
ServiceProvider,
|
||||
related_name="organizations",
|
||||
blank=True,
|
||||
)
|
||||
is_active = models.BooleanField(verbose_name=_("active"), default=True)
|
||||
|
||||
objects = OrganizationManager()
|
||||
|
||||
@@ -320,6 +420,22 @@ class Organization(BaseModel):
|
||||
def __str__(self):
|
||||
return f"{self.name} (# {self.pk})"
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
organization_metadata_schema = get_organization_metadata_schema()
|
||||
if not organization_metadata_schema:
|
||||
return
|
||||
|
||||
try:
|
||||
jsonschema.validate(self.metadata, organization_metadata_schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
# Specify the property in the data in which the error occurred
|
||||
field_path = ".".join(map(str, e.path))
|
||||
error_message = f"Validation error in '{field_path:s}': {e.message}"
|
||||
raise exceptions.ValidationError({"metadata": [error_message]}) from e
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""
|
||||
Validate Registration/Domain values in an array field are unique
|
||||
@@ -392,16 +508,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
)
|
||||
email = models.EmailField(_("email address"), null=True, blank=True)
|
||||
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
|
||||
profile_contact = models.OneToOneField(
|
||||
Contact,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="user",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
choices=settings.LANGUAGES,
|
||||
default=settings.LANGUAGE_CODE,
|
||||
verbose_name=_("language"),
|
||||
help_text=_("The language in which the user wants to see the interface."),
|
||||
@@ -453,11 +562,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
If it's a new user, give her access to the relevant teams.
|
||||
If it's a new user, give them access to the relevant teams.
|
||||
"""
|
||||
|
||||
if self._state.adding:
|
||||
self._convert_valid_invitations()
|
||||
self._convert_valid_team_invitations()
|
||||
# a post_save signal to convert domain invitations to domain accesses
|
||||
# is triggered by the mailbox_manager app
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -467,12 +578,7 @@ 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):
|
||||
def _convert_valid_team_invitations(self):
|
||||
"""
|
||||
Convert valid invitations to team accesses.
|
||||
Expired invitations are ignored.
|
||||
@@ -538,7 +644,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
.get()
|
||||
)
|
||||
|
||||
teams_can_view = user_info.teams_can_view
|
||||
teams_can_view = user_info.teams_can_view or settings.FEATURES["TEAMS_DISPLAY"]
|
||||
mailboxes_can_view = user_info.mailboxes_can_view
|
||||
|
||||
return {
|
||||
@@ -550,7 +656,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
),
|
||||
},
|
||||
"teams": {
|
||||
"can_view": teams_can_view and settings.FEATURES["TEAMS_DISPLAY"],
|
||||
"can_view": teams_can_view,
|
||||
"can_create": teams_can_view and settings.FEATURES["TEAMS_CREATE"],
|
||||
},
|
||||
"mailboxes": {
|
||||
@@ -583,6 +689,8 @@ class OrganizationAccess(BaseModel):
|
||||
default=OrganizationRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
objects = OrganizationAccessManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "people_organization_access"
|
||||
verbose_name = _("Organization/user relation")
|
||||
@@ -599,7 +707,34 @@ class OrganizationAccess(BaseModel):
|
||||
return f"{self.user!s} is {self.role:s} in organization {self.organization!s}"
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
class TeamManager(MP_NodeManager):
|
||||
"""
|
||||
Custom manager for the Team model, to manage complexity/automation.
|
||||
"""
|
||||
|
||||
def create(self, parent_id=None, **kwargs):
|
||||
"""
|
||||
Replace the default create method to ease the Team creation process.
|
||||
|
||||
Notes:
|
||||
- the `add_*` methods from django-treebeard does not support the "using db".
|
||||
Which means it will always use the default db.
|
||||
- the `add_*` methods from django-treebeard does not support the "force_insert".
|
||||
|
||||
"""
|
||||
if parent_id is None:
|
||||
return self.model.add_root(**kwargs)
|
||||
|
||||
# Retrieve parent object, because django-treebeard uses raw queries for most
|
||||
# write operations, and raw queries don't update the django objects of the db
|
||||
# entries they modify. See caveats in the django-treebeard documentation.
|
||||
# This might be changed later if we never do any operation on the parent object
|
||||
# before creating the child.
|
||||
# Beware the N+1 here.
|
||||
return self.get(pk=parent_id).add_child(**kwargs)
|
||||
|
||||
|
||||
class Team(MP_Node, BaseModel):
|
||||
"""
|
||||
Represents the link between teams and users, specifying the role a user has in a team.
|
||||
|
||||
@@ -607,10 +742,29 @@ class Team(BaseModel):
|
||||
can see it.
|
||||
When a team is created from a Service Provider this one is automatically set in the
|
||||
Team `service_providers`.
|
||||
|
||||
The team `external_id` is used to synchronize the team with external systems, this
|
||||
is the equivalent of the User `sub` field but for teams (note: `sub` is highly related
|
||||
to the OIDC standard, while `external_id` is not). The `external_id` is NOT the same as
|
||||
the `externalId` field in the SCIM standard when importing teams from SCIM.
|
||||
"""
|
||||
|
||||
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
# Django treebeard does not allow max_length = None...
|
||||
steplen = 5
|
||||
path = models.CharField(max_length=5 * 80, unique=True, db_collation="C")
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
|
||||
external_id = models.UUIDField(
|
||||
verbose_name=_("external_id"),
|
||||
help_text=_("Team external UUID for synchronization with external systems"),
|
||||
unique=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
through="TeamAccess",
|
||||
@@ -629,6 +783,13 @@ class Team(BaseModel):
|
||||
related_name="teams",
|
||||
blank=True,
|
||||
)
|
||||
is_visible_all_services = models.BooleanField(
|
||||
_("is visible for all SP"),
|
||||
default=False,
|
||||
help_text=_("Whether this team is visible to all service providers."),
|
||||
)
|
||||
|
||||
objects = TeamManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "people_team"
|
||||
@@ -703,16 +864,12 @@ class TeamAccess(BaseModel):
|
||||
Override save function to fire webhooks on any addition or update
|
||||
to a team access.
|
||||
"""
|
||||
|
||||
if self._state.adding:
|
||||
if self._state.adding and self.team.webhooks.exists():
|
||||
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
|
||||
with transaction.atomic():
|
||||
instance = super().save(*args, **kwargs)
|
||||
scim_synchronizer.add_user_to_group(self.team, self.user)
|
||||
else:
|
||||
instance = super().save(*args, **kwargs)
|
||||
# try to synchronize all webhooks
|
||||
webhooks_synchronizer.add_user_to_group(self.team, self.user)
|
||||
|
||||
return instance
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""
|
||||
@@ -720,11 +877,12 @@ class TeamAccess(BaseModel):
|
||||
Don't allow deleting a team access until it is successfully synchronized with all
|
||||
its webhooks.
|
||||
"""
|
||||
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
|
||||
with transaction.atomic():
|
||||
arguments = self.team, self.user
|
||||
super().delete(*args, **kwargs)
|
||||
scim_synchronizer.remove_user_from_group(*arguments)
|
||||
if webhooks := self.team.webhooks.all():
|
||||
webhooks.update(status=WebhookStatusChoices.PENDING)
|
||||
# try to synchronize all webhooks
|
||||
webhooks_synchronizer.remove_user_from_group(self.team, self.user)
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
@@ -782,6 +940,11 @@ class TeamWebhook(BaseModel):
|
||||
team = models.ForeignKey(Team, related_name="webhooks", on_delete=models.CASCADE)
|
||||
url = models.URLField(_("url"))
|
||||
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
|
||||
protocol = models.CharField(
|
||||
max_length=None,
|
||||
default=WebhookProtocolChoices.SCIM,
|
||||
choices=WebhookProtocolChoices.choices,
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=10,
|
||||
default=WebhookStatusChoices.PENDING,
|
||||
@@ -804,36 +967,21 @@ class TeamWebhook(BaseModel):
|
||||
return headers
|
||||
|
||||
|
||||
class Invitation(BaseModel):
|
||||
"""User invitation to teams."""
|
||||
class BaseInvitation(BaseModel):
|
||||
"""Abstract base invitation model, surcharged for teams or domains."""
|
||||
|
||||
email = models.EmailField(_("email address"), null=False, blank=False)
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||
)
|
||||
issuer = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_invitation"
|
||||
verbose_name = _("Team invitation")
|
||||
verbose_name_plural = _("Team invitations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["email", "team"], name="email_and_team_unique_together"
|
||||
)
|
||||
]
|
||||
MAIL_TEMPLATE_HTML = None
|
||||
MAIL_TEMPLATE_TXT = None
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email} invited to {self.team}"
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Make invitations read-only."""
|
||||
@@ -862,6 +1010,79 @@ class Invitation(BaseModel):
|
||||
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||
return timezone.now() > (self.created_at + validity_duration)
|
||||
|
||||
def _get_mail_subject(self):
|
||||
"""Get the subject of the invitation."""
|
||||
return gettext("Invitation to join La Régie!")
|
||||
|
||||
def _get_mail_context(self):
|
||||
"""Get the template variables for the invitation."""
|
||||
return {
|
||||
"site": Site.objects.get_current(),
|
||||
}
|
||||
|
||||
def email_invitation(self):
|
||||
"""Email invitation to the user."""
|
||||
try:
|
||||
with override(self.issuer.language):
|
||||
subject = self._get_mail_subject()
|
||||
context = self._get_mail_context()
|
||||
msg_html = render_to_string(self.MAIL_TEMPLATE_HTML, context)
|
||||
msg_plain = render_to_string(self.MAIL_TEMPLATE_TXT, context)
|
||||
mail.send_mail(
|
||||
subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[self.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", self.email, exception)
|
||||
|
||||
|
||||
class Invitation(BaseInvitation):
|
||||
"""User invitation to teams."""
|
||||
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||
)
|
||||
|
||||
MAIL_TEMPLATE_HTML = "mail/html/team_invitation.html"
|
||||
MAIL_TEMPLATE_TXT = "mail/text/team_invitation.txt"
|
||||
|
||||
class Meta:
|
||||
db_table = "people_invitation"
|
||||
verbose_name = _("Team invitation")
|
||||
verbose_name_plural = _("Team invitations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["email", "team"], name="email_and_team_unique_together"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email} invited to {self.team}"
|
||||
|
||||
def _get_mail_subject(self):
|
||||
"""Get the subject of the team invitation."""
|
||||
return gettext(
|
||||
"[La Suite] You have been invited to become a %(role)s of a group"
|
||||
) % {"role": self.get_role_display().lower()}
|
||||
|
||||
def _get_mail_context(self):
|
||||
"""Get the template variables for the invitation."""
|
||||
return {
|
||||
**super()._get_mail_context(),
|
||||
"team": self.team.name,
|
||||
"role": self.get_role_display().lower(),
|
||||
}
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user."""
|
||||
can_delete = False
|
||||
@@ -887,24 +1108,44 @@ class Invitation(BaseModel):
|
||||
"put": False,
|
||||
}
|
||||
|
||||
def email_invitation(self):
|
||||
"""Email invitation to the user."""
|
||||
try:
|
||||
with override(self.issuer.language):
|
||||
template_vars = {
|
||||
"title": _("Invitation to join Desk!"),
|
||||
"site": Site.objects.get_current(),
|
||||
}
|
||||
msg_html = render_to_string("mail/html/invitation.html", template_vars)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||
mail.send_mail(
|
||||
_("Invitation to join Desk!"),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[self.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", self.email, exception)
|
||||
def validate_account_service_scope(scope):
|
||||
"""Validate the scope of the account service."""
|
||||
if scope not in settings.ACCOUNT_SERVICE_SCOPES:
|
||||
raise ValidationError(f"Invalid scope: {scope}")
|
||||
|
||||
|
||||
class AccountService(BaseModel):
|
||||
"""Account service model."""
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
api_key = models.CharField(
|
||||
_("api key"),
|
||||
max_length=255,
|
||||
)
|
||||
scopes = ArrayField(
|
||||
models.CharField(max_length=255, validators=[validate_account_service_scope]),
|
||||
verbose_name=_("allowed scopes"),
|
||||
help_text=_("Allowed scopes for this service"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_account_service"
|
||||
verbose_name = _("Account service")
|
||||
verbose_name_plural = _("Account services")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save method to generate a new api_key if it is not set.
|
||||
"""
|
||||
if not self.api_key:
|
||||
self.api_key = secrets.token_urlsafe(32)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
"""Indicate if the account service is authenticated."""
|
||||
return True
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core plugins package."""
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Base Django Application Configuration for plugins."""
|
||||
|
||||
|
||||
class BasePluginAppConfigMixIn:
|
||||
"""
|
||||
Configuration for the La Suite plugin application.
|
||||
|
||||
We cannot use the `AppConfig` class directly because it is not compatible with
|
||||
the Django way to discover default AppConfig (see `AppConfig.create`).
|
||||
We use a mixin then, to be able to list plugins using `plugins.la_suite` instead
|
||||
of `plugins.la_suite.apps.LaSuitePluginConfig`.
|
||||
|
||||
Another way would be to force `default` attribute on plugin AppConfig.
|
||||
"""
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Initialize the hooks registry when the application is ready.
|
||||
This is called by Django when the application is loaded.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from .registry import registry # noqa: PLC0415
|
||||
|
||||
registry.register_app(self.name) # pylint: disable=no-member
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Registry for hooks."""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Dict, List, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HooksRegistry:
|
||||
"""Registry for hooks."""
|
||||
|
||||
_available_hooks = {
|
||||
"organization_created",
|
||||
"organization_access_granted",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the registry."""
|
||||
self._hooks: Dict[str, List[Callable]] = {
|
||||
hook_name: [] for hook_name in self._available_hooks
|
||||
}
|
||||
self._registered_apps: Set[str] = set()
|
||||
|
||||
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
||||
"""
|
||||
Register a hook callback.
|
||||
|
||||
Args:
|
||||
hook_name: The name of the hook.
|
||||
callback: The callback function to register.
|
||||
"""
|
||||
try:
|
||||
self._hooks[hook_name].append(callback)
|
||||
except KeyError as exc:
|
||||
logger.exception(
|
||||
"Failed to register hook '%s' is not a valid hook: %s", hook_name, exc
|
||||
)
|
||||
logger.info("Registered hook %s: %s", hook_name, callback)
|
||||
|
||||
def get_registered_hooks(self):
|
||||
"""Get all registered hooks."""
|
||||
return self._hooks.items()
|
||||
|
||||
def register_app(self, app_name: str) -> None:
|
||||
"""
|
||||
Register an app as having hooks.
|
||||
|
||||
Args:
|
||||
app_name: The name of the app.
|
||||
"""
|
||||
if app_name in self._registered_apps:
|
||||
return
|
||||
|
||||
self._registered_apps.add(app_name)
|
||||
try:
|
||||
# Try to import the hooks module from the app
|
||||
__import__(f"{app_name}.hooks")
|
||||
logger.info("Registered hooks from app: %s", app_name)
|
||||
except ImportError:
|
||||
# It's okay if the app doesn't have a hooks module
|
||||
logger.info("App %s has no hooks module", app_name)
|
||||
|
||||
def get_callbacks(self, hook_name: str) -> List[Callable]:
|
||||
"""
|
||||
Get all callbacks for a hook.
|
||||
|
||||
Args:
|
||||
hook_name: The name of the hook.
|
||||
|
||||
Returns:
|
||||
A list of callback functions.
|
||||
"""
|
||||
try:
|
||||
return self._hooks[hook_name]
|
||||
except KeyError as exc:
|
||||
logger.exception(
|
||||
"Failed to get callbacks for hook '%s' is not a valid hook: %s",
|
||||
hook_name,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
|
||||
def execute_hook(self, hook_name: str, *args, **kwargs):
|
||||
"""
|
||||
Execute all callbacks for a hook.
|
||||
|
||||
Args:
|
||||
hook_name: The name of the hook.
|
||||
*args: Positional arguments to pass to the callbacks.
|
||||
**kwargs: Keyword arguments to pass to the callbacks.
|
||||
|
||||
Returns:
|
||||
A list of results from the callbacks.
|
||||
"""
|
||||
results = []
|
||||
for callback in self.get_callbacks(hook_name):
|
||||
try:
|
||||
result = callback(*args, **kwargs)
|
||||
results.append(result)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.exception("Error executing hook %s: %s", hook_name, e)
|
||||
return results
|
||||
|
||||
def reset(self):
|
||||
"""Function to reset the registry, to be used in test only."""
|
||||
self._hooks = {hook_name: [] for hook_name in self._available_hooks}
|
||||
self._registered_apps = set()
|
||||
|
||||
|
||||
# Create a singleton instance of the registry
|
||||
registry = HooksRegistry()
|
||||
|
||||
|
||||
def register_hook(hook_name: str):
|
||||
"""
|
||||
Decorator to register a function as a hook callback.
|
||||
|
||||
Args:
|
||||
hook_name: The name of the hook.
|
||||
|
||||
Returns:
|
||||
A decorator function.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
registry.register_hook(hook_name, func)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,16 @@
|
||||
"""URLs for plugins"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
plugins_urlpatterns = []
|
||||
|
||||
# Try to import and include URLs from each installed plugin
|
||||
for app in settings.INSTALLED_PLUGINS:
|
||||
try:
|
||||
plugins_urlpatterns.append(path("", include(f"{app}.urls")))
|
||||
except (ImportError, AttributeError):
|
||||
# Skip if app doesn't have urls.py
|
||||
continue
|
||||
|
||||
urlpatterns = plugins_urlpatterns
|
||||
@@ -1 +0,0 @@
|
||||
"""Backend resource server module."""
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Resource Server Authentication"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from mozilla_django_oidc.contrib.drf import OIDCAuthentication
|
||||
|
||||
from .backend import ResourceServerBackend, ResourceServerImproperlyConfiguredBackend
|
||||
from .clients import AuthorizationServerClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceServerAuthentication(OIDCAuthentication):
|
||||
"""Authenticate clients using the token received from the authorization server."""
|
||||
|
||||
def __init__(self):
|
||||
"""Require authentication to be configured in order to instantiate."""
|
||||
super().__init__()
|
||||
|
||||
try:
|
||||
authorization_server_client = AuthorizationServerClient(
|
||||
url=settings.OIDC_OP_URL,
|
||||
verify_ssl=settings.OIDC_VERIFY_SSL,
|
||||
timeout=settings.OIDC_TIMEOUT,
|
||||
proxy=settings.OIDC_PROXY,
|
||||
url_jwks=settings.OIDC_OP_JWKS_ENDPOINT,
|
||||
url_introspection=settings.OIDC_OP_INTROSPECTION_ENDPOINT,
|
||||
)
|
||||
self.backend = ResourceServerBackend(authorization_server_client)
|
||||
|
||||
except ImproperlyConfigured as err:
|
||||
message = "Resource Server authentication is disabled"
|
||||
logger.debug("%s. Exception: %s", message, err)
|
||||
self.backend = ResourceServerImproperlyConfiguredBackend()
|
||||
|
||||
def get_access_token(self, request):
|
||||
"""Retrieve and decode the access token from the request.
|
||||
|
||||
This method overrides the 'get_access_token' method from the parent class,
|
||||
to support service providers that would base64 encode the bearer token.
|
||||
"""
|
||||
|
||||
access_token = super().get_access_token(request)
|
||||
|
||||
try:
|
||||
access_token = base64.b64decode(access_token).decode("utf-8")
|
||||
except (binascii.Error, TypeError):
|
||||
pass
|
||||
|
||||
return access_token
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the request and return a tuple of (user, token) or None.
|
||||
|
||||
We override the 'authenticate' method from the parent class to store
|
||||
the introspected token audience inside the request.
|
||||
"""
|
||||
result = super().authenticate(request) # Might raise AuthenticationFailed
|
||||
|
||||
if result is None: # Case when there is no access token
|
||||
return None
|
||||
|
||||
# Note: at this stage, the request is a "drf_request" object
|
||||
request.resource_server_token_audience = self.backend.token_origin_audience
|
||||
|
||||
return result
|
||||
@@ -1,240 +0,0 @@
|
||||
"""Resource Server Backend"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.errors import InvalidClaimError, InvalidTokenError
|
||||
from requests.exceptions import HTTPError
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from . import utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceServerBackend:
|
||||
"""Backend of an OAuth 2.0 resource server.
|
||||
|
||||
This backend is designed to authenticate resource owners to our API using the access token
|
||||
they received from the authorization server.
|
||||
|
||||
In the context of OAuth 2.0, a resource server is a server that hosts protected resources and
|
||||
is capable of accepting and responding to protected resource requests using access tokens.
|
||||
The resource server verifies the validity of the access tokens issued by the authorization
|
||||
server to ensure secure access to the resources.
|
||||
|
||||
For more information, visit: https://www.oauth.com/oauth2-servers/the-resource-server/
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
def __init__(self, authorization_server_client):
|
||||
"""Require client_id, client_secret set and authorization_server_client provided."""
|
||||
# pylint: disable=invalid-name
|
||||
self.UserModel = auth.get_user_model()
|
||||
|
||||
self._client_id = settings.OIDC_RS_CLIENT_ID
|
||||
self._client_secret = settings.OIDC_RS_CLIENT_SECRET
|
||||
self._encryption_encoding = settings.OIDC_RS_ENCRYPTION_ENCODING
|
||||
self._encryption_algorithm = settings.OIDC_RS_ENCRYPTION_ALGO
|
||||
self._signing_algorithm = settings.OIDC_RS_SIGNING_ALGO
|
||||
self._scopes = settings.OIDC_RS_SCOPES
|
||||
|
||||
if (
|
||||
not self._client_id
|
||||
or not self._client_secret
|
||||
or not authorization_server_client
|
||||
):
|
||||
raise ImproperlyConfigured(
|
||||
"Could not instantiate ResourceServerBackend, some parameters are missing."
|
||||
)
|
||||
|
||||
self._authorization_server_client = authorization_server_client
|
||||
|
||||
self._claims_registry = jose_jwt.JWTClaimsRegistry(
|
||||
iss={"essential": True, "value": self._authorization_server_client.url},
|
||||
aud={"essential": True, "value": self._client_id},
|
||||
token_introspection={"essential": True},
|
||||
)
|
||||
|
||||
# Declare the token origin audience: to know where the token comes from
|
||||
# and store it for further use in the application
|
||||
self.token_origin_audience = None
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Maintain API compatibility with OIDCAuthentication class from mozilla-django-oidc
|
||||
|
||||
Params 'id_token', 'payload' won't be used, and our implementation will only
|
||||
support 'get_user', not 'get_or_create_user'.
|
||||
"""
|
||||
|
||||
return self.get_user(access_token)
|
||||
|
||||
def get_user(self, access_token):
|
||||
"""Get user from an access token emitted by the authorization server.
|
||||
|
||||
This method will submit the access token to the authorization server for
|
||||
introspection, to ensure its validity and obtain the associated metadata.
|
||||
|
||||
It follows the specifications outlined in RFC7662 https://www.rfc-editor.org/info/rfc7662,
|
||||
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
|
||||
|
||||
In our eGovernment applications, the standard RFC 7662 doesn't provide sufficient security.
|
||||
Its introspection response is a plain JSON object. Therefore, we use the draft RFC
|
||||
that extends RFC 7662 by returning a signed and encrypted JWT for stronger assurance that
|
||||
the authorization server issued the token introspection response.
|
||||
"""
|
||||
self.token_origin_audience = None # Reset the token origin audience
|
||||
|
||||
jwt = self._introspect(access_token)
|
||||
claims = self._verify_claims(jwt)
|
||||
user_info = self._verify_user_info(claims["token_introspection"])
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if sub is None:
|
||||
message = "User info contained no recognizable user identification"
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
try:
|
||||
user = self.UserModel.objects.get(sub=sub)
|
||||
except self.UserModel.DoesNotExist:
|
||||
logger.debug("Login failed: No user with %s found", sub)
|
||||
return None
|
||||
|
||||
self.token_origin_audience = str(user_info["aud"])
|
||||
|
||||
return user
|
||||
|
||||
def _verify_user_info(self, introspection_response):
|
||||
"""Verify the 'introspection_response' to get valid and relevant user info.
|
||||
|
||||
The 'introspection_response' should be still active, and while authenticating
|
||||
the resource owner should have requested relevant scope to access her data in
|
||||
our resource server.
|
||||
|
||||
Scope should be configured to match between the AS and the RS. The AS will filter
|
||||
all the scopes the resource owner requested to expose only the relevant ones to
|
||||
our resource server.
|
||||
"""
|
||||
|
||||
active = introspection_response.get("active", None)
|
||||
|
||||
if not active:
|
||||
message = "Introspection response is not active."
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
requested_scopes = introspection_response.get("scope", None).split(" ")
|
||||
if set(self._scopes).isdisjoint(set(requested_scopes)):
|
||||
message = "Introspection response contains any required scopes."
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
audience = introspection_response.get("aud", None)
|
||||
if not audience:
|
||||
raise SuspiciousOperation(
|
||||
"Introspection response does not provide source audience."
|
||||
)
|
||||
|
||||
return introspection_response
|
||||
|
||||
def _introspect(self, token):
|
||||
"""Introspect an access token to the authorization server."""
|
||||
try:
|
||||
jwe = self._authorization_server_client.get_introspection(
|
||||
self._client_id,
|
||||
self._client_secret,
|
||||
token,
|
||||
)
|
||||
except HTTPError as err:
|
||||
message = "Could not fetch introspection"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
private_key = utils.import_private_key_from_settings()
|
||||
jws = self._decrypt(jwe, private_key=private_key)
|
||||
|
||||
try:
|
||||
public_key_set = self._authorization_server_client.import_public_keys()
|
||||
except (TypeError, ValueError, AttributeError, HTTPError) as err:
|
||||
message = "Could get authorization server JWKS"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
jwt = self._decode(jws, public_key_set)
|
||||
|
||||
return jwt
|
||||
|
||||
def _decrypt(self, encrypted_token, private_key):
|
||||
"""Decrypt the token encrypted by the Authorization Server (AS).
|
||||
|
||||
Resource Server (RS)'s public key is used for encryption, and its private
|
||||
key is used for decryption. The RS's public key is exposed to the AS via a JWKS endpoint.
|
||||
Encryption Algorithm and Encoding should be configured to match between the AS
|
||||
and the RS.
|
||||
"""
|
||||
|
||||
try:
|
||||
decrypted_token = jose_jwe.decrypt_compact(
|
||||
encrypted_token,
|
||||
private_key,
|
||||
algorithms=[self._encryption_algorithm, self._encryption_encoding],
|
||||
)
|
||||
except Exception as err:
|
||||
message = "Token decryption failed"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return decrypted_token
|
||||
|
||||
def _decode(self, encoded_token, public_key_set):
|
||||
"""Decode the token signed by the Authorization Server (AS).
|
||||
|
||||
AS's private key is used for signing, and its public key is used for decoding.
|
||||
The AS public key is exposed via a JWK endpoint.
|
||||
Signing Algorithm should be configured to match between the AS and the RS.
|
||||
"""
|
||||
try:
|
||||
token = jose_jwt.decode(
|
||||
encoded_token.plaintext,
|
||||
public_key_set,
|
||||
algorithms=[self._signing_algorithm],
|
||||
)
|
||||
except ValueError as err:
|
||||
message = "Token decoding failed"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return token
|
||||
|
||||
def _verify_claims(self, token):
|
||||
"""Verify the claims of the token to ensure authentication security.
|
||||
|
||||
By verifying these claims, we ensure that the token was issued by a
|
||||
trusted authorization server and is intended for this specific
|
||||
resource server. This prevents various types of attacks, such as
|
||||
token substitution or misuse of tokens issued for different clients.
|
||||
"""
|
||||
try:
|
||||
self._claims_registry.validate(token.claims)
|
||||
except (InvalidClaimError, InvalidTokenError) as err:
|
||||
message = "Failed to validate token's claims"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return token.claims
|
||||
|
||||
|
||||
class ResourceServerImproperlyConfiguredBackend:
|
||||
"""Fallback backend for improperly configured Resource Servers."""
|
||||
|
||||
token_origin_audience = None
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Indicate that the Resource Server is improperly configured."""
|
||||
raise AuthenticationFailed("Resource Server is improperly configured")
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Resource Server Clients classes"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import requests
|
||||
from joserfc.jwk import KeySet
|
||||
|
||||
|
||||
class AuthorizationServerClient:
|
||||
"""Client for interacting with an OAuth 2.0 authorization server.
|
||||
|
||||
An authorization server issues access tokens to client applications after authenticating
|
||||
and obtaining authorization from the resource owner. It also provides endpoints for token
|
||||
introspection and JSON Web Key Sets (JWKS) to validate and decode tokens.
|
||||
|
||||
This client facilitates communication with the authorization server, including:
|
||||
- Fetching token introspection responses.
|
||||
- Fetching JSON Web Key Sets (JWKS) for token validation.
|
||||
- Setting appropriate headers for secure communication as recommended by RFC drafts.
|
||||
"""
|
||||
|
||||
# ruff: noqa: PLR0913 PLR0917
|
||||
# pylint: disable=too-many-positional-arguments
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
url,
|
||||
url_jwks,
|
||||
url_introspection,
|
||||
verify_ssl,
|
||||
timeout,
|
||||
proxy,
|
||||
):
|
||||
"""Require at a minimum url, url_jwks and url_introspection."""
|
||||
|
||||
if not url or not url_jwks or not url_introspection:
|
||||
raise ImproperlyConfigured(
|
||||
"Could not instantiate AuthorizationServerClient, some parameters are missing."
|
||||
)
|
||||
|
||||
self.url = url
|
||||
self._url_introspection = url_introspection
|
||||
self._url_jwks = url_jwks
|
||||
self._verify_ssl = verify_ssl
|
||||
self._timeout = timeout
|
||||
self._proxy = proxy
|
||||
|
||||
@property
|
||||
def _introspection_headers(self):
|
||||
"""Get HTTP header for the introspection request.
|
||||
|
||||
Notify the authorization server that we expect a signed and encrypted response
|
||||
by setting the appropriate 'Accept' header.
|
||||
|
||||
This follows the recommendation from the draft RFC:
|
||||
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
|
||||
"""
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/token-introspection+jwt",
|
||||
}
|
||||
|
||||
def get_introspection(self, client_id, client_secret, token):
|
||||
"""Retrieve introspection response about a token."""
|
||||
response = requests.post(
|
||||
self._url_introspection,
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"token": token,
|
||||
},
|
||||
headers=self._introspection_headers,
|
||||
verify=self._verify_ssl,
|
||||
timeout=self._timeout,
|
||||
proxies=self._proxy,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
def get_jwks(self):
|
||||
"""Retrieve Authorization Server JWKS."""
|
||||
response = requests.get(
|
||||
self._url_jwks,
|
||||
verify=self._verify_ssl,
|
||||
timeout=self._timeout,
|
||||
proxies=self._proxy,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def import_public_keys(self):
|
||||
"""Retrieve and import Authorization Server JWKS."""
|
||||
|
||||
jwks = self.get_jwks()
|
||||
public_keys = KeySet.import_key_set(jwks)
|
||||
|
||||
return public_keys
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
Mixins for resource server views.
|
||||
"""
|
||||
|
||||
from rest_framework import exceptions as drf_exceptions
|
||||
|
||||
from .authentication import ResourceServerAuthentication
|
||||
|
||||
|
||||
class ResourceServerMixin:
|
||||
"""
|
||||
Mixin for resource server views:
|
||||
- Restrict the authentication class to ResourceServerAuthentication.
|
||||
- Adds the Service Provider ID to the serializer context.
|
||||
- Fetch the Service Provider ID from the OIDC introspected token stored
|
||||
in the request.
|
||||
|
||||
This Mixin *must* be used in every view that should act as a resource server.
|
||||
"""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# When used as a resource server, we need to know the audience to automatically:
|
||||
# - add the Service Provider to the team "scope" on creation
|
||||
context["from_service_provider_audience"] = (
|
||||
self._get_service_provider_audience()
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def _get_service_provider_audience(self):
|
||||
"""Return the audience of the Service Provider from the OIDC introspected token."""
|
||||
if not isinstance(
|
||||
self.request.successful_authenticator, ResourceServerAuthentication
|
||||
):
|
||||
# We could check request.resource_server_token_audience here, but it's
|
||||
# more explicit to check the authenticator type and assert the attribute
|
||||
# existence.
|
||||
return None
|
||||
|
||||
# When used as a resource server, the request has a token audience
|
||||
service_provider_audience = self.request.resource_server_token_audience
|
||||
|
||||
if not service_provider_audience: # should not happen
|
||||
raise drf_exceptions.AuthenticationFailed(
|
||||
"Resource server token audience not found in request"
|
||||
)
|
||||
|
||||
return service_provider_audience
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Resource Server URL Configuration"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from .views import JWKSView
|
||||
|
||||
urlpatterns = [
|
||||
path("jwks", JWKSView.as_view(), name="resource_server_jwks"),
|
||||
]
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Resource Server utils functions"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from joserfc.jwk import JWKRegistry
|
||||
|
||||
|
||||
def import_private_key_from_settings():
|
||||
"""Import the private key used by the resource server when interacting with the OIDC provider.
|
||||
|
||||
This private key is crucial; its public components are exposed in the JWK endpoints,
|
||||
while its private component is used for decrypting the introspection token retrieved
|
||||
from the OIDC provider.
|
||||
|
||||
By default, we recommend using RSAKey for asymmetric encryption,
|
||||
known for its strong security features.
|
||||
|
||||
Note:
|
||||
- The function requires the 'OIDC_RS_PRIVATE_KEY_STR' setting to be configured.
|
||||
- The 'OIDC_RS_ENCRYPTION_KEY_TYPE' and 'OIDC_RS_ENCRYPTION_ALGO' settings can be customized
|
||||
based on the chosen key type.
|
||||
|
||||
Raises:
|
||||
ImproperlyConfigured: If the private key setting is missing, empty, or incorrect.
|
||||
|
||||
Returns:
|
||||
joserfc.jwk.JWK: The imported private key as a JWK object.
|
||||
"""
|
||||
|
||||
private_key_str = getattr(settings, "OIDC_RS_PRIVATE_KEY_STR", None)
|
||||
if not private_key_str:
|
||||
raise ImproperlyConfigured(
|
||||
"OIDC_RS_PRIVATE_KEY_STR setting is missing or empty."
|
||||
)
|
||||
|
||||
private_key_pem = private_key_str.encode()
|
||||
|
||||
try:
|
||||
private_key = JWKRegistry.import_key(
|
||||
private_key_pem,
|
||||
key_type=settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
parameters={"alg": settings.OIDC_RS_ENCRYPTION_ALGO, "use": "enc"},
|
||||
)
|
||||
except ValueError as err:
|
||||
raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is wrong.") from err
|
||||
|
||||
return private_key
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Resource Server views"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from joserfc.jwk import KeySet
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from . import utils
|
||||
|
||||
|
||||
class JWKSView(APIView):
|
||||
"""
|
||||
API endpoint for retrieving a JSON Web Keys Set (JWKS).
|
||||
|
||||
Returns:
|
||||
Response: JSON response containing the JWKS data.
|
||||
"""
|
||||
|
||||
authentication_classes = [] # disable authentication
|
||||
permission_classes = [] # disable permission
|
||||
|
||||
def get(self, request):
|
||||
"""Handle GET requests to retrieve JSON Web Keys Set (JWKS).
|
||||
|
||||
Returns:
|
||||
Response: JSON response containing the JWKS data.
|
||||
"""
|
||||
|
||||
try:
|
||||
private_key = utils.import_private_key_from_settings()
|
||||
except (ImproperlyConfigured, ValueError) as err:
|
||||
return Response({"error": str(err)}, status=500)
|
||||
|
||||
try:
|
||||
jwk = KeySet([private_key]).as_dict(private=False)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return Response({"error": "Could not load key"}, status=500)
|
||||
|
||||
return Response(jwk)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@@ -2,11 +2,16 @@
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory, override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from core import factories, models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.authentication.backends import (
|
||||
AccountServiceAuthentication,
|
||||
OIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
User = get_user_model()
|
||||
@@ -95,7 +100,7 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
with django_assert_num_queries(4):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -155,7 +160,8 @@ def test_authentication_getter_existing_user_via_email(
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
with django_assert_num_queries(5):
|
||||
# 5 = user by email + user by sub + update sub + 2 from django-lasuite
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -233,7 +239,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.name == "John Doe"
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -252,7 +258,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
match="Claims verification failed",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
@@ -385,6 +391,8 @@ def test_authentication_getter_new_user_with_registration_id_new_organization(
|
||||
assert user.organization.domain_list == expected_domain_list
|
||||
assert user.organization.registration_id_list == expected_registration_id_list
|
||||
|
||||
assert models.OrganizationAccess.objects.filter(user=user).exists() is False
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email_update_organization(
|
||||
django_assert_num_queries, monkeypatch
|
||||
@@ -406,7 +414,7 @@ def test_authentication_getter_existing_user_via_email_update_organization(
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(9):
|
||||
with django_assert_num_queries(11):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -453,3 +461,60 @@ def test_authentication_getter_existing_user_with_registration_id(
|
||||
|
||||
assert user.organization is not None
|
||||
assert user.organization.registration_id_list == ["12345678901234"]
|
||||
|
||||
|
||||
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
|
||||
def test_account_service_authenticate_valid_api_key():
|
||||
"""Test the authenticate method with a valid API key."""
|
||||
request = RequestFactory().get("/")
|
||||
account_service = factories.AccountServiceFactory(
|
||||
name="test_service",
|
||||
api_key="test_api_key_123",
|
||||
scopes=["la-suite-list-organizations-siret"],
|
||||
)
|
||||
request.headers = {"Authorization": f"ApiKey {account_service.api_key}"}
|
||||
|
||||
result = AccountServiceAuthentication().authenticate(request)
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == account_service
|
||||
assert result[1] == account_service.api_key
|
||||
|
||||
|
||||
def test_account_service_authenticate_missing_api_key():
|
||||
"""Test the authenticate method with a missing API key."""
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {}
|
||||
|
||||
result = AccountServiceAuthentication().authenticate(request)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_account_service_authenticate_invalid_api_key():
|
||||
"""Test the authenticate method with an invalid API key."""
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "ApiKey invalid_key"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
AccountServiceAuthentication().authenticate(request)
|
||||
|
||||
|
||||
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
|
||||
def test_account_service_authenticate_invalid_header():
|
||||
"""Test the authenticate method with an invalid header."""
|
||||
request = RequestFactory().get("/")
|
||||
account_service = factories.AccountServiceFactory(
|
||||
name="test_service",
|
||||
api_key="test_api_key_123",
|
||||
scopes=["la-suite-list-organizations-siret"],
|
||||
)
|
||||
request.headers = {"Authorization": f"Bearer {account_service.api_key}"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
AccountServiceAuthentication().authenticate(request)
|
||||
|
||||
request.headers = {"Authorization": account_service.api_key}
|
||||
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
AccountServiceAuthentication().authenticate(request)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Unit tests for the Authentication URLs."""
|
||||
|
||||
from core.authentication.urls import urlpatterns
|
||||
|
||||
|
||||
def test_urls_override_default_mozilla_django_oidc():
|
||||
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
|
||||
|
||||
url_names = [u.name for u in urlpatterns]
|
||||
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_anonymous():
|
||||
"""Anonymous users calling the logout url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
||||
)
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = client.get(url)
|
||||
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
||||
)
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
response = client.get(url)
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/default-redirect-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback_anonymous():
|
||||
"""Anonymous users calling the logout callback url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_oidc_states",
|
||||
[{}, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_oidc_states:
|
||||
request.session["oidc_states"] = initial_oidc_states
|
||||
request.session.save()
|
||||
|
||||
mocked_state = "mock_state"
|
||||
|
||||
OIDCLogoutView().persist_state(request, mocked_state)
|
||||
|
||||
assert "oidc_states" in request.session
|
||||
assert request.session["oidc_states"] == {
|
||||
"mock_state": {},
|
||||
**initial_oidc_states,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
||||
def test_view_logout_construct_oidc_logout_url(
|
||||
mocked_get_random_string, mocked_persist_state
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
||||
request.session.save()
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
mocked_persist_state.assert_called_once()
|
||||
mocked_get_random_string.assert_called_once()
|
||||
|
||||
params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
||||
assert params["state"][0] == "mocked_state"
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
assert url in params["post_logout_redirect_uri"][0]
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
||||
def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
assert redirect_url == "/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[None, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_state:
|
||||
request.session["oidc_states"] = initial_state
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
||||
callback_view(request)
|
||||
|
||||
assert (
|
||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
|
||||
request.session["oidc_states"] = {mocked_state: {}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
|
||||
def clear_user(request):
|
||||
# Assert state is cleared prior to logout
|
||||
assert request.session["oidc_states"] == {}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
mock_logout.side_effect = clear_user
|
||||
response = callback_view(request)
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user