Compare commits
267 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f866529307 | |||
| 99eaec9653 | |||
| 2ab3a67a44 | |||
| 0fd29e82d5 | |||
| 0776b7d95a | |||
| 19f7bb77c0 | |||
| 83cae4036c | |||
| ed07eecad4 | |||
| 46ba75b119 | |||
| 1b4d1043af | |||
| bc458b3f68 | |||
| 5bc845172b | |||
| 8a35e862ff | |||
| bc0dbcb678 | |||
| 68ec85e94a | |||
| ca754d5bcd | |||
| 06df255072 | |||
| da1a9e1d7a | |||
| 74ada8fd6b | |||
| 0c123bb9de | |||
| 2a6367c3ac | |||
| 79bdc6606e | |||
| c53434411d | |||
| b9c69e7c08 | |||
| b1e0a24626 | |||
| a1b58fb864 | |||
| e1a8cc31f9 | |||
| d0e5aa5952 | |||
| 3b58fb7e1e | |||
| 1e02ded7b8 | |||
| 92fcb359c9 | |||
| 8c9b35ec47 | |||
| 4e75d675f8 | |||
| 1d4d40aad0 | |||
| aaa9b27c61 | |||
| a29ef05d8b | |||
| 40c39bd9ba | |||
| 59f9f54b34 | |||
| 7804583632 | |||
| 63b8984eb2 | |||
| 0df8f53037 | |||
| 41084baa0f | |||
| 32de0b9221 | |||
| 1384640a3a | |||
| 569aff05a1 | |||
| b13f4db536 | |||
| 8ace3099b9 | |||
| 38d2125ecf | |||
| 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 |
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Create empty source files
|
||||
run: |
|
||||
touch src/backend/locale/django.pot
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
@@ -20,19 +20,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
# Backend i18n
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .
|
||||
python-version-file: "src/backend/pyproject.toml"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
working-directory: src/backend
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
@@ -45,10 +45,10 @@ jobs:
|
||||
- name: generate pot files
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
|
||||
DJANGO_CONFIGURATION=Build uv run python manage.py makemessages -a --keep-pot
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '18.x'
|
||||
default: '22.x'
|
||||
type: string
|
||||
with-front-dependencies-installation:
|
||||
type: boolean
|
||||
@@ -20,16 +20,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
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
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
- name: Install dependencies
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
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
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
@@ -50,10 +50,10 @@ jobs:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
- name: Cache mail templates
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
name: Run trivy scan (frontend)
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target frontend-production -f Dockerfile'
|
||||
docker-build-args: '--target frontend-production -f src/frontend/Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
|
||||
|
||||
build-and-push-backend:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -58,7 +58,10 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
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
|
||||
@@ -82,7 +85,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -99,12 +102,16 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
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' }}
|
||||
@@ -118,12 +125,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
id: notify
|
||||
with:
|
||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
|
||||
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
@@ -22,20 +22,24 @@ jobs:
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- 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 -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:
|
||||
@@ -45,17 +49,17 @@ jobs:
|
||||
github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
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
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
@@ -69,10 +73,10 @@ jobs:
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
@@ -82,7 +86,7 @@ jobs:
|
||||
run: cd src/frontend/ && yarn ci:build
|
||||
|
||||
- name: Cache build frontend
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
@@ -92,10 +96,10 @@ jobs:
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
@@ -109,10 +113,10 @@ jobs:
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
@@ -132,7 +136,7 @@ jobs:
|
||||
shardTotal: [4]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
@@ -140,7 +144,7 @@ jobs:
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
@@ -148,14 +152,14 @@ jobs:
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Restore the build cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: cache-build
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
@@ -167,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
|
||||
@@ -177,10 +183,6 @@ jobs:
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Setup Dimail DB
|
||||
run: |
|
||||
make dimail-setup-db
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
|
||||
@@ -218,19 +220,22 @@ jobs:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
python-version-file: "src/backend/pyproject.toml"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
run: uv run ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
run: uv run ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint .
|
||||
run: uv run pylint .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -264,29 +269,35 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: mail-templates
|
||||
with:
|
||||
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
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
python-version-file: "src/backend/pyproject.toml"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the dependencies
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Install gettext (required to compile messages)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
run: uv run pytest -n 2
|
||||
|
||||
@@ -5,8 +5,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- ./src/helm/desk/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -15,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
-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
|
||||
+198
-1
@@ -8,6 +8,188 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✨(mailboxes) enforce lowercase on mailboxes
|
||||
- 🐛(i18n) fix missing translations for status tag labels
|
||||
- 🚚(route) prioritize mail domains as default landing page
|
||||
|
||||
### Changed
|
||||
|
||||
- 🍱(static) update logo in invitation email template #1085
|
||||
- ✨(uiV2) use Lasuite UI kit, new layout
|
||||
|
||||
## [1.23.1] - 2026-02-16
|
||||
|
||||
- ✨(invitations) refresh expired invitations
|
||||
|
||||
## [1.23.0] - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(demo) add aliases to demo #1050
|
||||
- ✨(front) add icon to button to configure a domain
|
||||
- ✨(datagrid) add sort to mailboxes list + mail domain list
|
||||
- ✨(invitations) allow delete invitations mails domains access by an admin
|
||||
- ✨(front) delete invitations mails domains access
|
||||
- ✨(front) add show invitations mails domains access #1040
|
||||
- ✨(invitations) can delete domain invitations
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(domains) fix attemps to send invitations to existing users #953
|
||||
|
||||
### Changed
|
||||
|
||||
- 🚸(email) we should ignore case when looking for existing emails #1056
|
||||
- 🏗️(core) migrate from pip to uv
|
||||
- ✨(front) add show invitations mails domains access #1040
|
||||
|
||||
## [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
|
||||
@@ -341,7 +523,22 @@ and this project adheres to
|
||||
- ✨(domains) create and manage domains on admin + API
|
||||
- ✨(domains) mailbox creation + link to email provisioning API
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/people/compare/v1.14.1...main
|
||||
[unreleased]: https://github.com/suitenumerique/people/compare/v1.23.1...main
|
||||
[1.23.1]: https://github.com/suitenumerique/people/releases/v1.23.1
|
||||
[1.23.0]: https://github.com/suitenumerique/people/releases/v1.23.0
|
||||
[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
|
||||
|
||||
+37
-80
@@ -1,84 +1,39 @@
|
||||
# Django People
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.12.6-alpine3.20 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
FROM python:3.14.2-alpine AS base
|
||||
|
||||
# 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.27-alpine AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
RUN apk update && apk upgrade libssl3 libcrypto3
|
||||
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
COPY --from=frontend-builder \
|
||||
/builder/apps/desk/out \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Copy required python dependencies
|
||||
COPY ./src/backend /builder
|
||||
# Disable Python downloads, because we want to use the system interpreter
|
||||
# across both images. If using a managed Python version, it needs to be
|
||||
# copied from the build image into the final image;
|
||||
ENV UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
# install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
COPY src/backend /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 AS mail-builder
|
||||
FROM node:22 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
@@ -97,14 +52,13 @@ RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy people application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
python manage.py collectstatic --noinput
|
||||
@@ -135,14 +89,18 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
# docker user (see entrypoint).
|
||||
RUN chmod g=u /etc/passwd
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy people application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Ensure the uv venv is used at runtime
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Generate compiled translation messages
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py compilemessages --ignore=".venv/**/*"
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||
# ID.
|
||||
@@ -157,10 +115,9 @@ USER root:root
|
||||
# Install psql
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall people and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
RUN pip uninstall -y people
|
||||
RUN pip install -e .[dev]
|
||||
# Install development dependencies
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.10,source=/uv,target=/bin/uv \
|
||||
uv sync --all-extras --locked
|
||||
|
||||
# Restore the un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
|
||||
@@ -41,11 +41,9 @@ DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm --no-deps
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
WAIT_KC_DB = $(COMPOSE_RUN) dockerize -wait tcp://kc_postgresql:5432 -timeout 60s
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
@@ -78,24 +76,39 @@ create-env-files: \
|
||||
env.d/development/kc_postgresql
|
||||
.PHONY: create-env-files
|
||||
|
||||
add-dev-rsa-private-key-to-env: ## Add a generated RSA private key to the env file
|
||||
@echo "Generating RSA private key PEM for development..."
|
||||
@mkdir -p env.d/development/rsa
|
||||
@openssl genrsa -out env.d/development/rsa/private.pem 2048
|
||||
@echo -n "\nOAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY=\"" >> env.d/development/common
|
||||
@openssl rsa -in env.d/development/rsa/private.pem -outform PEM >> env.d/development/common
|
||||
@echo "\"" >> env.d/development/common
|
||||
@rm -rf env.d/development/rsa
|
||||
.PHONY: add-dev-rsa-private-key-to-env
|
||||
|
||||
update-keycloak-realm-app: ## Create the Keycloak realm for the project
|
||||
@echo "$(BOLD)Creating Keycloak realm for 'app'$(RESET)"
|
||||
@sed -i 's|http://app-dev:8000|http://app:8000|g' ./docker/auth/realm.json
|
||||
.PHONY: update-keycloak-realm-app
|
||||
|
||||
bootstrap: ## Prepare Docker images for the project and install frontend dependencies
|
||||
bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
run \
|
||||
run-dev \
|
||||
migrate \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build \
|
||||
dimail-setup-db \
|
||||
install-front-desk
|
||||
dimail-setup-db
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev
|
||||
@$(COMPOSE) build app-dev frontend-dev
|
||||
@$(COMPOSE) build dimail
|
||||
.PHONY: build
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -106,19 +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 celery-beat-dev
|
||||
@$(COMPOSE) up --force-recreate -d flower-dev
|
||||
@$(COMPOSE) up --force-recreate -d keycloak
|
||||
@$(COMPOSE) up -d dimail
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_KC_DB)
|
||||
@$(WAIT_DB)
|
||||
run: ## start the wsgi (production) and servers with production Docker images
|
||||
@$(COMPOSE) up --force-recreate --detach app frontend celery celery-beat nginx maildev
|
||||
.PHONY: run
|
||||
|
||||
run-dev: ## start the servers in development mode (watch) Docker images
|
||||
@$(COMPOSE) up --force-recreate --detach app-dev dimail frontend-dev celery-dev celery-beat-dev nginx maildev
|
||||
.PHONY: run-dev
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE) ps
|
||||
.PHONY: status
|
||||
@@ -132,6 +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
|
||||
|
||||
|
||||
@@ -158,10 +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: ## lint front-end sources with eslint
|
||||
cd $(PATH_FRONT) && yarn lint
|
||||
.PHONY: lint-front
|
||||
|
||||
lint-front-fix: ## fix front-end sources with eslint
|
||||
cd $(PATH_FRONT) && yarn lint-fix
|
||||
.PHONY: lint-front-fix
|
||||
|
||||
test: ## run project tests
|
||||
@$(MAKE) test-back-parallel
|
||||
.PHONY: test
|
||||
@@ -182,22 +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
|
||||
|
||||
@@ -208,7 +215,7 @@ superuser: ## Create an admin superuser with password "admin"
|
||||
.PHONY: superuser
|
||||
|
||||
back-i18n-compile: ## compile the gettext files
|
||||
@$(MANAGE) compilemessages --ignore="venv/**/*"
|
||||
@$(MANAGE) compilemessages --ignore=".venv/**/*"
|
||||
.PHONY: back-i18n-compile
|
||||
|
||||
back-i18n-generate: ## create the .pot files used for i18n
|
||||
@@ -234,21 +241,21 @@ resetdb: ## flush database and create a superuser "admin"
|
||||
.PHONY: resetdb
|
||||
|
||||
env.d/development/common:
|
||||
cp -n env.d/development/common.dist env.d/development/common
|
||||
cp --update=none env.d/development/common.dist env.d/development/common
|
||||
|
||||
env.d/development/france:
|
||||
cp -n env.d/development/france.dist env.d/development/france
|
||||
cp --update=none env.d/development/france.dist env.d/development/france
|
||||
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
cp --update=none env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
env.d/development/kc_postgresql:
|
||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
cp --update=none env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
||||
cp --update=none env.d/development/crowdin.dist env.d/development/crowdin
|
||||
|
||||
crowdin-download: ## Download translated message from Crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||
@@ -289,14 +296,10 @@ i18n-generate-and-upload: \
|
||||
|
||||
# -- INTEROPERABILTY
|
||||
# -- Dimail configuration
|
||||
|
||||
recreate-dimail-container:
|
||||
@$(COMPOSE) up --force-recreate -d dimail
|
||||
.PHONY: recreate-dimail-container
|
||||
|
||||
dimail-setup-db:
|
||||
@$(COMPOSE) up --force-recreate -d dimail
|
||||
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
|
||||
@$(MANAGE) setup_dimail_db
|
||||
@$(MANAGE) setup_dimail_db --populate-from-people
|
||||
.PHONY: dimail-setup-db
|
||||
|
||||
# -- Mail generator
|
||||
@@ -425,3 +428,13 @@ install-secret: ## install the kubernetes secrets from Vaultwarden
|
||||
fetch-domain-status:
|
||||
@$(MANAGE) fetch_domain_status
|
||||
.PHONY: fetch-domain-status
|
||||
|
||||
# -- Keycloak related
|
||||
create-new-client: ## run the add-keycloak-client.sh script for keycloak.
|
||||
@echo "$(BOLD)Running add-keycloak-client.sh$(RESET)"
|
||||
@$(COMPOSE_RUN) \
|
||||
-v ./scripts/keycloak/add-keycloak-client.sh:/tmp/add-keycloak-client.sh \
|
||||
--entrypoint="/tmp/add-keycloak-client.sh" \
|
||||
keycloak \
|
||||
$(filter-out $@,$(MAKECMDGOALS))
|
||||
.PHONY: create-new-client
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# People
|
||||
|
||||
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
|
||||
People is an application to handle users and teams, and distribute permissions across [La Suite](https://lasuite.numerique.gouv.fr/).
|
||||
|
||||
It is built on top of [Django Rest
|
||||
Framework](https://www.django-rest-framework.org/).
|
||||
@@ -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.
|
||||
@@ -64,6 +64,23 @@ function _dc_run() {
|
||||
_docker_compose run --rm $user_args "$@"
|
||||
}
|
||||
|
||||
# _dc_run_no_deps: wrap docker compose run command without dependencies
|
||||
#
|
||||
# usage: _dc_run_no_deps [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose run command options
|
||||
# ARGS : docker compose run command arguments
|
||||
function _dc_run_no_deps() {
|
||||
_set_user
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose run --no-deps --rm $user_args "$@"
|
||||
}
|
||||
|
||||
# _dc_exec: wrap docker compose exec command
|
||||
#
|
||||
# usage: _dc_exec [options] [ARGS...]
|
||||
|
||||
+1
-1
@@ -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[@]}"
|
||||
|
||||
+126
-40
@@ -1,10 +1,16 @@
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
platform: linux/amd64
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "15432:5432"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
@@ -22,6 +28,7 @@ services:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-development
|
||||
pull_policy: never
|
||||
environment:
|
||||
- PYLINTHOME=/app/.pylint.d
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
@@ -35,12 +42,69 @@ services:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- dimail
|
||||
- postgresql
|
||||
- maildev
|
||||
- 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,8 +118,13 @@ services:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- app-dev
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
app-dev:
|
||||
condition: service_started
|
||||
|
||||
celery-beat-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -71,26 +140,9 @@ services:
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- app-dev
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
target: backend-production
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-production
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
volumes:
|
||||
- ./data/media:/data/media
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -102,7 +154,29 @@ services:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
depends_on:
|
||||
- app
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
app:
|
||||
condition: service_started
|
||||
|
||||
celery-beat:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-production
|
||||
command: ["celery", "-A", "people.celery_app", "beat", "-l", "INFO"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
@@ -111,11 +185,9 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- app
|
||||
- keycloak
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:4.6.1
|
||||
@@ -127,7 +199,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:18
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
@@ -135,11 +207,16 @@ services:
|
||||
- ".:/app"
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
@@ -156,6 +233,8 @@ services:
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
@@ -166,14 +245,21 @@ services:
|
||||
KC_DB_USERNAME: people
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready" ]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
dimail:
|
||||
entrypoint: /opt/dimail-api/start-dev.sh
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.1.1
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.5.2
|
||||
pull_policy: always
|
||||
environment:
|
||||
DIMAIL_MODE: FAKE
|
||||
|
||||
+61
-4
@@ -26,7 +26,7 @@
|
||||
"oauth2DeviceCodeLifespan": 600,
|
||||
"oauth2DevicePollingInterval": 5,
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": true,
|
||||
"registrationEmailAsUsername": false,
|
||||
"rememberMe": true,
|
||||
@@ -50,6 +50,9 @@
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -77,10 +80,13 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
"email": "user-e2e-chromium@example.org",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -91,10 +97,13 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.e2e",
|
||||
"email": "user-e2e-webkit@example.org",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -105,10 +114,13 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.e2e",
|
||||
"email": "user-e2e-firefox@example.org",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -123,6 +135,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -137,6 +152,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -151,6 +169,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -165,6 +186,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Member",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -179,6 +203,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -193,6 +220,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -207,6 +237,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Member",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -221,6 +254,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -235,6 +271,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Owner",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -249,6 +288,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Member",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -263,6 +305,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -277,6 +322,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Owner",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -291,6 +339,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Member",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -305,6 +356,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -319,6 +373,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
|
||||
@@ -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
|
||||
+27
-23
@@ -17,48 +17,52 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
|
||||
|
||||
This script will ask you for the version you want to release and the kind of release (patch, minor, major). It will then:
|
||||
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
- Create a new branch named: `release/4.18.1`.
|
||||
|
||||
2. Bump the release number for backend and frontend project.
|
||||
- Bump the release number for backend and frontend project.
|
||||
|
||||
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
|
||||
- Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
|
||||
|
||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
||||
- 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
|
||||
```
|
||||
|
||||
3. Open a pull request ask you to wait for an approval from your peers and merge it.
|
||||
- Triggers Crowdin translation action and open related PR
|
||||
|
||||
4. Ask you to tag and push your commit:
|
||||
- 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
|
||||
```
|
||||
|
||||
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
|
||||
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).
|
||||
|
||||
5. Ensure the new [backend](https://hub.docker.com/r/lasuite/people-backend/tags) and [frontend](https://hub.docker.com/r/lasuite/people-frontend/tags) image tags are on Docker Hub.
|
||||
|
||||
6. Create a PR on the [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement) repository to bump the preprod version.
|
||||
# Deploying
|
||||
|
||||
7. The release is now done!
|
||||
## Staging
|
||||
|
||||
# Deploying
|
||||
The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
|
||||
> [!TIP]
|
||||
> The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
## Pre-prod and production
|
||||
|
||||
Making a new release doesn't publish it automatically in 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.
|
||||
|
||||
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
|
||||
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
|
||||
|
||||
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
|
||||
|
||||
To publish the release you just made:
|
||||
|
||||
```bash
|
||||
git tag --force production v4.18.1
|
||||
git push --force origin production
|
||||
```
|
||||
The release is now done! 🎉
|
||||
|
||||
@@ -3,6 +3,7 @@ DJANGO_ALLOWED_HOSTS=*
|
||||
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=people.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
|
||||
# Python
|
||||
PYTHONPATH=/app
|
||||
@@ -65,3 +66,6 @@ IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
|
||||
MbyqKyC6DAzv4jEEhHaN7oY=
|
||||
-----END PRIVATE KEY-----
|
||||
"
|
||||
|
||||
# INTEROP
|
||||
MAIL_PROVISIONING_API_CREDENTIALS="bGFfcmVnaWU6cGFzc3dvcmQ=" # Dev and test key for dimail interop
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
|
||||
OAUTH2_PROVIDER_OIDC_ENABLED = True
|
||||
OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
|
||||
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD="siret"
|
||||
|
||||
OAUTH2_PROVIDER_OIDC_ENABLED=True
|
||||
OAUTH2_PROVIDER_VALIDATOR_CLASS="mailbox_oauth2.validators.ProConnectValidator"
|
||||
|
||||
INSTALLED_PLUGINS=plugins.la_suite
|
||||
|
||||
@@ -1 +1 @@
|
||||
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation
|
||||
INSTALLED_PLUGINS=plugins.la_suite
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint", "@hookform/resolvers"]
|
||||
},
|
||||
{
|
||||
"groupName": "docker-compose dependencies",
|
||||
"matchManagers": ["docker-compose"],
|
||||
"matchPackageNames": ["dimail-api"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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"
|
||||
+10
-5
@@ -102,11 +102,14 @@ def deployment_part(version):
|
||||
NEXT COMMAND on lasuite-deploiement repository:
|
||||
>> git push origin {deployment_branch}
|
||||
Continue ? (y,n)
|
||||
\x1b[0m""")
|
||||
\x1b[0m""").strip()
|
||||
if confirm == 'y':
|
||||
run_command(f"git push origin {deployment_branch}", shell=True)
|
||||
sys.stdout.write(f"""\033[1;34m
|
||||
PLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Check if you need to bump chart version in the manifests/regie/helmfile.yaml file
|
||||
and settings in the manifests/regie/env.d/preprod/values.desk.yaml.gotmpl file
|
||||
add commits into {deployment_branch} branch to do it if needed, then...
|
||||
--> Please submit PR {deployment_branch} and merge code to main [https://github.com/numerique-gouv/lasuite-deploiement]
|
||||
\x1b[0m""")
|
||||
|
||||
@@ -132,12 +135,14 @@ Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
|
||||
NEXT COMMAND on current repository:
|
||||
>> git push origin {branch_to_release}
|
||||
Continue ? (y,n)
|
||||
\x1b[0m""")
|
||||
\x1b[0m""").strip()
|
||||
if confirm == 'y':
|
||||
run_command(f"git push origin {branch_to_release}", shell=True)
|
||||
sys.stdout.write(f"""
|
||||
\033[1;34mPLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Please submit PR {branch_to_release} and merge code to main [https://github.com/suitenumerique/people/]
|
||||
--> A github action will download last translations from crowdin and create a PR (i18n/update-translations) to merge into the release branch.
|
||||
Please wait for this, approve and merge this translations PR into the release branch {branch_to_release}.
|
||||
--> Then please submit PR {branch_to_release} and merge code to main [https://github.com/suitenumerique/people/]
|
||||
--> Then do:
|
||||
>> git checkout main
|
||||
>> git pull
|
||||
@@ -153,9 +158,9 @@ Continue ? (y,n)
|
||||
if __name__ == "__main__":
|
||||
version, kind = None, None
|
||||
while not version:
|
||||
version = input("Enter your release version:")
|
||||
version = input("Enter your release version:").strip()
|
||||
while kind not in RELEASE_KINDS:
|
||||
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
|
||||
kind = input("Enter kind of release (p:patch, m:minor, mj:major):").strip()
|
||||
if "--only-deployment-part" not in sys.argv:
|
||||
project_part(version, kind)
|
||||
deployment_part(version)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""People custom admin site."""
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Custom Django admin site application configuration."""
|
||||
|
||||
from django.contrib.admin.apps import AdminConfig
|
||||
|
||||
|
||||
class PeopleAdminConfig(AdminConfig):
|
||||
"""Declare our custom Django admin site."""
|
||||
|
||||
default_site = "admin.sites.PeopleAdminSite"
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Custom Django admin site for the People app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
class PeopleAdminSite(admin.AdminSite):
|
||||
"""People custom admin site."""
|
||||
|
||||
def each_context(self, request):
|
||||
"""Add custom context to the admin site."""
|
||||
return super().each_context(request) | {
|
||||
"ADMIN_HEADER_BACKGROUND": settings.ADMIN_HEADER_BACKGROUND,
|
||||
"ADMIN_HEADER_COLOR": settings.ADMIN_HEADER_COLOR,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
|
||||
{% block extrastyle %}{{ block.super }}
|
||||
<style>
|
||||
html[data-theme="light"], :root {
|
||||
{% if ADMIN_HEADER_BACKGROUND %}--header-bg: {{ ADMIN_HEADER_BACKGROUND }};{% endif %}
|
||||
{% if ADMIN_HEADER_COLOR %}--header-color: {{ ADMIN_HEADER_COLOR }};{% endif %}
|
||||
}
|
||||
ul.messagelist li.info {
|
||||
background-color: #EEEEEE;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -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,5 +1,6 @@
|
||||
"""Admin classes and registrations for People's core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -10,10 +11,7 @@ from treebeard.forms import movenodeform_factory
|
||||
from mailbox_manager.admin import MailDomainAccessInline
|
||||
|
||||
from . import models
|
||||
from .plugins.loader import (
|
||||
get_organization_plugins,
|
||||
organization_plugins_run_after_create,
|
||||
)
|
||||
from .plugins.registry import registry as plugin_hooks_registry
|
||||
|
||||
|
||||
class TeamAccessInline(admin.TabularInline):
|
||||
@@ -229,7 +227,7 @@ class OrganizationServiceProviderInline(admin.TabularInline):
|
||||
def run_post_creation_plugins(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
"""Run the post creation plugins for the selected organizations."""
|
||||
for organization in queryset:
|
||||
organization_plugins_run_after_create(organization)
|
||||
plugin_hooks_registry.execute_hook("organization_created", organization)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
@@ -244,9 +242,11 @@ class OrganizationAdmin(admin.ModelAdmin):
|
||||
actions = [run_post_creation_plugins]
|
||||
list_display = (
|
||||
"name",
|
||||
"is_active",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("name",)
|
||||
inlines = (OrganizationAccessInline, OrganizationServiceProviderInline)
|
||||
exclude = ("service_providers",) # Handled by the inline
|
||||
@@ -254,7 +254,7 @@ class OrganizationAdmin(admin.ModelAdmin):
|
||||
def get_actions(self, request):
|
||||
"""Adapt actions list to the context."""
|
||||
actions = super().get_actions(request)
|
||||
if not get_organization_plugins():
|
||||
if not plugin_hooks_registry.get_callbacks("organization_created"):
|
||||
actions.pop("run_post_creation_plugins", None)
|
||||
return actions
|
||||
|
||||
@@ -285,3 +285,20 @@ class ServiceProviderAdmin(admin.ModelAdmin):
|
||||
)
|
||||
search_fields = ("name", "audience_id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(models.AccountService)
|
||||
class AccountServiceAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for account services."""
|
||||
|
||||
list_display = ("name", "created_at", "updated_at")
|
||||
readonly_fields = ("api_key", "created_at", "updated_at")
|
||||
|
||||
def get_form(self, request, obj=None, change=False, **kwargs):
|
||||
"""Add help text to the scopes field to provide list of available scopes."""
|
||||
form = super().get_form(request, obj, change, **kwargs)
|
||||
form.base_fields[
|
||||
"scopes"
|
||||
].help_text = f"Scopes define what the service can access. \
|
||||
Available scopes: {', '.join(settings.ACCOUNT_SERVICE_SCOPES)}"
|
||||
return form
|
||||
|
||||
@@ -29,6 +29,7 @@ from core.api import permissions
|
||||
from core.api.client import serializers
|
||||
from core.utils.raw_sql import gen_sql_filter_json_array
|
||||
|
||||
from mailbox_manager import enums
|
||||
from mailbox_manager import models as domains_models
|
||||
|
||||
|
||||
@@ -145,7 +146,7 @@ class ContactViewSet(
|
||||
"""Contact ViewSet"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.Contact.objects.select_related("user").all()
|
||||
queryset = models.Contact.objects.select_related("user", "owner").all()
|
||||
serializer_class = serializers.ContactSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
ordering_fields = ["full_name", "short_name", "created_at"]
|
||||
@@ -262,9 +263,10 @@ class UserViewSet(
|
||||
queryset = self.queryset
|
||||
|
||||
if self.action == "list":
|
||||
# Exclude inactive contacts
|
||||
# Filter active users
|
||||
# and users from same organization
|
||||
queryset = queryset.filter(
|
||||
is_active=True,
|
||||
is_active=True, organization_id=self.request.user.organization_id
|
||||
)
|
||||
|
||||
# Exclude all users already in the given team
|
||||
@@ -584,7 +586,13 @@ class ConfigView(views.APIView):
|
||||
GET /api/v1.0/config/
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = ["LANGUAGES", "FEATURES", "RELEASE", "COMMIT"]
|
||||
array_settings = [
|
||||
"LANGUAGES",
|
||||
"FEATURES",
|
||||
"RELEASE",
|
||||
"COMMIT",
|
||||
"CRISP_WEBSITE_ID",
|
||||
]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
@@ -609,8 +617,11 @@ class StatView(views.APIView):
|
||||
last_login__gte=timezone.now() - datetime.timedelta(30)
|
||||
).count(),
|
||||
"teams": models.Team.objects.count(),
|
||||
"domains": domains_models.MailDomain.objects.count(),
|
||||
"active_domains": domains_models.MailDomain.objects.filter(
|
||||
status=enums.MailDomainStatusChoices.ENABLED
|
||||
).count(),
|
||||
"mailboxes": domains_models.Mailbox.objects.count(),
|
||||
"aliases": domains_models.Alias.objects.count(),
|
||||
}
|
||||
return response.Response(context)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""SCIM compliant API for resource server"""
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Exceptions for SCIM API."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from core.api.resource_server.scim.response import ScimJsonResponse
|
||||
|
||||
|
||||
def scim_exception_handler(exc, _context):
|
||||
"""Handle SCIM exceptions and return them in the correct format."""
|
||||
data = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"status": str(exc.status_code),
|
||||
"detail": str(exc.detail) if settings.DEBUG else "",
|
||||
}
|
||||
|
||||
return ScimJsonResponse(data, status=exc.status_code)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""SCIM API response classes."""
|
||||
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
class ScimJsonResponse(Response):
|
||||
"""
|
||||
Custom JSON response class for SCIM API.
|
||||
|
||||
This class sets the content type to "application/json+scim" for SCIM
|
||||
responses.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""JSON response with enforced SCIM content type."""
|
||||
super().__init__(*args, content_type="application/json+scim", **kwargs)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""SCIM serializers for resource server API."""
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class SCIMUserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users in SCIM format."""
|
||||
|
||||
schemas = serializers.SerializerMethodField()
|
||||
userName = serializers.CharField(source="sub")
|
||||
displayName = serializers.CharField(source="name")
|
||||
emails = serializers.SerializerMethodField()
|
||||
meta = serializers.SerializerMethodField()
|
||||
groups = serializers.SerializerMethodField()
|
||||
active = serializers.BooleanField(source="is_active")
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"schemas",
|
||||
"active",
|
||||
"userName",
|
||||
"displayName",
|
||||
"emails",
|
||||
"groups",
|
||||
"meta",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"schemas",
|
||||
"active",
|
||||
"userName",
|
||||
"displayName",
|
||||
"emails",
|
||||
"groups",
|
||||
"meta",
|
||||
]
|
||||
|
||||
def get_schemas(self, _obj):
|
||||
"""Return the SCIM schemas for the user."""
|
||||
return ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||
|
||||
def get_emails(self, obj):
|
||||
"""Return the user's email as a list of email objects."""
|
||||
if obj.email:
|
||||
return [
|
||||
{
|
||||
"value": obj.email,
|
||||
"primary": True,
|
||||
"type": "work",
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
def get_groups(self, obj):
|
||||
"""
|
||||
Return the groups the user belongs to.
|
||||
|
||||
WARNING: you need to prefetch the team accesses in the
|
||||
viewset to avoid N+1 queries.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"value": str(team_access.team.external_id),
|
||||
"display": team_access.team.name,
|
||||
"type": "direct",
|
||||
}
|
||||
for team_access in obj.accesses.all()
|
||||
]
|
||||
|
||||
def get_meta(self, obj):
|
||||
"""Return metadata about the user."""
|
||||
request = self.context.get("request")
|
||||
location = (
|
||||
f"{request.build_absolute_uri('/').rstrip('/')}{reverse('scim-me-list')}"
|
||||
if request
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"resourceType": "User",
|
||||
"created": obj.created_at.isoformat(),
|
||||
"lastModified": obj.updated_at.isoformat(),
|
||||
"location": location,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Resource server SCIM API endpoints"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Prefetch, Q
|
||||
|
||||
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
|
||||
from rest_framework import (
|
||||
viewsets,
|
||||
)
|
||||
|
||||
from core.api import permissions
|
||||
from core.models import TeamAccess
|
||||
|
||||
from . import serializers
|
||||
from .exceptions import scim_exception_handler
|
||||
from .response import ScimJsonResponse
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class MeViewSet(ResourceServerMixin, viewsets.ViewSet):
|
||||
"""
|
||||
SCIM-compliant ViewSet for the /Me endpoint.
|
||||
|
||||
This endpoint provides information about the currently authenticated user
|
||||
in SCIM (System for Cross-domain Identity Management) format.
|
||||
|
||||
Features:
|
||||
- Returns user details in SCIM format.
|
||||
- Includes the user's teams, restricted to the audience.
|
||||
|
||||
Limitations:
|
||||
- Does not currently support managing Team hierarchies.
|
||||
|
||||
Endpoint:
|
||||
GET /resource-server/v1.0/scim/Me/
|
||||
Retrieves the authenticated user's details and associated teams.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = serializers.SCIMUserSerializer
|
||||
|
||||
def get_exception_handler(self):
|
||||
"""Override the default exception handler to use SCIM-specific handling."""
|
||||
return scim_exception_handler
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Return the current user's details in SCIM format."""
|
||||
service_provider_audience = self._get_service_provider_audience()
|
||||
|
||||
user = User.objects.prefetch_related(
|
||||
Prefetch(
|
||||
"accesses",
|
||||
queryset=TeamAccess.objects.select_related("team").filter(
|
||||
Q(team__service_providers__audience_id=service_provider_audience)
|
||||
| Q(team__is_visible_all_services=True)
|
||||
),
|
||||
)
|
||||
).get(pk=request.user.pk)
|
||||
|
||||
serializer = self.serializer_class(user, context={"request": request})
|
||||
return ScimJsonResponse(serializer.data)
|
||||
@@ -6,6 +6,7 @@ from functools import reduce
|
||||
from django.db.models import OuterRef, Prefetch, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
|
||||
from rest_framework import (
|
||||
filters,
|
||||
mixins,
|
||||
@@ -15,7 +16,6 @@ from rest_framework import (
|
||||
from core import models
|
||||
from core.api import permissions
|
||||
from core.api.client.viewsets import Pagination
|
||||
from core.resource_server.mixins import ResourceServerMixin
|
||||
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -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,122 +1,65 @@
|
||||
"""Authentication Backends for the People core app."""
|
||||
|
||||
import logging
|
||||
from email.headerregistry import Address
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
from lasuite.tools.email import get_domain_from_email
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from core.models import (
|
||||
AccountService,
|
||||
Contact,
|
||||
Organization,
|
||||
OrganizationAccess,
|
||||
OrganizationRoleChoices,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def get_domain_from_email(email: Optional[str]) -> Optional[str]:
|
||||
"""Extract domain from email."""
|
||||
try:
|
||||
return Address(addr_spec=email).domain
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User model, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
dict: A dictionary of extra claims.
|
||||
|
||||
"""
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Create a new user if no match is found.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
email = user_info.get("email")
|
||||
|
||||
claims = {
|
||||
"sub": sub,
|
||||
"email": email,
|
||||
"name": self.compute_full_name(user_info),
|
||||
}
|
||||
extra_claims = super().get_extra_claims(user_info)
|
||||
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
|
||||
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
extra_claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = (
|
||||
user_info.get(settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD)
|
||||
)
|
||||
return extra_claims
|
||||
|
||||
# if sub is absent, try matching on email
|
||||
user = self.get_existing_user(sub, email)
|
||||
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||
"""
|
||||
Post-processing after user creation or retrieval.
|
||||
|
||||
if user:
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
self.update_user_if_needed(user, claims)
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(claims)
|
||||
Args:
|
||||
user (User): The user instance.
|
||||
claims (dict): The claims dictionary.
|
||||
is_new_user (bool): Indicates if the user was newly created.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
|
||||
"""
|
||||
# Data cleaning, to be removed when user organization is null=False
|
||||
# or all users have an organization.
|
||||
# See https://github.com/suitenumerique/people/issues/504
|
||||
@@ -124,7 +67,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
organization_registration_id = claims.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
)
|
||||
domain = get_domain_from_email(email)
|
||||
domain = get_domain_from_email(claims["email"])
|
||||
try:
|
||||
organization, organization_created = (
|
||||
Organization.objects.get_or_create_from_user_claims(
|
||||
@@ -151,8 +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")
|
||||
@@ -164,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:
|
||||
@@ -185,21 +127,7 @@ 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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
user = super().create_user(claims | {"organization": organization})
|
||||
|
||||
# Initiate the user's profile
|
||||
Contact.objects.create(
|
||||
@@ -215,33 +143,44 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
return user
|
||||
|
||||
def compute_full_name(self, user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
name_fields = settings.USER_OIDC_FIELDS_TO_NAME
|
||||
full_name = " ".join(
|
||||
user_info[field] for field in name_fields if user_info.get(field)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
class AccountServiceAuthentication(BaseAuthentication):
|
||||
"""Authentication backend for account services using Authorization header.
|
||||
The Authorization header is used to authenticate the request.
|
||||
The api key is stored in the AccountService model.
|
||||
|
||||
Header format:
|
||||
Authorization: ApiKey <api_key>
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Authenticate the request. Find the account service and check the api key.
|
||||
|
||||
Should return either:
|
||||
- a tuple of (account_service, api_key) if allowed,
|
||||
- None to pass on other authentication backends
|
||||
- raise an AuthenticationFailed exception to stop propagation.
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header:
|
||||
return None
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
auth_mode, api_key = auth_header.split(" ")
|
||||
except (IndexError, ValueError) as err:
|
||||
raise AuthenticationFailed(_("Invalid authorization header.")) from err
|
||||
if auth_mode.lower() != "apikey" or not api_key:
|
||||
raise AuthenticationFailed(_("Invalid authorization header."))
|
||||
try:
|
||||
account_service = AccountService.objects.get(api_key=api_key)
|
||||
except AccountService.DoesNotExist as err:
|
||||
logger.warning("Invalid api_key: %s", api_key)
|
||||
raise AuthenticationFailed(_("Invalid api key.")) from err
|
||||
return (account_service, account_service.api_key)
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
updated_claims = {}
|
||||
for key in ["email", "name"]:
|
||||
claim_value = claims.get(key)
|
||||
if claim_value and claim_value != getattr(user, key):
|
||||
updated_claims[key] = claim_value
|
||||
|
||||
if updated_claims:
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
def authenticate_header(self, request):
|
||||
"""
|
||||
Return a string to be used as the value of the `WWW-Authenticate`
|
||||
header in a `401 Unauthenticated` response, or `None` if the
|
||||
authentication scheme should return `403 Permission Denied` responses.
|
||||
"""
|
||||
return "apikey"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Authentication URLs for the People core app."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
urlpatterns = [
|
||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
||||
path(
|
||||
"logout-callback/",
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozilla_oidc_urls,
|
||||
]
|
||||
@@ -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)
|
||||
@@ -24,3 +24,10 @@ class WebhookStatusChoices(models.TextChoices):
|
||||
FAILURE = "failure", _("Failure")
|
||||
PENDING = "pending", _("Pending")
|
||||
SUCCESS = "success", _("Success")
|
||||
|
||||
|
||||
class WebhookProtocolChoices(models.TextChoices):
|
||||
"""Defines the possible protocols of webhook."""
|
||||
|
||||
SCIM = "scim"
|
||||
MATRIX = "matrix"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Custom exceptions for mailbox manager app
|
||||
"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
|
||||
class EmailAlreadyKnownException(APIException):
|
||||
"""Exception raised when trying to create a user with an already existing email address."""
|
||||
|
||||
status_code = status.HTTP_201_CREATED
|
||||
default_detail = _(
|
||||
"Email already known. Invitation not sent but access created instead."
|
||||
)
|
||||
default_code = "email already known"
|
||||
@@ -1,4 +1,3 @@
|
||||
# ruff: noqa: S311
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
@@ -277,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,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"))
|
||||
@@ -11,7 +11,7 @@ def update_team_paths(apps, schema_editor):
|
||||
steplen = 5
|
||||
|
||||
# Initialize NumConv with the specified custom alphabet
|
||||
converter = NumConv(len(alphabet), alphabet)
|
||||
converter = NumConv(alphabet)
|
||||
|
||||
nodes = Team.objects.all().order_by("created_at")
|
||||
for i, node in enumerate(nodes, 1):
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
+91
-31
@@ -5,6 +5,7 @@ Declare and configure the models for the People core application
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import smtplib
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
@@ -20,7 +21,7 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.db import models
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext, override
|
||||
@@ -30,12 +31,10 @@ import jsonschema
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
|
||||
from core.enums import WebhookStatusChoices
|
||||
from core.plugins.loader import (
|
||||
organization_plugins_run_after_create,
|
||||
organization_plugins_run_after_grant_access,
|
||||
)
|
||||
from core.utils.webhooks import scim_synchronizer
|
||||
from core.enums import WebhookProtocolChoices, WebhookStatusChoices
|
||||
from core.exceptions import EmailAlreadyKnownException
|
||||
from core.plugins.registry import registry as plugin_hooks_registry
|
||||
from core.utils.webhooks import webhooks_synchronizer
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -325,7 +324,7 @@ class OrganizationManager(models.Manager):
|
||||
This method is overridden to call the Organization plugins.
|
||||
"""
|
||||
instance = super().create(**kwargs)
|
||||
organization_plugins_run_after_create(instance)
|
||||
plugin_hooks_registry.execute_hook("organization_created", instance)
|
||||
return instance
|
||||
|
||||
|
||||
@@ -341,7 +340,7 @@ class OrganizationAccessManager(models.Manager):
|
||||
This method is overridden to call the Organization plugins.
|
||||
"""
|
||||
instance = super().create(**kwargs)
|
||||
organization_plugins_run_after_grant_access(instance)
|
||||
plugin_hooks_registry.execute_hook("organization_access_granted", instance)
|
||||
return instance
|
||||
|
||||
|
||||
@@ -396,6 +395,7 @@ class Organization(BaseModel):
|
||||
related_name="organizations",
|
||||
blank=True,
|
||||
)
|
||||
is_active = models.BooleanField(verbose_name=_("active"), default=True)
|
||||
|
||||
objects = OrganizationManager()
|
||||
|
||||
@@ -743,6 +743,11 @@ class Team(MP_Node, BaseModel):
|
||||
can see it.
|
||||
When a team is created from a Service Provider this one is automatically set in the
|
||||
Team `service_providers`.
|
||||
|
||||
The team `external_id` is used to synchronize the team with external systems, this
|
||||
is the equivalent of the User `sub` field but for teams (note: `sub` is highly related
|
||||
to the OIDC standard, while `external_id` is not). The `external_id` is NOT the same as
|
||||
the `externalId` field in the SCIM standard when importing teams from SCIM.
|
||||
"""
|
||||
|
||||
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
|
||||
@@ -753,6 +758,14 @@ class Team(MP_Node, BaseModel):
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
|
||||
external_id = models.UUIDField(
|
||||
verbose_name=_("external_id"),
|
||||
help_text=_("Team external UUID for synchronization with external systems"),
|
||||
unique=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
through="TeamAccess",
|
||||
@@ -801,7 +814,7 @@ class Team(MP_Node, BaseModel):
|
||||
except AttributeError:
|
||||
try:
|
||||
role = self.accesses.filter(user=user).values("role")[0]["role"]
|
||||
except (TeamAccess.DoesNotExist, IndexError):
|
||||
except TeamAccess.DoesNotExist, IndexError:
|
||||
role = None
|
||||
|
||||
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
|
||||
@@ -852,16 +865,12 @@ class TeamAccess(BaseModel):
|
||||
Override save function to fire webhooks on any addition or update
|
||||
to a team access.
|
||||
"""
|
||||
|
||||
if self._state.adding:
|
||||
if self._state.adding and self.team.webhooks.exists():
|
||||
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
|
||||
with transaction.atomic():
|
||||
instance = super().save(*args, **kwargs)
|
||||
scim_synchronizer.add_user_to_group(self.team, self.user)
|
||||
else:
|
||||
instance = super().save(*args, **kwargs)
|
||||
# try to synchronize all webhooks
|
||||
webhooks_synchronizer.add_user_to_group(self.team, self.user)
|
||||
|
||||
return instance
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""
|
||||
@@ -869,11 +878,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):
|
||||
"""
|
||||
@@ -891,7 +901,7 @@ class TeamAccess(BaseModel):
|
||||
role = self._meta.model.objects.filter(
|
||||
team=self.team_id, user=user
|
||||
).values("role")[0]["role"]
|
||||
except (self._meta.model.DoesNotExist, IndexError):
|
||||
except self._meta.model.DoesNotExist, IndexError:
|
||||
role = None
|
||||
|
||||
is_team_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
|
||||
@@ -931,6 +941,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,
|
||||
@@ -982,19 +997,22 @@ class BaseInvitation(BaseModel):
|
||||
super().clean()
|
||||
|
||||
# Check if a user already exists for the provided email
|
||||
if User.objects.filter(email=self.email).exists():
|
||||
raise exceptions.ValidationError(
|
||||
{"email": _("This email is already associated to a registered user.")}
|
||||
)
|
||||
if User.objects.filter(email__iexact=self.email).exists():
|
||||
raise EmailAlreadyKnownException
|
||||
|
||||
def refresh(self):
|
||||
"""A simple way to refresh invitation and move expiration date."""
|
||||
self.clean()
|
||||
self.updated_at = timezone.now()
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""Calculate if invitation is still valid or has expired."""
|
||||
if not self.created_at:
|
||||
if not self.updated_at:
|
||||
return None
|
||||
|
||||
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||
return timezone.now() > (self.created_at + validity_duration)
|
||||
return timezone.now() > (self.updated_at + validity_duration)
|
||||
|
||||
def _get_mail_subject(self):
|
||||
"""Get the subject of the invitation."""
|
||||
@@ -1082,7 +1100,7 @@ class Invitation(BaseInvitation):
|
||||
role = self.team.accesses.filter(user=user).values("role")[0][
|
||||
"role"
|
||||
]
|
||||
except (self._meta.model.DoesNotExist, IndexError):
|
||||
except self._meta.model.DoesNotExist, IndexError:
|
||||
role = None
|
||||
|
||||
can_delete = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
|
||||
@@ -1093,3 +1111,45 @@ class Invitation(BaseInvitation):
|
||||
"patch": False,
|
||||
"put": False,
|
||||
}
|
||||
|
||||
|
||||
def validate_account_service_scope(scope):
|
||||
"""Validate the scope of the account service."""
|
||||
if scope not in settings.ACCOUNT_SERVICE_SCOPES:
|
||||
raise ValidationError(f"Invalid scope: {scope}")
|
||||
|
||||
|
||||
class AccountService(BaseModel):
|
||||
"""Account service model."""
|
||||
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
api_key = models.CharField(
|
||||
_("api key"),
|
||||
max_length=255,
|
||||
)
|
||||
scopes = ArrayField(
|
||||
models.CharField(max_length=255, validators=[validate_account_service_scope]),
|
||||
verbose_name=_("allowed scopes"),
|
||||
help_text=_("Allowed scopes for this service"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_account_service"
|
||||
verbose_name = _("Account service")
|
||||
verbose_name_plural = _("Account services")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save method to generate a new api_key if it is not set.
|
||||
"""
|
||||
if not self.api_key:
|
||||
self.api_key = secrets.token_urlsafe(32)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
"""Indicate if the account service is authenticated."""
|
||||
return True
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
"""Base plugin class for organization plugins."""
|
||||
"""Base Django Application Configuration for plugins."""
|
||||
|
||||
|
||||
class BaseOrganizationPlugin:
|
||||
class BasePluginAppConfigMixIn:
|
||||
"""
|
||||
Base class for organization plugins.
|
||||
Configuration for the La Suite plugin application.
|
||||
|
||||
Plugins must implement all methods of this class even if it is only to "pass".
|
||||
We cannot use the `AppConfig` class directly because it is not compatible with
|
||||
the Django way to discover default AppConfig (see `AppConfig.create`).
|
||||
We use a mixin then, to be able to list plugins using `plugins.la_suite` instead
|
||||
of `plugins.la_suite.apps.LaSuitePluginConfig`.
|
||||
|
||||
Another way would be to force `default` attribute on plugin AppConfig.
|
||||
"""
|
||||
|
||||
def run_after_create(self, organization) -> None:
|
||||
"""Method called after creating an organization."""
|
||||
raise NotImplementedError("Plugins must implement the run_after_create method")
|
||||
def ready(self):
|
||||
"""
|
||||
Initialize the hooks registry when the application is ready.
|
||||
This is called by Django when the application is loaded.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from .registry import registry # noqa: PLC0415
|
||||
|
||||
def run_after_grant_access(self, organization_access) -> None:
|
||||
"""Method called after creating an organization."""
|
||||
raise NotImplementedError(
|
||||
"Plugins must implement the run_after_grant_access method"
|
||||
)
|
||||
registry.register_app(self.name) # pylint: disable=no-member
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Helper functions to load and run organization plugins."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from core.plugins.base import BaseOrganizationPlugin
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_organization_plugins() -> List[BaseOrganizationPlugin]:
|
||||
"""
|
||||
Return a list of all organization plugins.
|
||||
While the plugins initialization does not depend on the request, we can cache the result.
|
||||
"""
|
||||
return [
|
||||
import_string(plugin_path)() for plugin_path in settings.ORGANIZATION_PLUGINS
|
||||
]
|
||||
|
||||
|
||||
def organization_plugins_run_after_create(organization):
|
||||
"""
|
||||
Run the after create method for all organization plugins.
|
||||
|
||||
Each plugin will be called in the order they are listed in the settings.
|
||||
Each plugin is responsible to save changes if needed, this is not optimized
|
||||
but this could be easily improved later if needed.
|
||||
"""
|
||||
for plugin_instance in get_organization_plugins():
|
||||
plugin_instance.run_after_create(organization)
|
||||
|
||||
|
||||
def organization_plugins_run_after_grant_access(organization_access):
|
||||
"""
|
||||
Run the after grant access method for all organization plugins.
|
||||
|
||||
Each plugin will be called in the order they are listed in the settings.
|
||||
Each plugin is responsible to save changes if needed, this is not optimized
|
||||
but this could be easily improved later if needed.
|
||||
"""
|
||||
for plugin_instance in get_organization_plugins():
|
||||
plugin_instance.run_after_grant_access(organization_access)
|
||||
@@ -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.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 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"
|
||||
@@ -52,8 +52,8 @@ def test_api_contacts_update_authenticated_owned(django_assert_num_queries):
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
with django_assert_num_queries(8):
|
||||
# user, 2x contact, user, 3x check, update contact
|
||||
with django_assert_num_queries(14):
|
||||
# user, 2x contact, user, 3x check, update contact, 3x savepoint/release
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
"""Test fixtures."""
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
"""Define here some fake responses from Matrix API, useful to mock responses in tests."""
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
# SEARCH
|
||||
def mock_search_empty():
|
||||
"""Mock response when no Matrix user has been found through search."""
|
||||
return {
|
||||
"message": {"limited": "false", "results": []},
|
||||
"status_code": status.HTTP_200_OK,
|
||||
}
|
||||
|
||||
|
||||
def mock_search_successful(user):
|
||||
"""Mock response when exactly one user has been found through search."""
|
||||
return {
|
||||
"message": {
|
||||
"limited": "false",
|
||||
"results": [
|
||||
{
|
||||
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
|
||||
"display_name": f"@{user.name} [Fake]",
|
||||
"avatar_url": "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
"status_code": status.HTTP_200_OK,
|
||||
}
|
||||
|
||||
|
||||
def mock_search_successful_multiple(user):
|
||||
"""Mock response when more than one user has been found through search."""
|
||||
return {
|
||||
"message": {
|
||||
"limited": "false",
|
||||
"results": [
|
||||
{
|
||||
"user_id": f"@{user.email.replace('@', '-')}:user_server1.com",
|
||||
"display_name": f"@{user.name} [Fake]",
|
||||
"avatar_url": "null",
|
||||
},
|
||||
{
|
||||
"user_id": f"@{user.email.replace('@', '-')}:user_server2.com",
|
||||
"display_name": f"@{user.name} [Other Fake]",
|
||||
"avatar_url": "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
"status_code": status.HTTP_200_OK,
|
||||
}
|
||||
|
||||
|
||||
# JOIN ROOMS
|
||||
def mock_join_room_successful(room_id):
|
||||
"""Mock response when succesfully joining room. Same response if already in room."""
|
||||
return {"message": {"room_id": str(room_id)}, "status_code": status.HTTP_200_OK}
|
||||
|
||||
|
||||
def mock_join_room_no_known_servers():
|
||||
"""Mock response when room to join cannot be found."""
|
||||
return {
|
||||
"message": {"errcode": "M_UNKNOWN", "error": "No known servers"},
|
||||
"status_code": status.HTTP_404_NOT_FOUND,
|
||||
}
|
||||
|
||||
|
||||
def mock_join_room_forbidden():
|
||||
"""Mock response when room cannot be joined."""
|
||||
return {
|
||||
"message": {
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": "You do not belong to any of the required rooms/spaces to join this room.",
|
||||
},
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
}
|
||||
|
||||
|
||||
# INVITE USER
|
||||
def mock_invite_successful():
|
||||
"""Mock response when invite request was succesful. Does not check the user exists."""
|
||||
return {"message": {}, "status_code": status.HTTP_200_OK}
|
||||
|
||||
|
||||
def mock_invite_user_already_in_room(user):
|
||||
"""Mock response when invitation forbidden for People user."""
|
||||
return {
|
||||
"message": {
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": f"{user.email.replace('@', '-')}:home_server.fr is already in the room.",
|
||||
},
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
}
|
||||
|
||||
|
||||
# KICK USER
|
||||
def mock_kick_successful():
|
||||
"""Mock response when succesfully joining room."""
|
||||
return {"message": {}, "status_code": status.HTTP_200_OK}
|
||||
|
||||
|
||||
def mock_kick_user_forbidden(user):
|
||||
"""Mock response when kick request is forbidden (i.e. wrong permission or user is room admin."""
|
||||
return {
|
||||
"message": {
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": f"You cannot kick user @{user.email.replace('@', '-')}.",
|
||||
},
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
}
|
||||
|
||||
|
||||
def mock_kick_user_not_in_room():
|
||||
"""
|
||||
Mock response when trying to kick a user who isn't in the room. Don't check the user exists.
|
||||
"""
|
||||
return {
|
||||
"message": {
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": "The target user is not in the room",
|
||||
},
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
}
|
||||
@@ -9,18 +9,37 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.rfc7518.rsa_key import RSAKey
|
||||
from joserfc.jwk import RSAKey
|
||||
from jwt.utils import to_base64url_uint
|
||||
from lasuite.oidc_resource_server.authentication import (
|
||||
ResourceServerAuthentication,
|
||||
get_resource_server_backend,
|
||||
)
|
||||
from rest_framework.request import Request as DRFRequest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.models import ServiceProvider
|
||||
from core.resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(name="jwt_resource_server_backend")
|
||||
def jwt_resource_server_backend_fixture(settings):
|
||||
"""Fixture to switch the backend to the JWTResourceServerBackend."""
|
||||
_original_backend = str(settings.OIDC_RS_BACKEND_CLASS)
|
||||
|
||||
settings.OIDC_RS_BACKEND_CLASS = (
|
||||
"lasuite.oidc_resource_server.backend.JWTResourceServerBackend"
|
||||
)
|
||||
get_resource_server_backend.cache_clear()
|
||||
|
||||
yield
|
||||
|
||||
settings.OIDC_RS_BACKEND_CLASS = _original_backend
|
||||
get_resource_server_backend.cache_clear()
|
||||
|
||||
|
||||
def build_authorization_bearer(token):
|
||||
"""
|
||||
Build an Authorization Bearer header value from a token.
|
||||
@@ -47,6 +66,88 @@ def test_resource_server_authentication_class(client, settings):
|
||||
`resource_server_token_audience` attribute which is used in
|
||||
the resource server views.
|
||||
|
||||
This test uses the `/resource-server/v1.0/teams/` URL as an example
|
||||
because we don't want to create a new URL just for this test.
|
||||
"""
|
||||
assert (
|
||||
settings.OIDC_RS_BACKEND_CLASS
|
||||
== "lasuite.oidc_resource_server.backend.ResourceServerBackend"
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid groups",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Try to authenticate while the user does not exist => 401
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/", # use an exising URL here
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
|
||||
# Create a user with the specific sub, the access is authorized
|
||||
UserFactory(sub="very-specific-sub")
|
||||
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/", # use an exising URL here
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
response_request = response.renderer_context.get("request")
|
||||
assert isinstance(response_request, DRFRequest)
|
||||
assert isinstance(
|
||||
response_request.successful_authenticator, ResourceServerAuthentication
|
||||
)
|
||||
|
||||
# Check that the user is authenticated
|
||||
assert response_request.user.is_authenticated
|
||||
|
||||
# Check the request contains the resource server token audience
|
||||
assert response_request.resource_server_token_audience == "some_service_provider"
|
||||
|
||||
# Check that no service provider is created here
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_jwt_resource_server_authentication_class( # pylint: disable=unused-argument
|
||||
client, jwt_resource_server_backend, settings
|
||||
):
|
||||
"""
|
||||
Defines the settings for the resource server
|
||||
for a full authentication with introspection process.
|
||||
|
||||
This is an integration test that checks the authentication process
|
||||
when using the ResourceServerAuthentication class.
|
||||
|
||||
This test asserts the DRF request object contains the
|
||||
`resource_server_token_audience` attribute which is used in
|
||||
the resource server views.
|
||||
|
||||
This test uses the `/resource-server/v1.0/teams/` URL as an example
|
||||
because we don't want to create a new URL just for this test.
|
||||
"""
|
||||
@@ -134,7 +235,8 @@ def test_resource_server_authentication_class(client, settings):
|
||||
"token_introspection": {
|
||||
"sub": "very-specific-sub",
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_service_provider",
|
||||
"aud": "some_client_id",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid groups",
|
||||
"active": True,
|
||||
},
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
"""
|
||||
Test for the Resource Server (RS) Backend.
|
||||
"""
|
||||
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from logging import Logger
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from joserfc.errors import InvalidClaimError, InvalidTokenError
|
||||
from joserfc.jwt import JWTClaimsRegistry
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from core.resource_server.backend import ResourceServerBackend
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_authorization_server")
|
||||
def fixture_mock_authorization_server():
|
||||
"""Mock an Authorization Server client."""
|
||||
mock_server = Mock()
|
||||
mock_server.url = "https://auth.server.com"
|
||||
return mock_server
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_token")
|
||||
def fixture_mock_token():
|
||||
"""Mock a token"""
|
||||
mock_token = Mock()
|
||||
mock_token.claims = {"sub": "user123", "iss": "https://auth.server.com"}
|
||||
return mock_token
|
||||
|
||||
|
||||
@pytest.fixture(name="resource_server_backend")
|
||||
def fixture_resource_server_backend(settings, mock_authorization_server):
|
||||
"""Generate a Resource Server backend."""
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "client_secret"
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
|
||||
settings.OIDC_RS_SIGNING_ALGO = "ES256"
|
||||
settings.OIDC_RS_SCOPES = ["groups"]
|
||||
|
||||
return ResourceServerBackend(mock_authorization_server)
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_CLIENT_ID="client_id")
|
||||
@override_settings(OIDC_RS_CLIENT_SECRET="client_secret")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ENCODING="A256GCM")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RSA-OAEP")
|
||||
@override_settings(OIDC_RS_SIGNING_ALGO="RS256")
|
||||
@override_settings(OIDC_RS_SCOPES=["scopes"])
|
||||
@patch.object(auth, "get_user_model", return_value="foo")
|
||||
def test_backend_initialization(mock_get_user_model, mock_authorization_server):
|
||||
"""Test the ResourceServerBackend initialization."""
|
||||
|
||||
backend = ResourceServerBackend(mock_authorization_server)
|
||||
|
||||
mock_get_user_model.assert_called_once()
|
||||
assert backend.UserModel == "foo"
|
||||
|
||||
assert backend._client_id == "client_id"
|
||||
assert backend._client_secret == "client_secret"
|
||||
assert backend._encryption_encoding == "A256GCM"
|
||||
assert backend._encryption_algorithm == "RSA-OAEP"
|
||||
assert backend._signing_algorithm == "RS256"
|
||||
assert backend._scopes == ["scopes"]
|
||||
|
||||
assert backend._authorization_server_client == mock_authorization_server
|
||||
assert isinstance(backend._claims_registry, JWTClaimsRegistry)
|
||||
|
||||
assert backend._claims_registry.options == {
|
||||
"iss": {"essential": True, "value": "https://auth.server.com"},
|
||||
"aud": {"essential": True, "value": "client_id"},
|
||||
"token_introspection": {"essential": True},
|
||||
}
|
||||
|
||||
|
||||
@patch.object(ResourceServerBackend, "get_user", return_value="user")
|
||||
def test_get_or_create_user(mock_get_user, resource_server_backend):
|
||||
"""Test 'get_or_create_user' method."""
|
||||
|
||||
access_token = "access_token"
|
||||
res = resource_server_backend.get_or_create_user(access_token, None, None)
|
||||
|
||||
mock_get_user.assert_called_once_with(access_token)
|
||||
assert res == "user"
|
||||
|
||||
|
||||
def test_verify_claims_success(resource_server_backend, mock_token):
|
||||
"""Test '_verify_claims' method with a successful response."""
|
||||
|
||||
with patch.object(
|
||||
resource_server_backend._claims_registry, "validate"
|
||||
) as mock_validate:
|
||||
resource_server_backend._verify_claims(mock_token)
|
||||
mock_validate.assert_called_once_with(mock_token.claims)
|
||||
|
||||
|
||||
def test_verify_claims_invalid_claim_error(resource_server_backend, mock_token):
|
||||
"""Test '_verify_claims' method with an invalid claim error."""
|
||||
|
||||
with patch.object(
|
||||
resource_server_backend._claims_registry, "validate"
|
||||
) as mock_validate:
|
||||
mock_validate.side_effect = InvalidClaimError("claim_name")
|
||||
|
||||
expected_message = "Failed to validate token's claims"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_claims(mock_token)
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_verify_claims_invalid_token_error(resource_server_backend, mock_token):
|
||||
"""Test '_verify_claims' method with an invalid token error."""
|
||||
|
||||
with patch.object(
|
||||
resource_server_backend._claims_registry, "validate"
|
||||
) as mock_validate:
|
||||
mock_validate.side_effect = InvalidTokenError
|
||||
|
||||
expected_message = "Failed to validate token's claims"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_claims(mock_token)
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_decode_success(resource_server_backend):
|
||||
"""Test '_decode' method with a successful response."""
|
||||
|
||||
encoded_token = Mock()
|
||||
encoded_token.plaintext = "valid_encoded_token"
|
||||
public_key_set = Mock()
|
||||
|
||||
expected_decoded_token = {"sub": "user123"}
|
||||
|
||||
with patch(
|
||||
"joserfc.jwt.decode", return_value=expected_decoded_token
|
||||
) as mock_decode:
|
||||
decoded_token = resource_server_backend._decode(encoded_token, public_key_set)
|
||||
|
||||
mock_decode.assert_called_once_with(
|
||||
"valid_encoded_token", public_key_set, algorithms=["ES256"]
|
||||
)
|
||||
|
||||
assert decoded_token == expected_decoded_token
|
||||
|
||||
|
||||
def test_decode_failure(resource_server_backend):
|
||||
"""Test '_decode' method with a ValueError"""
|
||||
encoded_token = Mock()
|
||||
encoded_token.plaintext = "invalid_encoded_token"
|
||||
public_key_set = Mock()
|
||||
|
||||
with patch("joserfc.jwt.decode", side_effect=ValueError):
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match="Token decoding failed"):
|
||||
resource_server_backend._decode(encoded_token, public_key_set)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", "Token decoding failed", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_decrypt_success(resource_server_backend):
|
||||
"""Test '_decrypt' method with a successful response."""
|
||||
encrypted_token = "valid_encrypted_token"
|
||||
private_key = "private_key"
|
||||
|
||||
expected_decrypted_token = {"sub": "user123"}
|
||||
|
||||
with patch(
|
||||
"joserfc.jwe.decrypt_compact", return_value=expected_decrypted_token
|
||||
) as mock_decrypt:
|
||||
decrypted_token = resource_server_backend._decrypt(encrypted_token, private_key)
|
||||
mock_decrypt.assert_called_once_with(
|
||||
encrypted_token, private_key, algorithms=["RSA-OAEP", "A256GCM"]
|
||||
)
|
||||
|
||||
assert decrypted_token == expected_decrypted_token
|
||||
|
||||
|
||||
def test_decrypt_failure(resource_server_backend):
|
||||
"""Test '_decrypt' method with an Exception."""
|
||||
encrypted_token = "invalid_encrypted_token"
|
||||
private_key = "private_key"
|
||||
|
||||
with patch(
|
||||
"joserfc.jwe.decrypt_compact", side_effect=Exception("Decryption error")
|
||||
):
|
||||
expected_message = "Token decryption failed"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._decrypt(encrypted_token, private_key)
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"core.resource_server.utils.import_private_key_from_settings",
|
||||
return_value="private_key",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def test_introspect_success(
|
||||
mock_import_private_key_from_settings, resource_server_backend
|
||||
):
|
||||
"""Test '_introspect' method with a successful response."""
|
||||
token = "valid_token"
|
||||
jwe = "valid_jwe"
|
||||
jws = "valid_jws"
|
||||
jwt = {"sub": "user123"}
|
||||
|
||||
resource_server_backend._authorization_server_client.get_introspection = Mock(
|
||||
return_value=jwe
|
||||
)
|
||||
resource_server_backend._decrypt = Mock(return_value=jws)
|
||||
resource_server_backend._authorization_server_client.import_public_keys = Mock(
|
||||
return_value="public_key_set"
|
||||
)
|
||||
resource_server_backend._decode = Mock(return_value=jwt)
|
||||
|
||||
result = resource_server_backend._introspect(token)
|
||||
|
||||
assert result == jwt
|
||||
resource_server_backend._authorization_server_client.get_introspection.assert_called_once_with(
|
||||
"client_id", "client_secret", token
|
||||
)
|
||||
resource_server_backend._decrypt.assert_called_once_with(
|
||||
jwe, private_key="private_key"
|
||||
)
|
||||
resource_server_backend._authorization_server_client.import_public_keys.assert_called_once()
|
||||
resource_server_backend._decode.assert_called_once_with(jws, "public_key_set")
|
||||
|
||||
|
||||
def test_introspect_introspection_failure(resource_server_backend):
|
||||
"""Test '_introspect' method when introspection to the AS fails."""
|
||||
token = "invalid_token"
|
||||
resource_server_backend._authorization_server_client.get_introspection.side_effect = HTTPError(
|
||||
"Introspection error"
|
||||
)
|
||||
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
expected_message = "Could not fetch introspection"
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._introspect(token)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"core.resource_server.utils.import_private_key_from_settings",
|
||||
return_value="private_key",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def test_introspect_public_key_import_failure(
|
||||
mock_import_private_key_from_settings, resource_server_backend
|
||||
):
|
||||
"""Test '_introspect' method when fetching AS's jwks fails."""
|
||||
token = "valid_token"
|
||||
jwe = "valid_jwe"
|
||||
jws = "valid_jws"
|
||||
|
||||
resource_server_backend._authorization_server_client.get_introspection = Mock(
|
||||
return_value=jwe
|
||||
)
|
||||
resource_server_backend._decrypt = Mock(return_value=jws)
|
||||
|
||||
resource_server_backend._authorization_server_client.import_public_keys.side_effect = HTTPError(
|
||||
"Public key error"
|
||||
)
|
||||
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
expected_message = "Could get authorization server JWKS"
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._introspect(token)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_verify_user_info_success(resource_server_backend):
|
||||
"""Test '_verify_user_info' with a successful response."""
|
||||
introspection_response = {"active": True, "scope": "groups", "aud": "123"}
|
||||
|
||||
result = resource_server_backend._verify_user_info(introspection_response)
|
||||
assert result == introspection_response
|
||||
|
||||
|
||||
def test_verify_user_info_inactive(resource_server_backend):
|
||||
"""Test '_verify_user_info' with an inactive introspection response."""
|
||||
|
||||
introspection_response = {"active": False, "scope": "groups"}
|
||||
|
||||
expected_message = "Introspection response is not active."
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_user_info(introspection_response)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(expected_message)
|
||||
|
||||
|
||||
def test_verify_user_info_wrong_scopes(resource_server_backend):
|
||||
"""Test '_verify_user_info' with wrong requested scopes."""
|
||||
|
||||
introspection_response = {"active": True, "scope": "wrong-scopes"}
|
||||
|
||||
expected_message = "Introspection response contains any required scopes."
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_user_info(introspection_response)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(expected_message)
|
||||
|
||||
|
||||
def test_get_user_success(resource_server_backend):
|
||||
"""Test '_get_user' with a successful response."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
mock_claims = {"token_introspection": {"sub": "user123", "aud": "123"}}
|
||||
mock_user = Mock()
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
|
||||
resource_server_backend._verify_user_info = Mock(
|
||||
return_value=mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get = Mock(return_value=mock_user)
|
||||
|
||||
user = resource_server_backend.get_user(access_token)
|
||||
|
||||
assert user == mock_user
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
|
||||
resource_server_backend._verify_user_info.assert_called_once_with(
|
||||
mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get.assert_called_once_with(sub="user123")
|
||||
|
||||
|
||||
def test_get_user_could_not_introspect(resource_server_backend):
|
||||
"""Test '_get_user' with introspection failing."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
|
||||
resource_server_backend._introspect = Mock(
|
||||
side_effect=SuspiciousOperation("Invalid jwt")
|
||||
)
|
||||
resource_server_backend._verify_claims = Mock()
|
||||
resource_server_backend._verify_user_info = Mock()
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="Invalid jwt"):
|
||||
resource_server_backend.get_user(access_token)
|
||||
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_not_called()
|
||||
resource_server_backend._verify_user_info.assert_not_called()
|
||||
|
||||
|
||||
def test_get_user_invalid_introspection_response(resource_server_backend):
|
||||
"""Test '_get_user' with an invalid introspection response."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(
|
||||
side_effect=SuspiciousOperation("Invalid claims")
|
||||
)
|
||||
resource_server_backend._verify_user_info = Mock()
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="Invalid claims"):
|
||||
resource_server_backend.get_user(access_token)
|
||||
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
|
||||
resource_server_backend._verify_user_info.assert_not_called()
|
||||
|
||||
|
||||
def test_get_user_user_not_found(resource_server_backend):
|
||||
"""Test '_get_user' if the user is not found."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
mock_claims = {"token_introspection": {"sub": "user123"}}
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
|
||||
resource_server_backend._verify_user_info = Mock(
|
||||
return_value=mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get = Mock(
|
||||
side_effect=resource_server_backend.UserModel.DoesNotExist
|
||||
)
|
||||
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
user = resource_server_backend.get_user(access_token)
|
||||
assert user is None
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
|
||||
resource_server_backend._verify_user_info.assert_called_once_with(
|
||||
mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get.assert_called_once_with(
|
||||
sub="user123"
|
||||
)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"Login failed: No user with %s found", "user123"
|
||||
)
|
||||
|
||||
|
||||
def test_get_user_no_user_identification(resource_server_backend):
|
||||
"""Test '_get_user' if the response miss a user identification."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
mock_claims = {"token_introspection": {}}
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
|
||||
resource_server_backend._verify_user_info = Mock(
|
||||
return_value=mock_claims["token_introspection"]
|
||||
)
|
||||
|
||||
expected_message = "User info contained no recognizable user identification"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend.get_user(access_token)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(expected_message)
|
||||
@@ -1,187 +0,0 @@
|
||||
"""
|
||||
Test for the Resource Server (RS) clients classes.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from joserfc.jwk import KeySet, RSAKey
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from core.resource_server.clients import AuthorizationServerClient
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def fixture_client():
|
||||
"""Generate an Authorization Server client."""
|
||||
return AuthorizationServerClient(
|
||||
url="https://auth.example.com/api/v2",
|
||||
url_jwks="https://auth.example.com/api/v2/jwks",
|
||||
url_introspection="https://auth.example.com/api/v2/introspect",
|
||||
verify_ssl=True,
|
||||
timeout=5,
|
||||
proxy=None,
|
||||
)
|
||||
|
||||
|
||||
def test_authorization_server_client_initialization():
|
||||
"""Test the AuthorizationServerClient initialization."""
|
||||
|
||||
new_client = AuthorizationServerClient(
|
||||
url="https://auth.example.com/api/v2",
|
||||
url_jwks="https://auth.example.com/api/v2/jwks",
|
||||
url_introspection="https://auth.example.com/api/v2/checktoken/foo",
|
||||
verify_ssl=True,
|
||||
timeout=5,
|
||||
proxy=None,
|
||||
)
|
||||
|
||||
assert new_client.url == "https://auth.example.com/api/v2"
|
||||
assert (
|
||||
new_client._url_introspection
|
||||
== "https://auth.example.com/api/v2/checktoken/foo"
|
||||
)
|
||||
assert new_client._url_jwks == "https://auth.example.com/api/v2/jwks"
|
||||
assert new_client._verify_ssl is True
|
||||
assert new_client._timeout == 5
|
||||
assert new_client._proxy is None
|
||||
|
||||
|
||||
def test_introspection_headers(client):
|
||||
"""Test the introspection headers to ensure they match the expected values."""
|
||||
assert client._introspection_headers == {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/token-introspection+jwt",
|
||||
}
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_get_introspection_success(mock_post, client):
|
||||
"""Test 'get_introspection' method with a successful response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.text = "introspection response"
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = client.get_introspection("client_id", "client_secret", "token")
|
||||
assert result == "introspection response"
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
"https://auth.example.com/api/v2/introspect",
|
||||
data={
|
||||
"client_id": "client_id",
|
||||
"client_secret": "client_secret",
|
||||
"token": "token",
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/token-introspection+jwt",
|
||||
},
|
||||
verify=True,
|
||||
timeout=5,
|
||||
proxies=None,
|
||||
)
|
||||
|
||||
|
||||
@patch("requests.post", side_effect=HTTPError())
|
||||
# pylint: disable=(unused-argument
|
||||
def test_get_introspection_error(mock_post, client):
|
||||
"""Test 'get_introspection' method with an HTTPError."""
|
||||
with pytest.raises(HTTPError):
|
||||
client.get_introspection("client_id", "client_secret", "token")
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_get_jwks_success(mock_get, client):
|
||||
"""Test 'get_jwks' method with a successful response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"jwks": "foo"}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
result = client.get_jwks()
|
||||
assert result == {"jwks": "foo"}
|
||||
|
||||
mock_get.assert_called_once_with(
|
||||
"https://auth.example.com/api/v2/jwks",
|
||||
verify=client._verify_ssl,
|
||||
timeout=client._timeout,
|
||||
proxies=client._proxy,
|
||||
)
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_get_jwks_error(mock_get, client):
|
||||
"""Test 'get_jwks' method with an HTTPError."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = HTTPError(
|
||||
response=MagicMock(status=500)
|
||||
)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
client.get_jwks()
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_valid(mock_get, client):
|
||||
"""Test 'import_public_keys' method with a successful response."""
|
||||
|
||||
mocked_key = RSAKey.generate_key(2048)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"keys": [mocked_key.as_dict()]}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.import_public_keys()
|
||||
|
||||
assert isinstance(response, KeySet)
|
||||
assert response.as_dict() == KeySet([mocked_key]).as_dict()
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_http_error(mock_get, client):
|
||||
"""Test 'import_public_keys' method with an HTTPError."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = HTTPError(
|
||||
response=MagicMock(status=500)
|
||||
)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
client.import_public_keys()
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_empty_jwks(mock_get, client):
|
||||
"""Test 'import_public_keys' method with empty keys response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"keys": []}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.import_public_keys()
|
||||
|
||||
assert isinstance(response, KeySet)
|
||||
assert response.as_dict() == {"keys": []}
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_invalid_jwks(mock_get, client):
|
||||
"""Test 'import_public_keys' method with invalid keys response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"keys": [{"foo": "foo"}]}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
client.import_public_keys()
|
||||
@@ -1,88 +0,0 @@
|
||||
"""
|
||||
Test for the Resource Server (RS) utils functions.
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from joserfc.jwk import ECKey, RSAKey
|
||||
|
||||
from core.resource_server.utils import import_private_key_from_settings
|
||||
|
||||
PRIVATE_KEY_STR_MOCKED = """-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
|
||||
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
|
||||
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
|
||||
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
|
||||
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
|
||||
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
|
||||
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
|
||||
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
|
||||
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
|
||||
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
|
||||
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
|
||||
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
|
||||
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
|
||||
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
|
||||
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
|
||||
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
|
||||
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
|
||||
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
|
||||
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
|
||||
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
|
||||
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
|
||||
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
|
||||
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
|
||||
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
|
||||
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
|
||||
MbyqKyC6DAzv4jEEhHaN7oY=
|
||||
-----END PRIVATE KEY-----
|
||||
"""
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@pytest.mark.parametrize("mocked_private_key", [None, ""])
|
||||
def test_import_private_key_from_settings_missing_or_empty_key(
|
||||
settings, mocked_private_key
|
||||
):
|
||||
"""Should raise an exception if the settings 'OIDC_RS_PRIVATE_KEY_STR' is missing or empty."""
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured,
|
||||
match="OIDC_RS_PRIVATE_KEY_STR setting is missing or empty.",
|
||||
):
|
||||
import_private_key_from_settings()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_private_key", ["123", "foo", "invalid_key"])
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
|
||||
def test_import_private_key_from_settings_incorrect_key(settings, mocked_private_key):
|
||||
"""Should raise an exception if the setting 'OIDC_RS_PRIVATE_KEY_STR' has an incorrect value."""
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured, match="OIDC_RS_PRIVATE_KEY_STR setting is wrong."
|
||||
):
|
||||
import_private_key_from_settings()
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
|
||||
def test_import_private_key_from_settings_success_rsa_key():
|
||||
"""Should import private key string as an RSA key."""
|
||||
private_key = import_private_key_from_settings()
|
||||
assert isinstance(private_key, RSAKey)
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="EC")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="ES256")
|
||||
def test_import_private_key_from_settings_success_ec_key():
|
||||
"""Should import private key string as an EC key."""
|
||||
private_key = import_private_key_from_settings()
|
||||
assert isinstance(private_key, ECKey)
|
||||
@@ -1,70 +0,0 @@
|
||||
"""
|
||||
Tests for the Resource Server (RS) Views.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from joserfc.jwk import RSAKey
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
|
||||
def test_view_jwks_valid_public_key(mock_import_private_key_from_settings):
|
||||
"""JWKs endpoint should return a set of valid Json Web Key"""
|
||||
|
||||
mocked_key = RSAKey.generate_key(2048)
|
||||
mock_import_private_key_from_settings.return_value = mocked_key
|
||||
|
||||
url = reverse("resource_server_jwks")
|
||||
response = APIClient().get(url)
|
||||
|
||||
mock_import_private_key_from_settings.assert_called_once()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/json"
|
||||
|
||||
jwks = response.json()
|
||||
assert jwks == {"keys": [mocked_key.as_dict(private=False)]}
|
||||
|
||||
# Security checks to make sure no details from the private key are exposed
|
||||
private_details = ["d", "p", "q", "dp", "dq", "qi", "oth", "r", "t"]
|
||||
assert all(
|
||||
private_detail not in jwks["keys"][0].keys()
|
||||
for private_detail in private_details
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
|
||||
def test_view_jwks_invalid_private_key(mock_import_private_key_from_settings):
|
||||
"""JWKS endpoint should return a proper exception when loading keys fails."""
|
||||
|
||||
mock_import_private_key_from_settings.return_value = "wrong_key"
|
||||
|
||||
url = reverse("resource_server_jwks")
|
||||
response = APIClient().get(url)
|
||||
|
||||
mock_import_private_key_from_settings.assert_called_once()
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"error": "Could not load key"}
|
||||
|
||||
|
||||
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
|
||||
def test_view_jwks_missing_private_key(mock_import_private_key_from_settings):
|
||||
"""JWKS endpoint should return a proper exception when private key is missing."""
|
||||
|
||||
mock_import_private_key_from_settings.side_effect = ImproperlyConfigured("foo.")
|
||||
|
||||
url = reverse("resource_server_jwks")
|
||||
response = APIClient().get(url)
|
||||
|
||||
mock_import_private_key_from_settings.assert_called_once()
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"error": "foo."}
|
||||
@@ -9,8 +9,7 @@ from django.contrib.auth import get_user_model
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
|
||||
from core.resource_server.authentication import ResourceServerAuthentication
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
User = get_user_model()
|
||||
fake = Faker()
|
||||
@@ -48,7 +47,7 @@ def _force_login_via_resource_server(
|
||||
):
|
||||
client_fixture.force_login(
|
||||
user,
|
||||
backend="core.resource_server.authentication.ResourceServerAuthentication",
|
||||
backend="lasuite.oidc_resource_server.authentication.ResourceServerAuthentication",
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the resource server SCIM API endpoints."""
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Tests for the SCIM Me API endpoint in People's core app
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
|
||||
from core import factories
|
||||
from core.models import RoleChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_me_anonymous(client):
|
||||
"""Anonymous users should not be allowed to access the Me endpoint."""
|
||||
response = client.get("/resource-server/v1.0/scim/Me/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.headers["Content-Type"] == "application/json+scim"
|
||||
|
||||
# Check the full response with the expected structure
|
||||
assert response.json() == {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"status": "401",
|
||||
"detail": "",
|
||||
}
|
||||
|
||||
|
||||
def test_api_me_authenticated(
|
||||
client, force_login_via_resource_server, django_assert_num_queries
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to access their own information
|
||||
in SCIM format.
|
||||
"""
|
||||
user = factories.UserFactory(name="Test User", email="test@example.com")
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
# Authenticate using the resource server, ie via the Authorization header
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/scim/Me/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.headers["Content-Type"] == "application/json+scim"
|
||||
|
||||
# Check the full response with the expected structure
|
||||
assert response.json() == {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": str(user.pk),
|
||||
"active": True,
|
||||
"userName": user.sub,
|
||||
"displayName": user.name,
|
||||
"emails": [
|
||||
{
|
||||
"value": user.email,
|
||||
"primary": True,
|
||||
"type": "work",
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"meta": {
|
||||
"resourceType": "User",
|
||||
"created": user.created_at.isoformat(),
|
||||
"lastModified": user.updated_at.isoformat(),
|
||||
"location": "http://testserver/resource-server/v1.0/scim/Me/",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_api_me_authenticated_with_team_access(
|
||||
client, force_login_via_resource_server, django_assert_num_queries
|
||||
):
|
||||
"""
|
||||
Authenticated users with TeamAccess should see their team information
|
||||
in SCIM format, but only for teams visible to the service provider.
|
||||
"""
|
||||
user = factories.UserFactory(name="Test User", email="test@example.com")
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
# Create teams with different visibility settings
|
||||
team_visible = factories.TeamFactory(name="Visible Team")
|
||||
team_visible.service_providers.add(service_provider)
|
||||
|
||||
team_all_services = factories.TeamFactory(
|
||||
name="All Services Team", is_visible_all_services=True
|
||||
)
|
||||
|
||||
team_not_visible = factories.TeamFactory(name="Not Visible Team")
|
||||
# This team is not associated with the service provider
|
||||
|
||||
# Add user to all teams
|
||||
factories.TeamAccessFactory(user=user, team=team_visible, role=RoleChoices.MEMBER)
|
||||
factories.TeamAccessFactory(
|
||||
user=user, team=team_all_services, role=RoleChoices.ADMIN
|
||||
)
|
||||
factories.TeamAccessFactory(
|
||||
user=user, team=team_not_visible, role=RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Authenticate using the resource server, ie via the Authorization header
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
with django_assert_num_queries(
|
||||
2
|
||||
): # User + TeamAccess (with select_related teams)
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/scim/Me/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.headers["Content-Type"] == "application/json+scim"
|
||||
# Check the full response with the expected structure
|
||||
assert response.json() == {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": str(user.pk),
|
||||
"active": True,
|
||||
"userName": user.sub,
|
||||
"displayName": user.name,
|
||||
"emails": [
|
||||
{
|
||||
"value": user.email,
|
||||
"primary": True,
|
||||
"type": "work",
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"value": str(team_visible.external_id),
|
||||
"display": team_visible.name,
|
||||
"type": "direct",
|
||||
},
|
||||
{
|
||||
"value": str(team_all_services.external_id),
|
||||
"display": team_all_services.name,
|
||||
"type": "direct",
|
||||
},
|
||||
],
|
||||
"meta": {
|
||||
"resourceType": "User",
|
||||
"created": user.created_at.isoformat(),
|
||||
"lastModified": user.updated_at.isoformat(),
|
||||
"location": "http://testserver/resource-server/v1.0/scim/Me/",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"http_method",
|
||||
["post", "put", "patch", "delete"],
|
||||
ids=["POST", "PUT", "PATCH", "DELETE"],
|
||||
)
|
||||
def test_api_me_method_not_allowed(
|
||||
client, force_login_via_resource_server, http_method
|
||||
):
|
||||
"""Test that methods other than GET are not allowed for the Me endpoint."""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
# Authenticate using the resource server, ie via the Authorization header
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
client_method = getattr(client, http_method)
|
||||
response = client_method(
|
||||
"/resource-server/v1.0/scim/Me/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.headers["Content-Type"] == "application/json+scim"
|
||||
|
||||
# Check the full response with the expected structure
|
||||
assert response.json() == {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"status": str(HTTP_405_METHOD_NOT_ALLOWED),
|
||||
"detail": "",
|
||||
}
|
||||
@@ -3,14 +3,17 @@ Test for team accesses API endpoints in People's core app : create
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core import enums, factories, models
|
||||
from core.tests.fixtures import matrix
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -171,14 +174,17 @@ def test_api_team_accesses_create_authenticated_owner():
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_create_webhook():
|
||||
def test_api_team_accesses_create__with_scim_webhook():
|
||||
"""
|
||||
When the team has a webhook, creating a team access should fire a call.
|
||||
If a team has a SCIM webhook, creating a team access should fire a call
|
||||
with the expected payload.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
webhook = factories.TeamWebhookFactory(team=team)
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
team=team, protocol=enums.WebhookProtocolChoices.SCIM
|
||||
)
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
@@ -226,3 +232,207 @@ def test_api_team_accesses_create_webhook():
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_team_accesses_create__with_matrix_webhook():
|
||||
"""
|
||||
If a team has a Matrix webhook, creating a team access should fire a call
|
||||
with the expected payload.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
team=team,
|
||||
url="https://server.fr/#/room/room_id:room_server.fr",
|
||||
secret="some-secret-you-should-not-store-on-a-postit",
|
||||
protocol=enums.WebhookProtocolChoices.MATRIX,
|
||||
)
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock successful responses by matrix server
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful("room_id")["message"]),
|
||||
status=matrix.mock_join_room_successful("room_id")["status_code"],
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(other_user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(matrix.mock_invite_successful()["message"]),
|
||||
status=matrix.mock_invite_successful()["status_code"],
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
assert len(responses.calls) == 3
|
||||
assert (
|
||||
responses.calls[0].request.url
|
||||
== "https://room_server.fr/_matrix/client/v3/rooms/room_id:room_server.fr/join"
|
||||
)
|
||||
|
||||
# Payload sent to matrix server
|
||||
assert webhook.secret in responses.calls[0].request.headers["Authorization"]
|
||||
assert json.loads(responses.calls[2].request.body) == {
|
||||
"user_id": f"@{other_user.email.replace('@', '-')}:user_server.com",
|
||||
"reason": f"User added to team {webhook.team} on People",
|
||||
}
|
||||
|
||||
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_team_accesses_create__multiple_webhooks_success(caplog):
|
||||
"""
|
||||
When the team has multiple webhooks, creating a team access should fire all the expected calls.
|
||||
If all responses are positive, proceeds to add the user to the team.
|
||||
"""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
webhook_scim = factories.TeamWebhookFactory(
|
||||
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
|
||||
)
|
||||
webhook_matrix = factories.TeamWebhookFactory(
|
||||
team=team,
|
||||
url="https://www.webhookserver.fr/#/room/room_id:home_server.fr/",
|
||||
protocol=enums.WebhookProtocolChoices.MATRIX,
|
||||
secret="yo",
|
||||
)
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Ensure successful response by scim provider using "responses":
|
||||
responses.patch(
|
||||
re.compile(r".*/Groups/.*"),
|
||||
body="{}",
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful("room_id")["message"]),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(matrix.mock_invite_successful()["message"]),
|
||||
status=matrix.mock_invite_successful()["status_code"],
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
for webhook in [webhook_scim, webhook_matrix]:
|
||||
assert (
|
||||
f"add_user_to_group synchronization succeeded with {webhook.url}"
|
||||
in log_messages
|
||||
)
|
||||
|
||||
# Status
|
||||
for webhook in [webhook_scim, webhook_matrix]:
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "success"
|
||||
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_team_accesses_create__multiple_webhooks_failure(caplog):
|
||||
"""When a webhook fails, user should still be added to the team."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
webhook_scim = factories.TeamWebhookFactory(
|
||||
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
|
||||
)
|
||||
webhook_matrix = factories.TeamWebhookFactory(
|
||||
team=team,
|
||||
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
|
||||
protocol=enums.WebhookProtocolChoices.MATRIX,
|
||||
secret="secret",
|
||||
)
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
responses.patch(
|
||||
re.compile(r".*/Groups/.*"),
|
||||
body="{}",
|
||||
status=200,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_forbidden()["message"]),
|
||||
status=str(matrix.mock_join_room_forbidden()["status_code"]),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
assert (
|
||||
f"add_user_to_group synchronization succeeded with {webhook_scim.url}"
|
||||
in log_messages
|
||||
)
|
||||
assert (
|
||||
f"add_user_to_group synchronization failed with {webhook_matrix.url}"
|
||||
in log_messages
|
||||
)
|
||||
|
||||
# Status
|
||||
webhook_scim.status = "success"
|
||||
webhook_matrix.status = "failure"
|
||||
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
|
||||
|
||||
@@ -4,7 +4,6 @@ Test for team accesses API endpoints in People's core app : delete
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
@@ -157,16 +156,18 @@ def test_api_team_accesses_delete_owners_last_owner():
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_team_accesses_delete_webhook():
|
||||
"""
|
||||
When the team has a webhook, deleting a team access should fire a call.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
webhook = factories.TeamWebhookFactory(team=team)
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
# add webhook after access to prevent webhook from being triggered
|
||||
webhook = factories.TeamWebhookFactory(team=team)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
@@ -174,42 +175,34 @@ def test_api_team_accesses_delete_webhook():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response by scim provider using "responses":
|
||||
rsp = rsps.add(
|
||||
rsps.PATCH,
|
||||
re.compile(r".*/Groups/.*"),
|
||||
body="{}",
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
)
|
||||
patch_response = responses.patch(webhook.url, status=200, json={})
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
assert response.status_code == 204
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
assert rsp.call_count == 1
|
||||
assert rsps.calls[0].request.url == webhook.url
|
||||
# Check the request was made
|
||||
assert patch_response.call_count == 1
|
||||
|
||||
# Payload sent to scim provider
|
||||
payload = json.loads(rsps.calls[0].request.body)
|
||||
assert payload == {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "members",
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": access.user.email,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
# Payload sent to scim provider
|
||||
scim_payload = json.loads(patch_response.calls[0].request.body)
|
||||
assert scim_payload == {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "members",
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": access.user.email,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists() is False
|
||||
|
||||
@@ -19,6 +19,7 @@ def test_api_config_anonymous():
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"CRISP_WEBSITE_ID": None,
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
|
||||
"COMMIT": "NA",
|
||||
"FEATURES": {
|
||||
@@ -42,6 +43,7 @@ def test_api_config_authenticated():
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"CRISP_WEBSITE_ID": None,
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
|
||||
"COMMIT": "NA",
|
||||
"FEATURES": {
|
||||
|
||||
@@ -11,7 +11,6 @@ from rest_framework.test import APIClient
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import factories as domains_factories
|
||||
from mailbox_manager import models as domains_models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -19,20 +18,22 @@ pytestmark = pytest.mark.django_db
|
||||
def test_api_stats__anonymous(django_assert_num_queries):
|
||||
"""Stats endpoint should be available even when not connected."""
|
||||
|
||||
domains_factories.MailDomainFactory.create_batch(5)
|
||||
domains_factories.MailDomainEnabledFactory.create_batch(5)
|
||||
core_factories.TeamFactory.create_batch(3)
|
||||
domains_factories.AliasFactory.create_batch(2)
|
||||
|
||||
# clear cache to allow stats count
|
||||
cache.clear()
|
||||
with django_assert_num_queries(5):
|
||||
with django_assert_num_queries(6):
|
||||
response = APIClient().get("/api/v1.0/stats/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"total_users": 0,
|
||||
"mau": 0,
|
||||
"domains": 5,
|
||||
"active_domains": 7,
|
||||
"mailboxes": 0,
|
||||
"teams": 3,
|
||||
"aliases": 2,
|
||||
}
|
||||
# no new request made due to caching
|
||||
with django_assert_num_queries(0):
|
||||
@@ -41,9 +42,10 @@ def test_api_stats__anonymous(django_assert_num_queries):
|
||||
assert response.json() == {
|
||||
"total_users": 0,
|
||||
"mau": 0,
|
||||
"domains": 5,
|
||||
"active_domains": 7,
|
||||
"mailboxes": 0,
|
||||
"teams": 3,
|
||||
"aliases": 2,
|
||||
}
|
||||
|
||||
|
||||
@@ -57,10 +59,10 @@ def test_api_stats__expected_count():
|
||||
client.force_login(user)
|
||||
|
||||
core_factories.TeamFactory.create_batch(3)
|
||||
domains_factories.MailDomainFactory.create_batch(2)
|
||||
domains_factories.MailboxFactory.create_batch(
|
||||
10, domain=domains_models.MailDomain.objects.all()[1]
|
||||
)
|
||||
domains_factories.MailDomainFactory.create_batch(1)
|
||||
enabled_domain, _ = domains_factories.MailDomainEnabledFactory.create_batch(2)
|
||||
domains_factories.MailboxFactory.create_batch(10, domain=enabled_domain)
|
||||
domains_factories.AliasFactory.create_batch(2, domain=enabled_domain)
|
||||
|
||||
# clear cache to allow stats count
|
||||
cache.clear()
|
||||
@@ -69,7 +71,8 @@ def test_api_stats__expected_count():
|
||||
assert response.json() == {
|
||||
"total_users": 10,
|
||||
"mau": 6,
|
||||
"domains": 2,
|
||||
"active_domains": 2,
|
||||
"mailboxes": 10,
|
||||
"teams": 3,
|
||||
"aliases": 2,
|
||||
}
|
||||
|
||||
@@ -171,10 +171,11 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["email"] == [
|
||||
"This email is already associated to a registered user."
|
||||
]
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert (
|
||||
response.json()["detail"]
|
||||
== "Email already known. Invitation not sent but access created instead."
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_invitations__list__anonymous_user():
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests the core application loads the management command as tasks."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from people.celery_app import app as celery_app
|
||||
|
||||
|
||||
def test_fill_organization_metadata_as_task(settings):
|
||||
"""Check the fill_organization_metadata command is loaded as a task."""
|
||||
# Verify the command is configured to be loaded as a task
|
||||
assert "fill_organization_metadata" in settings.MANAGEMENT_COMMAND_AS_TASK
|
||||
|
||||
# The task should be registered in the format "app_name.command_name"
|
||||
task_name = "core.fill_organization_metadata"
|
||||
assert task_name in celery_app.tasks
|
||||
|
||||
# Test that the task can be executed properly
|
||||
with patch("core.apps.call_command") as mock_call_command:
|
||||
# Get the registered task
|
||||
task = celery_app.tasks[task_name]
|
||||
|
||||
# Execute the task
|
||||
result = task("arg1", "arg2", kwarg1="value1", kwarg2="value2")
|
||||
|
||||
# Verify call_command was called with the correct command name
|
||||
mock_call_command.assert_called_once()
|
||||
assert mock_call_command.call_args[0][0] == "fill_organization_metadata"
|
||||
assert mock_call_command.call_args[0][1] == "arg1"
|
||||
assert mock_call_command.call_args[0][2] == "arg2"
|
||||
assert mock_call_command.call_args[1]["kwarg1"] == "value1"
|
||||
assert mock_call_command.call_args[1]["kwarg2"] == "value2"
|
||||
|
||||
# Verify the task returns a dictionary with stdout and stderr
|
||||
assert isinstance(result, dict)
|
||||
assert "stdout" in result
|
||||
assert "stderr" in result
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Unit tests for the Team model
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
|
||||
def test_models_account_services_validate_scope():
|
||||
"""Test that account service scopes are validated."""
|
||||
# Valid scope from settings.ACCOUNT_SERVICE_SCOPES
|
||||
account_service = factories.AccountServiceFactory(
|
||||
scopes=["la-suite-list-organizations-siret"]
|
||||
)
|
||||
assert account_service.scopes == ["la-suite-list-organizations-siret"]
|
||||
|
||||
# Invalid scope
|
||||
with pytest.raises(ValidationError, match="Invalid scope: invalid-scope"):
|
||||
factories.AccountServiceFactory(scopes=["invalid-scope"])
|
||||
|
||||
# Multiple scopes with one invalid
|
||||
with pytest.raises(ValidationError, match="Invalid scope: invalid-scope"):
|
||||
factories.AccountServiceFactory(
|
||||
scopes=["la-suite-list-organizations-siret", "invalid-scope"]
|
||||
)
|
||||
|
||||
|
||||
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
|
||||
def test_models_account_services_name_null():
|
||||
"""The "name" field should not be null."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be null."):
|
||||
models.AccountService.objects.create(name=None)
|
||||
|
||||
|
||||
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
|
||||
def test_models_account_services_name_empty():
|
||||
"""The "name" field should not be empty."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank."):
|
||||
models.AccountService.objects.create(name="")
|
||||
@@ -16,6 +16,7 @@ from faker import Faker
|
||||
from freezegun import freeze_time
|
||||
|
||||
from core import factories, models
|
||||
from core.exceptions import EmailAlreadyKnownException
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -48,6 +49,17 @@ def test_models_invitations_team_required():
|
||||
factories.InvitationFactory(team=None)
|
||||
|
||||
|
||||
def test_models_invitations_email_case_insensitive_duplicate_check():
|
||||
"""The email validation should be case-insensitive when checking for existing users."""
|
||||
# Create a user with a lowercase email
|
||||
factories.UserFactory(email="john.doe@example.com")
|
||||
|
||||
# Try to create an invitation with different case
|
||||
# This should raise the same exception as if the email was exactly the same
|
||||
with pytest.raises(EmailAlreadyKnownException):
|
||||
factories.InvitationFactory(email="John.Doe@Example.COM")
|
||||
|
||||
|
||||
def test_models_invitations_team_should_be_team_instance():
|
||||
"""The "team" field should be a team instance."""
|
||||
with pytest.raises(ValueError, match='Invitation.team" must be a "Team" instance'):
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_models_team_accesses_unique():
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Team/user relation with this User and Team already exists.",
|
||||
match="This user is already in this team.",
|
||||
):
|
||||
factories.TeamAccessFactory(user=access.user, team=access.team)
|
||||
|
||||
@@ -83,47 +83,41 @@ def test_models_team_accesses_create_webhook():
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_models_team_accesses_delete_webhook():
|
||||
"""
|
||||
When the team has a webhook, deleting a team access should fire a call.
|
||||
"""
|
||||
team = factories.TeamFactory()
|
||||
webhook = factories.TeamWebhookFactory(team=team)
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
# add webhook after access to prevent webhook from being triggered
|
||||
webhook = factories.TeamWebhookFactory(team=team)
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response by scim provider using "responses":
|
||||
rsp = rsps.add(
|
||||
rsps.PATCH,
|
||||
re.compile(r".*/Groups/.*"),
|
||||
body="{}",
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
)
|
||||
# Ensure successful response by scim provider using "responses":
|
||||
rsp = responses.patch(webhook.url, status=200, json={})
|
||||
|
||||
access.delete()
|
||||
access.delete()
|
||||
|
||||
assert rsp.call_count == 1
|
||||
assert rsps.calls[0].request.url == webhook.url
|
||||
assert rsp.call_count == 1
|
||||
|
||||
# Payload sent to scim provider
|
||||
payload = json.loads(rsps.calls[0].request.body)
|
||||
assert payload == {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "members",
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": access.user.email,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
# Payload sent to scim provider
|
||||
payload = json.loads(rsp.calls[0].request.body)
|
||||
assert payload == {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "members",
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": access.user.email,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert models.TeamAccess.objects.exists() is False
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Test the production settings for password validation is correct."""
|
||||
|
||||
from django.contrib.auth.password_validation import (
|
||||
get_default_password_validators,
|
||||
validate_password,
|
||||
)
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from people.settings import Production
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(name="use_production_password_validators")
|
||||
def use_production_password_validators_fixture(settings):
|
||||
"""Set the production password validators."""
|
||||
settings.AUTH_PASSWORD_VALIDATORS = Production.AUTH_PASSWORD_VALIDATORS
|
||||
|
||||
get_default_password_validators.cache_clear()
|
||||
assert len(get_default_password_validators()) == 5
|
||||
|
||||
yield
|
||||
|
||||
get_default_password_validators.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"password, error",
|
||||
[
|
||||
("password", "This password is too common."),
|
||||
("password123", "This password is too common."),
|
||||
("123", "This password is too common."),
|
||||
("coucou", "This password is too common."),
|
||||
("john doe 123", "The password is too similar to the name"),
|
||||
],
|
||||
)
|
||||
def test_validate_password_validator(
|
||||
use_production_password_validators, # pylint: disable=unused-argument
|
||||
password,
|
||||
error,
|
||||
):
|
||||
"""Test the Mailbox password validation."""
|
||||
user = UserFactory(name="John Doe")
|
||||
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
validate_password(password, user)
|
||||
assert error in str(excinfo.value)
|
||||
@@ -31,26 +31,29 @@ def test_api_users_list_anonymous():
|
||||
|
||||
def test_api_users_list_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to list all users.
|
||||
Authenticated users should be able to list users from their organization.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
factories.UserFactory(organization=organization)
|
||||
factories.UserFactory.create_batch(2) # 2 users outside organization
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert len(response.json()["results"]) == 3
|
||||
assert len(response.json()["results"]) == 2
|
||||
|
||||
|
||||
def test_api_users_list_authenticated_response_content(
|
||||
client, django_assert_num_queries
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list all users with the expected output.
|
||||
Authenticated users should be able to list all users from their organization
|
||||
with the expected output.
|
||||
"""
|
||||
user_organization = factories.OrganizationFactory(
|
||||
with_registration_id=True, name="HAL 9000"
|
||||
@@ -67,7 +70,7 @@ def test_api_users_list_authenticated_response_content(
|
||||
other_user_organization = factories.OrganizationFactory(
|
||||
with_registration_id=True, name="Corp. Inc."
|
||||
)
|
||||
other_user = factories.UserFactory(
|
||||
factories.UserFactory(
|
||||
organization=other_user_organization,
|
||||
email="sara83@example.com",
|
||||
name="Christopher Thompson",
|
||||
@@ -85,23 +88,10 @@ def test_api_users_list_authenticated_response_content(
|
||||
.first()
|
||||
)
|
||||
assert edited_json == {
|
||||
"count": 2,
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"email": "sara83@example.com",
|
||||
"id": str(other_user.pk),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
"language": "fr-fr",
|
||||
"name": "Christopher Thompson",
|
||||
"organization": {
|
||||
"id": str(other_user.organization.pk),
|
||||
"name": "Corp. Inc.",
|
||||
},
|
||||
"timezone": "UTC",
|
||||
},
|
||||
{
|
||||
"email": "kylefields@example.net",
|
||||
"id": str(user.pk),
|
||||
@@ -124,13 +114,17 @@ def test_api_users_authenticated_list_by_email():
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the email.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(email="david.bowman@work.com", name=None)
|
||||
user = factories.UserFactory(
|
||||
email="tester@ministry.fr", name="john doe", with_organization=True
|
||||
)
|
||||
dave = factories.UserFactory(
|
||||
email="david.bowman@work.com", name=None, organization=user.organization
|
||||
)
|
||||
nicole = factories.UserFactory(
|
||||
email="nicole_foole@work.com", name=None, with_organization=True
|
||||
email="nicole_foole@work.com", name=None, organization=user.organization
|
||||
)
|
||||
frank = factories.UserFactory(
|
||||
email="frank_poole@work.com", name=None, with_organization=True
|
||||
email="frank_poole@work.com", name=None, organization=user.organization
|
||||
)
|
||||
factories.UserFactory(email="heywood_floyd@work.com", name=None)
|
||||
|
||||
@@ -203,13 +197,17 @@ def test_api_users_authenticated_list_by_name():
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the name.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(name="Dave bowman", email=None)
|
||||
user = factories.UserFactory(
|
||||
email="tester@ministry.fr", name="john doe", with_organization=True
|
||||
)
|
||||
dave = factories.UserFactory(
|
||||
name="Dave bowman", email=None, organization=user.organization
|
||||
)
|
||||
nicole = factories.UserFactory(
|
||||
name="nicole foole", email=None, with_organization=True
|
||||
name="nicole foole", email=None, organization=user.organization
|
||||
)
|
||||
frank = factories.UserFactory(
|
||||
name="frank poolé", email=None, with_organization=True
|
||||
name="frank poolé", email=None, organization=user.organization
|
||||
)
|
||||
factories.UserFactory(name="heywood floyd", email=None)
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Test Team synchronization webhooks : focus on matrix client."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories
|
||||
from core.enums import WebhookProtocolChoices
|
||||
from core.tests.fixtures import matrix
|
||||
from core.utils.matrix import MatrixAPIClient
|
||||
from core.utils.webhooks import webhooks_synchronizer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
## SEARCH
|
||||
@responses.activate
|
||||
def test_matrix_webhook__search_user_unknown(caplog):
|
||||
"""When searching for a user in the Matrix federation but cannot find any,
|
||||
we invite a (future ?) user using user's email and room's server."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:room_server.au",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
client = MatrixAPIClient()
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_empty()["message"]),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.get_user_id(user=user, webhook=webhook)
|
||||
assert response == f"@{user.email.replace('@', '-')}:room_server.au"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_matrix_webhook__search_multiple_ids(caplog):
|
||||
"""When searching for a user in Matrix federation,
|
||||
if user directory returns multiple ids, invite the first one."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:room_server.au",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
client = MatrixAPIClient()
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful_multiple(user)["message"]),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.get_user_id(user=user, webhook=webhook)
|
||||
assert response == f"@{user.email.replace('@', '-')}:user_server1.com"
|
||||
|
||||
|
||||
## INVITE
|
||||
@responses.activate
|
||||
def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
|
||||
"""Cannot invite when Matrix returns forbidden. This might mean the user is an admin."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:home_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
error = matrix.mock_kick_user_forbidden(user)
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(error["message"]),
|
||||
status=error["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_matrix_webhook__invite_user_to_room_already_in_room(caplog):
|
||||
"""If user is already in room, webhooks should be set to success."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:home_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful("room_id")["message"]),
|
||||
status=matrix.mock_join_room_successful("room_id")["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(matrix.mock_invite_user_already_in_room(user)["message"]),
|
||||
status=matrix.mock_invite_user_already_in_room(user)["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
expected_messages = (
|
||||
f"add_user_to_group synchronization succeeded with {webhook.url}"
|
||||
)
|
||||
assert expected_messages in log_messages
|
||||
|
||||
# Status
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "success"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_matrix_webhook__invite_user_to_room_success(caplog):
|
||||
"""The user passed to the function should get invited."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:home_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful("room_id")["message"]),
|
||||
status=matrix.mock_join_room_successful("room_id")["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(matrix.mock_invite_successful()["message"]),
|
||||
status=matrix.mock_invite_successful()["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
|
||||
|
||||
# Check headers
|
||||
headers = responses.calls[0].request.headers
|
||||
assert webhook.secret in headers["Authorization"]
|
||||
|
||||
# Check payloads sent to Matrix API
|
||||
assert json.loads(responses.calls[2].request.body) == {
|
||||
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
|
||||
"reason": f"User added to team {webhook.team} on People",
|
||||
}
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
expected_messages = (
|
||||
f"add_user_to_group synchronization succeeded with {webhook.url}"
|
||||
)
|
||||
assert expected_messages in log_messages
|
||||
|
||||
# Status
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "success"
|
||||
|
||||
|
||||
@responses.activate
|
||||
@override_settings(MATRIX_BOT_ACCESS_TOKEN="TCHAP_TOKEN")
|
||||
def test_matrix_webhook__override_secret_for_tchap():
|
||||
"""The user passed to the function should get invited."""
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.tchap.gouv.fr/#/room/room_id:home_server",
|
||||
secret=None,
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful("room_id")["message"]),
|
||||
status=matrix.mock_join_room_successful("room_id")["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(matrix.mock_invite_successful()["message"]),
|
||||
status=matrix.mock_invite_successful()["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
|
||||
|
||||
# Check headers
|
||||
headers = responses.calls[0].request.headers
|
||||
assert "TCHAP_TOKEN" in headers["Authorization"]
|
||||
|
||||
# Check correctly inferred base url from room_id
|
||||
assert "matrix.home_server" in responses.calls[0].request.url
|
||||
|
||||
|
||||
## KICK
|
||||
@responses.activate
|
||||
def test_matrix_webhook__kick_user_from_room_not_in_room(caplog):
|
||||
"""Webhook should report a success when user was already not in room."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:home_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/kick"),
|
||||
body=str(matrix.mock_kick_user_not_in_room()["message"]),
|
||||
status=matrix.mock_kick_user_not_in_room()["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
assert (
|
||||
f"remove_user_from_group synchronization succeeded with {webhook.url}"
|
||||
in log_messages
|
||||
)
|
||||
|
||||
# Status
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "success"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_matrix_webhook__kick_user_from_room_success(caplog):
|
||||
"""The user passed to the function should get removed."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:room_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/kick"),
|
||||
body=str(matrix.mock_kick_successful),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
|
||||
|
||||
# Check payloads sent to Matrix API
|
||||
assert json.loads(responses.calls[2].request.body) == {
|
||||
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
|
||||
"reason": f"User removed from team {webhook.team} on People",
|
||||
}
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
expected_messages = (
|
||||
f"remove_user_from_group synchronization succeeded with {webhook.url}"
|
||||
)
|
||||
assert expected_messages in log_messages
|
||||
|
||||
# Status
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "success"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
|
||||
"""Cannot kick an admin."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:home_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
error = matrix.mock_kick_user_forbidden(user)
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/kick"),
|
||||
body=str(error["message"]),
|
||||
status=error["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
assert (
|
||||
f"remove_user_from_group synchronization failed with {webhook.url}"
|
||||
in log_messages
|
||||
)
|
||||
|
||||
# Status
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "failure"
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Test Team synchronization webhooks."""
|
||||
"""Test Team synchronization webhooks : focus on scim client"""
|
||||
|
||||
import json
|
||||
import random
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
import responses
|
||||
|
||||
from core import factories
|
||||
from core.utils.webhooks import scim_synchronizer
|
||||
from core.utils.webhooks import webhooks_synchronizer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_utils_webhooks_add_user_to_group_no_webhooks():
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
with responses.RequestsMock():
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
|
||||
assert len(responses.calls) == 0
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_utils_webhooks_add_user_to_group_success(mock_info):
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
|
||||
for i, webhook in enumerate(webhooks):
|
||||
assert rsps.calls[i].request.url == webhook.url
|
||||
@@ -107,7 +107,7 @@ def test_utils_webhooks_remove_user_from_group_success(mock_info):
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
scim_synchronizer.remove_user_from_group(access.team, access.user)
|
||||
webhooks_synchronizer.remove_user_from_group(access.team, access.user)
|
||||
|
||||
for i, webhook in enumerate(webhooks):
|
||||
assert rsps.calls[i].request.url == webhook.url
|
||||
@@ -163,11 +163,11 @@ def test_utils_webhooks_add_user_to_group_failure(mock_error):
|
||||
rsps.PATCH,
|
||||
re.compile(r".*/Groups/.*"),
|
||||
body="{}",
|
||||
status=random.choice([404, 301, 302]),
|
||||
status=404,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
|
||||
for i, webhook in enumerate(webhooks):
|
||||
assert rsps.calls[i].request.url == webhook.url
|
||||
@@ -228,7 +228,7 @@ def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
|
||||
rsps.add(rsps.PATCH, url, status=200, content_type="application/json"),
|
||||
]
|
||||
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
|
||||
for i in range(4):
|
||||
assert all_rsps[i].call_count == 1
|
||||
@@ -285,7 +285,7 @@ def test_utils_synchronize_course_runs_max_retries_exceeded(mock_error):
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
|
||||
assert rsp.call_count == 5
|
||||
assert rsps.calls[0].request.url == webhook.url
|
||||
@@ -339,7 +339,7 @@ def test_utils_webhooks_add_user_to_group_authorization():
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
assert rsps.calls[0].request.url == webhook.url
|
||||
|
||||
# Check headers
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Utility module providing I/O related classes and functions."""
|
||||
|
||||
from io import StringIO
|
||||
|
||||
|
||||
class TeeStringIO:
|
||||
"""String IO implementation that captures output while preserving original logger output."""
|
||||
|
||||
def __init__(self, logger_output):
|
||||
"""Initialize a TeeStringIO instance.
|
||||
|
||||
Args:
|
||||
logger_output: A callable that will receive captured output.
|
||||
"""
|
||||
self.logger_output = logger_output
|
||||
self.buffer = StringIO()
|
||||
|
||||
def write(self, value):
|
||||
"""Write a string to both the logger and internal buffer.
|
||||
|
||||
Args:
|
||||
value: The string to write.
|
||||
"""
|
||||
self.logger_output(value.strip("\n"))
|
||||
self.buffer.write(value)
|
||||
|
||||
def read(self):
|
||||
"""Read the contents of the buffer.
|
||||
|
||||
Returns:
|
||||
The buffer contents as a string.
|
||||
"""
|
||||
return self.buffer.read()
|
||||
|
||||
def seek(self, *args, **kwargs):
|
||||
"""Set the buffer's position.
|
||||
|
||||
Args:
|
||||
*args: Positional arguments passed to the underlying buffer.
|
||||
**kwargs: Keyword arguments passed to the underlying buffer.
|
||||
|
||||
Returns:
|
||||
The new position in the buffer.
|
||||
"""
|
||||
return self.buffer.seek(*args, **kwargs)
|
||||
|
||||
def flush(self):
|
||||
"""Flush the internal buffer."""
|
||||
self.buffer.flush()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Matrix client for interoperability to synchronize with remote service providers."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
)
|
||||
from urllib3.util import Retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
max_retries=Retry(
|
||||
total=4,
|
||||
backoff_factor=0.1,
|
||||
status_forcelist=[500, 502],
|
||||
allowed_methods=["POST"],
|
||||
)
|
||||
)
|
||||
|
||||
session = requests.Session()
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
|
||||
|
||||
class MatrixAPIClient:
|
||||
"""A client to interact with Matrix API"""
|
||||
|
||||
def get_headers(self, webhook):
|
||||
"""Build header dict from webhook object."""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if "tchap.gouv.fr" in webhook.url:
|
||||
token = settings.MATRIX_BOT_ACCESS_TOKEN
|
||||
elif webhook.secret:
|
||||
token = webhook.secret
|
||||
else:
|
||||
raise ValueError("Please configure this webhook's secret access token.")
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
def _get_room_url(self, webhook_url):
|
||||
"""Returns room id from webhook url."""
|
||||
room_id = webhook_url.split("/room/")[1]
|
||||
base_url = room_id.split(":")[1]
|
||||
if "tchap.gouv.fr" in webhook_url and "i.tchap.gouv.fr" not in webhook_url:
|
||||
base_url = f"matrix.{base_url}"
|
||||
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
|
||||
|
||||
def get_user_id(self, user, webhook):
|
||||
"""Returns user id from email."""
|
||||
if user.email is None:
|
||||
raise ValueError("You must first set an email for the user.")
|
||||
|
||||
if settings.MATRIX_BASE_HOME_SERVER:
|
||||
home_server = settings.MATRIX_BASE_HOME_SERVER
|
||||
search = session.post(
|
||||
f"{home_server}/_matrix/client/v3/user_directory/search",
|
||||
json={"search_term": f"@{user.email.replace('@', '-')}"},
|
||||
headers=self.get_headers(webhook),
|
||||
verify=True,
|
||||
timeout=3,
|
||||
)
|
||||
results = search.json()["results"]
|
||||
if len(results) > 0:
|
||||
return results[0]["user_id"]
|
||||
|
||||
# try and invite unknown user using room home server
|
||||
room_home_server = webhook.url.split(":")[2]
|
||||
return f"@{user.email.replace('@', '-')}:{room_home_server}"
|
||||
|
||||
def join_room(self, webhook):
|
||||
"""Accept invitation to the room. As of today, it is a mandatory step
|
||||
to make sure our account will be able to invite/remove users."""
|
||||
return session.post(
|
||||
f"{self._get_room_url(webhook.url)}/join",
|
||||
json={},
|
||||
headers=self.get_headers(webhook),
|
||||
verify=True,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
def add_user_to_group(self, webhook, user):
|
||||
"""Send request to invite an user to a room or space upon adding them to group.."""
|
||||
join_response = self.join_room(webhook)
|
||||
if join_response.status_code != HTTP_200_OK:
|
||||
logger.error(
|
||||
"Synchronization failed (cannot join room) %s",
|
||||
webhook.url,
|
||||
)
|
||||
return join_response, False
|
||||
logger.info(
|
||||
"Succesfully joined room",
|
||||
)
|
||||
|
||||
user_id = self.get_user_id(user, webhook)
|
||||
response = session.post(
|
||||
f"{self._get_room_url(webhook.url)}/invite",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"reason": f"User added to team {webhook.team} on People",
|
||||
},
|
||||
headers=self.get_headers(webhook),
|
||||
verify=True,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
# Checks for false negative
|
||||
# (i.e. trying to invite user already in room)
|
||||
webhook_succeeded = False
|
||||
if (
|
||||
response.status_code == HTTP_200_OK
|
||||
or b"is already in the room." in response.content
|
||||
):
|
||||
webhook_succeeded = True
|
||||
|
||||
return response, webhook_succeeded
|
||||
|
||||
def remove_user_from_group(self, webhook, user):
|
||||
"""Send request to kick an user from a room or space upon removing them from group."""
|
||||
join_response = self.join_room(webhook)
|
||||
if join_response.status_code != HTTP_200_OK:
|
||||
logger.error(
|
||||
"Synchronization failed (cannot join room) %s",
|
||||
webhook.url,
|
||||
)
|
||||
return join_response, False
|
||||
|
||||
user_id = self.get_user_id(user, webhook)
|
||||
response = session.post(
|
||||
f"{self._get_room_url(webhook.url)}/kick",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"reason": f"User removed from team {webhook.team} on People",
|
||||
},
|
||||
headers=self.get_headers(webhook),
|
||||
verify=True,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
# Checks for false negative
|
||||
# (i.e. trying to remove user who already left the room)
|
||||
webhook_succeeded = False
|
||||
if (
|
||||
response.status_code == HTTP_200_OK
|
||||
or b"The target user is not in the room" in response.content
|
||||
):
|
||||
webhook_succeeded = True
|
||||
|
||||
return response, webhook_succeeded
|
||||
@@ -39,7 +39,7 @@ class SCIMClient:
|
||||
],
|
||||
}
|
||||
|
||||
return session.patch(
|
||||
response = session.patch(
|
||||
webhook.url,
|
||||
json=payload,
|
||||
headers=webhook.get_headers(),
|
||||
@@ -47,6 +47,8 @@ class SCIMClient:
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
return response, response.ok
|
||||
|
||||
def remove_user_from_group(self, webhook, user):
|
||||
"""Remove a user from a group by its ID or email."""
|
||||
payload = {
|
||||
@@ -61,10 +63,12 @@ class SCIMClient:
|
||||
}
|
||||
],
|
||||
}
|
||||
return session.patch(
|
||||
response = session.patch(
|
||||
webhook.url,
|
||||
json=payload,
|
||||
headers=webhook.get_headers(),
|
||||
verify=False,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
return response, response.ok
|
||||
|
||||
@@ -4,14 +4,16 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
from core import enums
|
||||
from core.enums import WebhookStatusChoices
|
||||
|
||||
from .matrix import MatrixAPIClient
|
||||
from .scim import SCIMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookSCIMClient:
|
||||
class WebhookClient:
|
||||
"""Wraps the SCIM client to record call results on webhooks."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
@@ -26,31 +28,14 @@ class WebhookSCIMClient:
|
||||
if not webhook.url:
|
||||
continue
|
||||
|
||||
client = SCIMClient()
|
||||
status = WebhookStatusChoices.FAILURE
|
||||
try:
|
||||
response = getattr(client, name)(webhook, user)
|
||||
|
||||
except requests.exceptions.RetryError as exc:
|
||||
logger.error(
|
||||
"%s synchronization failed due to max retries exceeded with url %s",
|
||||
name,
|
||||
webhook.url,
|
||||
exc_info=exc,
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.error(
|
||||
"%s synchronization failed with %s.",
|
||||
name,
|
||||
webhook.url,
|
||||
exc_info=exc,
|
||||
)
|
||||
else:
|
||||
extra = {
|
||||
"response": response.content,
|
||||
}
|
||||
response, webhook_succeeded = self._get_response_and_status(
|
||||
name, webhook, user
|
||||
)
|
||||
if response is not None:
|
||||
extra = {"response": response.content}
|
||||
# pylint: disable=no-member
|
||||
if response.status_code == requests.codes.ok:
|
||||
if webhook_succeeded:
|
||||
logger.info(
|
||||
"%s synchronization succeeded with %s",
|
||||
name,
|
||||
@@ -71,5 +56,37 @@ class WebhookSCIMClient:
|
||||
|
||||
return wrapper
|
||||
|
||||
def _get_client(self, webhook):
|
||||
"""Get client depending on the protocol."""
|
||||
if webhook.protocol == enums.WebhookProtocolChoices.MATRIX:
|
||||
return MatrixAPIClient()
|
||||
|
||||
scim_synchronizer = WebhookSCIMClient()
|
||||
return SCIMClient()
|
||||
|
||||
def _get_response_and_status(self, name, webhook, user):
|
||||
"""Get response from webhook outside party."""
|
||||
client = self._get_client(webhook)
|
||||
|
||||
try:
|
||||
response, webhook_succeeded = getattr(client, name)(webhook, user)
|
||||
except requests.exceptions.RetryError as exc:
|
||||
logger.error(
|
||||
"%s synchronization failed due to max retries exceeded with url %s",
|
||||
name,
|
||||
webhook.url,
|
||||
exc_info=exc,
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.error(
|
||||
"%s synchronization failed with %s.",
|
||||
name,
|
||||
webhook.url,
|
||||
exc_info=exc,
|
||||
)
|
||||
else:
|
||||
return response, webhook_succeeded
|
||||
|
||||
return None, False
|
||||
|
||||
|
||||
webhooks_synchronizer = WebhookClient()
|
||||
|
||||
@@ -4,5 +4,7 @@ NB_OBJECTS = {
|
||||
"users": 1000,
|
||||
"teams": 100,
|
||||
"max_users_per_team": 100,
|
||||
"domains": 20,
|
||||
"domains": 10,
|
||||
"mailboxes_per_domain": 2,
|
||||
"aliases_per_domain": 2,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user