Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 133688324b | |||
| a7b3cd42bc | |||
| ceebf8f7aa | |||
| 8ef2cc9a37 | |||
| e2d362bc77 | |||
| 594d3af0d0 | |||
| 855e20d407 | |||
| f60bfc2676 | |||
| b4de7fda92 | |||
| a009f3ccb7 | |||
| 2f1843e0e8 | |||
| 3a044e6b02 | |||
| 7c569a3ca3 | |||
| e23d236614 | |||
| 61c3b6ac6b | |||
| 1eb9dffa48 | |||
| d0854851a2 | |||
| 6eae92d9e5 | |||
| b02146e4eb | |||
| dd43483ce6 | |||
| 838d1267b2 | |||
| fbe3aa54d0 | |||
| e4e9a121a4 | |||
| 3173e096d9 | |||
| 4420bab073 | |||
| 8cbedeb76e | |||
| 28fdee868d | |||
| 4ced342062 | |||
| 2502ff0c99 | |||
| dc33493739 | |||
| 81030004e9 | |||
| 339831f090 | |||
| 5178e460c4 | |||
| feb5d7154b | |||
| 7dac39034a | |||
| 660fc7c291 | |||
| 27fd43b164 | |||
| e63c31f960 | |||
| 6b2ca88ff2 | |||
| b771f614e2 | |||
| 889a495ea3 | |||
| f21716dc68 | |||
| 7e1f0b31f9 | |||
| 666cafe220 | |||
| 1ec98f0948 | |||
| f0258bbde7 | |||
| 773f8cfb4b | |||
| 4003f66243 | |||
| 5cd8f79f1e | |||
| 9c451e74a6 | |||
| f35d17628d | |||
| fa6cfefcd9 | |||
| 46ef6eca78 | |||
| 9439f454de | |||
| db3185e16b | |||
| 4c033d7262 | |||
| 74655ba378 | |||
| 859efa26dc | |||
| d31b79aaad | |||
| 495245a752 | |||
| 03600f243e | |||
| d5eb736343 | |||
| 9f1c1ea7dd | |||
| 803b2c1930 | |||
| e1193bc26d | |||
| 285647a8a9 | |||
| c4dd4ae3fd | |||
| 06f1695071 | |||
| 34783d0557 | |||
| 5cc8108e7b | |||
| 59633d6543 | |||
| a6f7c07052 | |||
| 67f8bc32fa | |||
| 0b290d9a5e | |||
| 68ed5e4d55 | |||
| 48264a0b40 | |||
| e3bf1d76fa | |||
| f64a592648 | |||
| 7ce5b28af4 | |||
| 3aaddc0493 | |||
| 07ff093b18 | |||
| 319a9b18d8 | |||
| 403fea94bb | |||
| 5730b9ea5e | |||
| 7f75efacf8 | |||
| 305e2438c5 | |||
| ebc2b02d22 | |||
| 185b87da40 | |||
| 701aeca763 | |||
| 7a128393f6 | |||
| 21993b3272 | |||
| 63ec61c465 | |||
| 67d9b6462f | |||
| ea1f06f6cc | |||
| b063f690f6 | |||
| ae92ab5dd8 | |||
| 22419d4779 | |||
| 91389181f0 | |||
| 45bafe04de | |||
| c90a74b362 | |||
| 54df9af179 | |||
| 2224acf12d | |||
| 9ee1ef5ba0 | |||
| 7ea381c88a | |||
| 680e05b4a7 | |||
| 6e7ebc76d0 | |||
| d5b154fbe0 | |||
| ef4c1da78c | |||
| 4060006a22 | |||
| 8b56d97037 | |||
| 5fd6579d3c | |||
| b4ab36fc0e | |||
| 160ce92e54 | |||
| a7ab2142f9 | |||
| cf4b435c63 | |||
| fd8e0e08c3 | |||
| 68550f6f7e | |||
| db6cdadd72 | |||
| 8faa049046 | |||
| a386c61729 | |||
| 99cc4d00d5 | |||
| 55b7d1adbd | |||
| 7d3f10a4b6 | |||
| 5f294b8436 | |||
| db338231b7 | |||
| 21033dedbd | |||
| 2dfa97749f | |||
| 7ccd8e3035 | |||
| 8d0fbdfecd | |||
| a83fb25f6d | |||
| 11c7af205b | |||
| f23f1eabd6 | |||
| 7025a0787f | |||
| 70d22eecfa | |||
| 7379d70321 | |||
| 29d0bbb692 | |||
| cdb766b0e0 | |||
| 38de864d68 | |||
| cad065da84 | |||
| 3893fdf4d7 | |||
| d29b5141b1 | |||
| 110fc82250 | |||
| 95f19f7c6c | |||
| ab03cd9db9 | |||
| a811431070 | |||
| d23ac76f36 | |||
| 9377a96e87 | |||
| 25313d3e84 | |||
| 9cd1b42c3d | |||
| d08198e44d | |||
| e55468862d | |||
| fbb4797f29 | |||
| 0f290df24a | |||
| a2b2c71448 | |||
| e1594493a7 | |||
| e903c5d4ca | |||
| 0f80d5f2db | |||
| 4cb695c2bf | |||
| a68f8171cb | |||
| 471f69d4ec | |||
| dc938d3159 | |||
| 87907d57de | |||
| 57fd15100e | |||
| 34c9dc6cd7 | |||
| 1be30496be | |||
| 0ca5fa5318 | |||
| 4d3901b35d | |||
| 961bceb64e | |||
| d7957547f8 | |||
| 418db6194a | |||
| c2899f66af | |||
| ba3f6a504f | |||
| 3de495a489 | |||
| e297a025c3 |
@@ -9,9 +9,9 @@ We primarily use GitHub as an issue tracker. If however you're encountering an i
|
||||
|
||||
---
|
||||
|
||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/people).
|
||||
Please make sure you have read our [main Readme](https://github.com/suitenumerique/people).
|
||||
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/people/issues).
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/people/issues).
|
||||
|
||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Download translations from Crowdin
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Create empty source files
|
||||
run: |
|
||||
touch src/backend/locale/django.pot
|
||||
mkdir -p src/frontend/packages/i18n/locales/desk/
|
||||
touch src/frontend/packages/i18n/locales/desk/translations-crowdin.json
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
create_pull_request: false
|
||||
push_translations: false
|
||||
push_sources: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate translations files
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:deploy
|
||||
# Create a new PR
|
||||
- name: Create a new Pull Request with new translated strings
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: |
|
||||
🌐(i18n) update translated strings
|
||||
|
||||
Update translated files with new translations
|
||||
title: 🌐(i18n) update translated strings
|
||||
body: |
|
||||
## Purpose
|
||||
|
||||
update translated strings
|
||||
|
||||
## Proposal
|
||||
|
||||
- [x] update translated strings
|
||||
branch: i18n/update-translations
|
||||
labels: i18n
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Update crowdin sources
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
needs: install-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# Backend i18n
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .
|
||||
working-directory: src/backend
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: Install gettext
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc
|
||||
- name: generate pot files
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate source translation file
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:extract
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Dependency reusable workflow
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '18.x'
|
||||
type: string
|
||||
with-front-dependencies-installation:
|
||||
type: boolean
|
||||
default: false
|
||||
with-build_mails:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
front-dependencies-installation:
|
||||
if: ${{ inputs.with-front-dependencies-installation == true }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
- name: Setup Node.js
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
build-mails:
|
||||
if: ${{ inputs.with-build_mails == true }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
|
||||
- name: Install yarn
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Install node dependencies
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build mails
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn build
|
||||
|
||||
- name: Cache mail templates
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
@@ -118,12 +118,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 }}"
|
||||
|
||||
@@ -9,6 +9,14 @@ on:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
# Call the reusable workflow to install dependencies
|
||||
dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
lint-git:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
@@ -21,7 +29,7 @@ jobs:
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep "print("
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep -E "(\bprint\(|\bpprint\()"
|
||||
- name: Check absence of fixup commits
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
@@ -56,39 +64,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install-front:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
build-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -111,7 +89,7 @@ jobs:
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -128,7 +106,7 @@ jobs:
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -145,7 +123,7 @@ jobs:
|
||||
|
||||
test-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-mails, build-front]
|
||||
needs: [dependencies, build-front]
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -161,11 +139,13 @@ jobs:
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Download mails' templates
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
@@ -201,9 +181,6 @@ jobs:
|
||||
run: |
|
||||
make dimail-setup-db
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
|
||||
@@ -234,30 +211,6 @@ jobs:
|
||||
if: ${{ contains(needs.*.result, 'failure') }}
|
||||
run: exit 1
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
- name: Install node dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build mails
|
||||
run: yarn build
|
||||
- name: Persist mails' templates
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -269,7 +222,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.11'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
@@ -281,7 +234,7 @@ jobs:
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
needs: dependencies
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
@@ -316,15 +269,17 @@ jobs:
|
||||
run: |
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
- name: Download mails' templates
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.11'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install gettext (required to compile messages)
|
||||
|
||||
@@ -5,8 +5,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- ./src/helm/desk/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -20,7 +18,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -rf ./src/helm/extra
|
||||
run: |
|
||||
rm -rf ./src/helm/extra
|
||||
rm -rf ./src/helm/dimail
|
||||
rm -rf ./src/helm/maildev
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
|
||||
@@ -58,6 +58,9 @@ src/frontend/tsclient
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Celery beat
|
||||
src/backend/celerybeat-schedule
|
||||
|
||||
# Test & lint
|
||||
.coverage
|
||||
coverage.json
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
|
||||
+123
-21
@@ -8,6 +8,99 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.15.0] - 2025-04-04
|
||||
|
||||
### Added
|
||||
|
||||
- 🧱(helm) add la-suite ingress path
|
||||
- ➕(backend) add django-lasuite dependency #858
|
||||
- ✨(plugins) add endpoint to list siret of active organizations #771
|
||||
- ✨(core) create AccountServiceAuthentication backend #771
|
||||
- ✨(core) create AccountService model #771
|
||||
- 🧱(helm) disable createsuperuser job by setting #863
|
||||
- 🔒️(passwords) add validators for production #850
|
||||
- ✨(domains) allow to re-run check on domain if status is failed
|
||||
- ✨(organization) add `is_active` field
|
||||
- ✨(domains) notify support when domain status changes #668
|
||||
- ✨(domains) define domain check interval as a settings
|
||||
- ✨(oidc) add simple introspection backend #832
|
||||
- 🧑💻(tasks) run management commands #814
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(plugins) rewrite plugin system as django app #844
|
||||
- 🔒️(users) restrict listable users to same organization #846
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(dimail) enhance sentry log
|
||||
- 🐛(oauth2) force JWT signed for /userinfo #804
|
||||
- 🐛(front) disable retries in useQuery and useInfiniteQuery #818
|
||||
|
||||
## [1.14.1] - 2025-03-17
|
||||
|
||||
## [1.14.0] - 2025-03-17
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(domains) enhance required action modal content
|
||||
- ✨(domains) add periodic tasks to fetch domain status
|
||||
- 🧑💻(docker) add celery beat to manage periodic tasks
|
||||
- ✨(organization) add metadata field #790
|
||||
- ⬆️(nginx) bump nginx-unprivileged to 1.27 #797
|
||||
- ✨(teams) allow broadly available teams #796
|
||||
- ✨(teams) update and enhance team invitation email
|
||||
- ✨(api) define dimail timeout as a setting
|
||||
- ✨(frontend) feature modal add new access role to domain
|
||||
- ✨(api) allow invitations for domain management #708
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(oauth2) force JWT signed for /userinfo #804
|
||||
- 🐛(oauth2) add ProConnect scopes #802
|
||||
- 🐛(domains) use a dedicated mail to invite user to manage domain
|
||||
- 🐛(mailbox) fix mailbox creation email language
|
||||
|
||||
## [1.13.1] - 2025-03-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(mailbox) fix migration to fill dn_email field
|
||||
|
||||
## [1.13.0] - 2025-03-04
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(oidc) people as an identity provider #638
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💄(domains) improve user experience and avoid repeat fix_domain operations
|
||||
- 👽️(dimail) increase timeout value for check domain API call
|
||||
- 🧱(helm) add resource-server ingress path #743
|
||||
- 🌐(backend) synchronize translations with crowdin again
|
||||
|
||||
## [1.12.1] - 2025-02-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- 👽️(dimail) increase timeout value for API calls
|
||||
|
||||
## [1.12.0] - 2025-02-18
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(domains) allow user to re-run all fetch domain data from dimail
|
||||
- ✨(domains) display DNS config expected for domain with required actions
|
||||
- ✨(domains) check status after creation
|
||||
- ✨(domains) display required actions to do on domain
|
||||
- ✨(plugin) add CommuneCreation plugin with domain provisioning #658
|
||||
- ✨(frontend) display action required status on domain
|
||||
- ✨(domains) store last health check details on MailDomain
|
||||
- ✨(domains) add support email field on domain
|
||||
|
||||
## [1.11.0] - 2025-02-07
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add count mailboxes to MailDomain serializer
|
||||
@@ -17,6 +110,7 @@ and this project adheres to
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✨(auth) fix empty names from ProConnect #687
|
||||
- 🚑️(teams) do not display add button when disallowed #676
|
||||
- 🚑️(plugins) fix name from SIRET specific case #674
|
||||
- 🐛(api) restrict mailbox sync to enabled domains
|
||||
@@ -276,24 +370,32 @@ and this project adheres to
|
||||
- ✨(domains) create and manage domains on admin + API
|
||||
- ✨(domains) mailbox creation + link to email provisioning API
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.10.1...main
|
||||
[1.10.1]: https://github.com/numerique-gouv/people/releases/v1.10.1
|
||||
[1.10.0]: https://github.com/numerique-gouv/people/releases/v1.10.0
|
||||
[1.9.1]: https://github.com/numerique-gouv/people/releases/v1.9.1
|
||||
[1.9.0]: https://github.com/numerique-gouv/people/releases/v1.9.0
|
||||
[1.8.0]: https://github.com/numerique-gouv/people/releases/v1.8.0
|
||||
[1.7.1]: https://github.com/numerique-gouv/people/releases/v1.7.1
|
||||
[1.7.0]: https://github.com/numerique-gouv/people/releases/v1.7.0
|
||||
[1.6.1]: https://github.com/numerique-gouv/people/releases/v1.6.1
|
||||
[1.6.0]: https://github.com/numerique-gouv/people/releases/v1.6.0
|
||||
[1.5.0]: https://github.com/numerique-gouv/people/releases/v1.5.0
|
||||
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
|
||||
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
|
||||
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
|
||||
[1.3.0]: https://github.com/numerique-gouv/people/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/numerique-gouv/people/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/numerique-gouv/people/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/numerique-gouv/people/releases/v1.1.0
|
||||
[1.0.2]: https://github.com/numerique-gouv/people/releases/v1.0.2
|
||||
[1.0.1]: https://github.com/numerique-gouv/people/releases/v1.0.1
|
||||
[1.0.0]: https://github.com/numerique-gouv/people/releases/v1.0.0
|
||||
[unreleased]: https://github.com/suitenumerique/people/compare/v1.15.0...main
|
||||
[1.15.0]: https://github.com/suitenumerique/people/releases/v1.15.0
|
||||
[1.14.1]: https://github.com/suitenumerique/people/releases/v1.14.1
|
||||
[1.14.0]: https://github.com/suitenumerique/people/releases/v1.14.0
|
||||
[1.13.1]: https://github.com/suitenumerique/people/releases/v1.13.1
|
||||
[1.13.0]: https://github.com/suitenumerique/people/releases/v1.13.0
|
||||
[1.12.1]: https://github.com/suitenumerique/people/releases/v1.12.1
|
||||
[1.12.0]: https://github.com/suitenumerique/people/releases/v1.12.0
|
||||
[1.11.0]: https://github.com/suitenumerique/people/releases/v1.11.0
|
||||
[1.10.1]: https://github.com/suitenumerique/people/releases/v1.10.1
|
||||
[1.10.0]: https://github.com/suitenumerique/people/releases/v1.10.0
|
||||
[1.9.1]: https://github.com/suitenumerique/people/releases/v1.9.1
|
||||
[1.9.0]: https://github.com/suitenumerique/people/releases/v1.9.0
|
||||
[1.8.0]: https://github.com/suitenumerique/people/releases/v1.8.0
|
||||
[1.7.1]: https://github.com/suitenumerique/people/releases/v1.7.1
|
||||
[1.7.0]: https://github.com/suitenumerique/people/releases/v1.7.0
|
||||
[1.6.1]: https://github.com/suitenumerique/people/releases/v1.6.1
|
||||
[1.6.0]: https://github.com/suitenumerique/people/releases/v1.6.0
|
||||
[1.5.0]: https://github.com/suitenumerique/people/releases/v1.5.0
|
||||
[1.4.1]: https://github.com/suitenumerique/people/releases/v1.4.1
|
||||
[1.4.0]: https://github.com/suitenumerique/people/releases/v1.4.0
|
||||
[1.3.1]: https://github.com/suitenumerique/people/releases/v1.3.1
|
||||
[1.3.0]: https://github.com/suitenumerique/people/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/suitenumerique/people/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/suitenumerique/people/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/suitenumerique/people/releases/v1.1.0
|
||||
[1.0.2]: https://github.com/suitenumerique/people/releases/v1.0.2
|
||||
[1.0.1]: https://github.com/suitenumerique/people/releases/v1.0.1
|
||||
[1.0.0]: https://github.com/suitenumerique/people/releases/v1.0.0
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# Contributing to the Project
|
||||
|
||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
|
||||
|
||||
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
||||
|
||||
## Help us with translations
|
||||
|
||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-people).
|
||||
Your language is not there? Request it on our Crowdin page 😊.
|
||||
|
||||
## Creating an Issue
|
||||
|
||||
When creating an issue, please provide the following details:
|
||||
|
||||
1. **Title**: A concise and descriptive title for the issue.
|
||||
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
|
||||
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
|
||||
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
|
||||
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
|
||||
|
||||
## Selecting an issue
|
||||
|
||||
We use a [GitHub Project](https://github.com/orgs/suitenumerique/projects/1) in order to prioritize our workload.
|
||||
|
||||
Please check in priority the issues that are in the **TODO cette semaine** column.
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
All commit messages must adhere to the following format:
|
||||
|
||||
`<gitmoji>(type) title description`
|
||||
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change, starting with a lowercase character.
|
||||
* **description**: Include additional details about what was changed and why.
|
||||
|
||||
### Example Commit Message
|
||||
|
||||
```
|
||||
✨(frontend) add user authentication logic
|
||||
|
||||
Implemented login and signup features, and integrated OAuth2 for social login.
|
||||
```
|
||||
|
||||
## Changelog Update
|
||||
|
||||
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
|
||||
|
||||
### Example Changelog Message
|
||||
```
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(frontend) add AI to the project #321
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||
|
||||
### Don't forget to:
|
||||
- check your commits
|
||||
- check the linting: `make lint && make frontend-lint`
|
||||
- check the tests: `make test`
|
||||
- add a changelog entry
|
||||
- squash your commits
|
||||
- rebase your branch on the latest `main` branch before pushing your changes `git pull --rebase origin main`
|
||||
|
||||
### Process to have a nice commit history
|
||||
|
||||
In the life time of your PR, you may need to add commits to fix things or add new features.
|
||||
Commit after commit, your PR will be full of commits but you have to clean it up with the following commands before merging on `main`:
|
||||
|
||||
Gradually you can use `--fixup` to add commits to some of previous commit ( for example 1234567890).
|
||||
```
|
||||
git commit --fixup=1234567890
|
||||
```
|
||||
Then, you can squash your commits with the following command:
|
||||
```
|
||||
git rebase --autosquash -i -r HEAD~<number-of-commits>
|
||||
```
|
||||
|
||||
Or you can use:
|
||||
```
|
||||
git rebase -i HEAD~<number-of-commits>
|
||||
```
|
||||
and move, squash and/or rename your commits manually. You can squash them with previous commit replacing the `pick` by `f`. You can rename them with replacing the `pick` by `r`.
|
||||
Tada! You have a clean commit history.
|
||||
|
||||
Once all the required tests have passed, you can request a review from the project maintainers.
|
||||
|
||||
## Code Style
|
||||
|
||||
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
|
||||
|
||||
## Tests
|
||||
|
||||
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
|
||||
|
||||
## Asking for Help
|
||||
|
||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||
|
||||
Thank you for your contributions! 👍
|
||||
+7
-1
@@ -39,7 +39,13 @@ FROM frontend-builder-dev AS frontend-builder
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
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
|
||||
|
||||
@@ -72,6 +72,7 @@ data/static:
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/france \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql
|
||||
@@ -109,6 +110,8 @@ 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..."
|
||||
@@ -155,6 +158,10 @@ 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:
|
||||
cd $(PATH_FRONT) && yarn lint
|
||||
.PHONY: lint-front
|
||||
|
||||
test: ## run project tests
|
||||
@$(MAKE) test-back-parallel
|
||||
.PHONY: test
|
||||
@@ -205,6 +212,7 @@ back-i18n-compile: ## compile the gettext files
|
||||
.PHONY: back-i18n-compile
|
||||
|
||||
back-i18n-generate: ## create the .pot files used for i18n
|
||||
back-i18n-generate: mails-build crowdin-download-sources
|
||||
@$(MANAGE) makemessages -a --keep-pot
|
||||
.PHONY: back-i18n-generate
|
||||
|
||||
@@ -228,6 +236,9 @@ resetdb: ## flush database and create a superuser "admin"
|
||||
env.d/development/common:
|
||||
cp -n env.d/development/common.dist env.d/development/common
|
||||
|
||||
env.d/development/france:
|
||||
cp -n env.d/development/france.dist env.d/development/france
|
||||
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
@@ -259,6 +270,7 @@ i18n-compile: \
|
||||
|
||||
i18n-generate: ## create the .pot files and extract frontend messages
|
||||
i18n-generate: \
|
||||
crowdin-download-sources \
|
||||
back-i18n-generate \
|
||||
frontend-i18n-generate
|
||||
.PHONY: i18n-generate
|
||||
@@ -284,12 +296,18 @@ recreate-dimail-container:
|
||||
|
||||
dimail-setup-db:
|
||||
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
|
||||
@$(MANAGE) setup_dimail_db
|
||||
@$(MANAGE) setup_dimail_db --populate-from-people
|
||||
.PHONY: dimail-setup-db
|
||||
|
||||
# -- Mail generator
|
||||
|
||||
mails-build: ## Convert mjml files to html and text
|
||||
mails-clean-templates: ## Clean the generated mail templates directory
|
||||
@echo "$(BOLD)Cleaning mail templates directory$(RESET)"
|
||||
@rm -rf ./src/backend/core/templates/mail
|
||||
@mkdir -p ./src/backend/core/templates/mail
|
||||
.PHONY: mails-clean-templates
|
||||
|
||||
mails-build: mails-clean-templates ## Convert mjml files to html and text
|
||||
@$(MAIL_YARN) build
|
||||
.PHONY: mails-build
|
||||
|
||||
@@ -328,7 +346,7 @@ help:
|
||||
|
||||
# Front
|
||||
install-front-desk: ## Install the frontend dependencies of app Desk
|
||||
cd $(PATH_FRONT_DESK) && yarn
|
||||
cd $(PATH_FRONT_DESK) && yarn && yarn playwright install chromium
|
||||
.PHONY: install-front-desk
|
||||
|
||||
run-front-desk: ## Start app Desk
|
||||
|
||||
@@ -59,6 +59,35 @@ cmd_button('Migrate db',
|
||||
text='Run database migration',
|
||||
)
|
||||
|
||||
# Command to reset DB
|
||||
reset_db = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py flush --no-input
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py createsuperuser --username admin@example.com --password admin
|
||||
'''
|
||||
cmd_button('Reset DB',
|
||||
argv=['sh', '-c', reset_db],
|
||||
resource='desk-backend',
|
||||
icon_name='developer_board',
|
||||
text='Reset DB',
|
||||
)
|
||||
|
||||
# Command to create demo data
|
||||
populate_people_with_demo_data = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py create_demo --force
|
||||
'''
|
||||
cmd_button('Populate with demo data',
|
||||
argv=['sh', '-c', populate_people_with_demo_data],
|
||||
resource='desk-backend',
|
||||
icon_name='developer_board',
|
||||
text='Populate with demo data',
|
||||
)
|
||||
|
||||
# Command to created domain/users/access from people to dimail
|
||||
populate_dimail_from_people = '''
|
||||
set -eu
|
||||
|
||||
+40
-4
@@ -9,8 +9,8 @@ services:
|
||||
redis:
|
||||
image: redis:5
|
||||
|
||||
mailcatcher:
|
||||
image: sj26/mailcatcher:latest
|
||||
maildev:
|
||||
image: maildev/maildev:latest
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
@@ -27,6 +27,7 @@ services:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/france
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "8071:8000"
|
||||
@@ -37,7 +38,7 @@ services:
|
||||
depends_on:
|
||||
- dimail
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- maildev
|
||||
- redis
|
||||
|
||||
celery-dev:
|
||||
@@ -56,6 +57,22 @@ services:
|
||||
depends_on:
|
||||
- app-dev
|
||||
|
||||
celery-beat-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-development
|
||||
command: ["celery", "-A", "people.celery_app", "beat", "-l", "DEBUG"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- app-dev
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
@@ -101,7 +118,7 @@ services:
|
||||
image: jwilder/dockerize
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:3.16.0
|
||||
image: crowdin/cli:4.6.1
|
||||
volumes:
|
||||
- ".:/app"
|
||||
env_file:
|
||||
@@ -128,6 +145,8 @@ services:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
@@ -161,3 +180,20 @@ services:
|
||||
DIMAIL_JWT_SECRET: fake_jwt_secret
|
||||
ports:
|
||||
- "8001:8000"
|
||||
|
||||
flower-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-development
|
||||
command: ["celery", "-A", "people.celery_app", "flower", "--port=5555"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "5555:5555"
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
depends_on:
|
||||
- celery-dev
|
||||
- redis
|
||||
|
||||
+183
-4
@@ -50,6 +50,9 @@
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -65,7 +68,7 @@
|
||||
"lastName": "Delamairie",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "21580304000017"
|
||||
"siret": "21510339100011"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
@@ -81,6 +84,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -95,6 +101,9 @@
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "13002526500013"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
@@ -109,6 +118,9 @@
|
||||
"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",
|
||||
@@ -1652,8 +1709,130 @@
|
||||
"enabledEventTypes": [],
|
||||
"adminEventsEnabled": false,
|
||||
"adminEventsDetailsEnabled": false,
|
||||
"identityProviders": [],
|
||||
"identityProviderMappers": [],
|
||||
"identityProviders": [
|
||||
{
|
||||
"alias": "oidc-people-local",
|
||||
"displayName": "People OIDC (local)",
|
||||
"internalId": "47aa6d7c-8ac5-4178-934e-66f78e510ee4",
|
||||
"providerId": "oidc",
|
||||
"enabled": true,
|
||||
"updateProfileFirstLoginMode": "on",
|
||||
"trustEmail": false,
|
||||
"storeToken": false,
|
||||
"addReadTokenRoleOnCreate": false,
|
||||
"authenticateByDefault": false,
|
||||
"linkOnly": false,
|
||||
"firstBrokerLoginFlowAlias": "first broker login",
|
||||
"config": {
|
||||
"hideOnLoginPage": "false",
|
||||
"userInfoUrl": "http://app-dev:8000/o/userinfo/",
|
||||
"validateSignature": "true",
|
||||
"acceptsPromptNoneForwardFromClient": "false",
|
||||
"clientId": "people-idp",
|
||||
"tokenUrl": "http://app-dev:8000/o/token/",
|
||||
"uiLocales": "false",
|
||||
"jwksUrl": "http://app-dev:8000/o/.well-known/jwks.json",
|
||||
"backchannelSupported": "false",
|
||||
"issuer": "http://app-dev:8000/o",
|
||||
"useJwksUrl": "true",
|
||||
"loginHint": "true",
|
||||
"pkceEnabled": "true",
|
||||
"pkceMethod": "S256",
|
||||
"authorizationUrl": "http://localhost:8071/o/authorize/",
|
||||
"clientAuthMethod": "client_secret_post",
|
||||
"disableUserInfo": "false",
|
||||
"syncMode": "IMPORT",
|
||||
"clientSecret": "local-tests-only",
|
||||
"passMaxAge": "false",
|
||||
"defaultScope": "openid given_name usual_name email siret",
|
||||
"allowedClockSkew": "0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"identityProviderMappers": [
|
||||
{
|
||||
"id": "e55dc88c-7bb5-46fb-95ad-1df701a96282",
|
||||
"name": "Sub",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "oidc-username-idp-mapper",
|
||||
"config": {
|
||||
"template": "${CLAIM.sub}",
|
||||
"are.claim.values.regex": "false",
|
||||
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
|
||||
"syncMode": "FORCE",
|
||||
"attributes": "[]",
|
||||
"target": "BROKER_ID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7e489676-8cba-49e4-aa1e-dcd1462d33f7",
|
||||
"name": "given_name",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "hardcoded-attribute-idp-mapper",
|
||||
"config": {
|
||||
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
|
||||
"syncMode": "FORCE",
|
||||
"are.claim.values.regex": "false",
|
||||
"attributes": "[]",
|
||||
"attribute": "firstName"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "30b6b3bc-5738-4936-bf88-c540b8805998",
|
||||
"name": "usual_name",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
||||
"config": {
|
||||
"template": "${ALIAS}.${CLAIM.preferred_username}",
|
||||
"are.claim.values.regex": "false",
|
||||
"claims": "[{\"key\":\"profile\",\"value\":\"lastName\"}]",
|
||||
"syncMode": "FORCE",
|
||||
"claim": "profile",
|
||||
"user.attribute": "lastName",
|
||||
"attributes": "[]",
|
||||
"target": "LOCAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b67caa26-4571-4cfe-9c15-68e022645fc5",
|
||||
"name": "Username",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "oidc-username-idp-mapper",
|
||||
"config": {
|
||||
"template": "${CLAIM.email | lowercase}",
|
||||
"are.claim.values.regex": "false",
|
||||
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
|
||||
"syncMode": "FORCE",
|
||||
"attributes": "[]",
|
||||
"target": "BROKER_USERNAME"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "4eef21ce-b5f7-4753-bd58-4e50eb2b5f31",
|
||||
"name": "Email",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
||||
"config": {
|
||||
"are.claim.values.regex": "false",
|
||||
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
|
||||
"syncMode": "FORCE",
|
||||
"claim": "email",
|
||||
"user.attribute": "email",
|
||||
"attributes": "[]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "084cdd0e-0794-4388-8474-84c9a7c1b9c8",
|
||||
"name": "siret",
|
||||
"identityProviderAlias": "oidc-people-local",
|
||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
||||
"config": {
|
||||
"syncMode": "FORCE",
|
||||
"claim": "siret",
|
||||
"user.attribute": "siret"
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
|
||||
{
|
||||
@@ -2195,7 +2374,7 @@
|
||||
"authenticatorConfig": "review profile config",
|
||||
"authenticator": "idp-review-profile",
|
||||
"authenticatorFlow": false,
|
||||
"requirement": "REQUIRED",
|
||||
"requirement": "DISABLED",
|
||||
"priority": 10,
|
||||
"autheticatorFlow": false,
|
||||
"userSetupAllowed": false
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Internationalization (i18n)
|
||||
|
||||
The backend and the frontend of the application are internationalized.
|
||||
This means that the application can be translated into different languages.
|
||||
The application is currently available in English and French.
|
||||
|
||||
## Local setup
|
||||
|
||||
To be able to upload/retrieve translation files to/from Crowdin you need to
|
||||
setup your local environment with the following environment variables:
|
||||
|
||||
```bash
|
||||
cp ./env.d/development/crowdin.dist ./env.d/development/crowdin
|
||||
```
|
||||
|
||||
Then fill "CROWDIN_API_TOKEN" with your token and "CROWDIN_PROJECT_ID" with the project id
|
||||
in the `./env.d/development/crowdin` file.
|
||||
|
||||
NB: If you don't have a personal Crowdin API token, go to Crowdin interface to generate it:
|
||||
Settings -> API -> Personal Access Tokens -> New token
|
||||
|
||||
This configuration file will be loaded by the crowdin docker instance.
|
||||
|
||||
|
||||
## How to update the translations
|
||||
|
||||
### First way (safe way)
|
||||
|
||||
1/ Generate the translation files for both frontend and backend
|
||||
```bash
|
||||
make i18n-generate
|
||||
```
|
||||
Check locally everything is ok before uploading the files to the translation platform.
|
||||
|
||||
2/ Upload the files to the translation platform (crowdin):
|
||||
```bash
|
||||
make crowdin-upload
|
||||
```
|
||||
|
||||
3/ Fill the missing translations on the translation platform:
|
||||
|
||||
=> [https://crowdin.com/project/lasuite-people](https://crowdin.com/project/lasuite-people)
|
||||
|
||||
4/ Download the translated files from the translation platform and compile them:
|
||||
```bash
|
||||
make i18n-download-and-compile
|
||||
```
|
||||
|
||||
### Second way (faster way)
|
||||
|
||||
1/ Generate the translation files for both frontend and backend and upload them to the translation platform (crowdin):
|
||||
```bash
|
||||
make i18n-generate-and-upload
|
||||
```
|
||||
|
||||
2/ Fill the missing translations on the translation platform:
|
||||
|
||||
=> [https://crowdin.com/project/lasuite-people](https://crowdin.com/project/lasuite-people)
|
||||
|
||||
3/ Download the translated files from the translation platform and compile them:
|
||||
```bash
|
||||
make i18n-download-and-compile
|
||||
```
|
||||
@@ -0,0 +1,100 @@
|
||||
# People as an Identity Provider
|
||||
|
||||
The people project can be configured to act as an Identity Provider for an OIDC federation.
|
||||
|
||||
The model containing the "identity" in `people` is the `Mailbox` model.
|
||||
|
||||
The connection workflow looks like:
|
||||
|
||||
- The user tries to reach a service provider.
|
||||
- The service provider redirects the user to OIDC federation (in dev we use Keycloak).
|
||||
- The user asks (or is redirected) to the Identity Provider (people).
|
||||
- The user enter their email and password.
|
||||
- If successful, the user is redirected to the OIDC federation with a token.
|
||||
- The federation make the same Authorization Flow as usual to authenticate on the Service Provider.
|
||||
|
||||
|
||||
## Technical aspects
|
||||
|
||||
### The `Mailbox` model
|
||||
|
||||
The `Mailbox` model can behave like a usual Django user:
|
||||
|
||||
- It has a `password` field (and `last_login`)
|
||||
- It is considered as `authenticated`.
|
||||
- To be `active` it must have an `enabled` status.
|
||||
- To be able to log in, it must also have a `MailboxDomain` with an `enabled` status and an associated `Organization`.
|
||||
|
||||
### The `MailboxModelBackend` authentication backend
|
||||
|
||||
This authentication backend only allow authentication with `email` and `password` with a `Mailbox` user.
|
||||
This backend is combined with the `one_time_email_authenticated_session` middleware to restrict
|
||||
the session to the OIDC login process.
|
||||
A user connecting with this backend will only be able to access the `/o/authorize/` URL.
|
||||
|
||||
### The OIDC validators
|
||||
|
||||
There are two validators available:
|
||||
|
||||
- `BaseValidator`: This validator retrieves simple claims
|
||||
- `ProConnectValidator`: This validator is a custom validator to allow ProConnect specific requirements.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
The keycloak configuration is not described here, but you may find information in the code base.
|
||||
|
||||
### Django settings
|
||||
|
||||
The following settings are required to enable the OIDC Identity Provider feature:
|
||||
|
||||
- OAUTH2_PROVIDER_OIDC_ENABLED: True
|
||||
- OAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY: ...
|
||||
|
||||
Optional:
|
||||
|
||||
- OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
|
||||
|
||||
If the `OAUTH2_PROVIDER_VALIDATOR_CLASS` is set to the `ProConnectValidator`, the claims will be
|
||||
automatically defined to match the ProConnect requirements.
|
||||
|
||||
### Django OIDC application
|
||||
|
||||
To enable an OIDC client to use people, you must declare it.
|
||||
|
||||
```python
|
||||
from oauth2_provider.models import Application
|
||||
|
||||
application = Application(
|
||||
client_id="people-idp",
|
||||
client_secret="local-tests-only",
|
||||
client_type=Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
|
||||
name="People Identity Provider",
|
||||
algorithm=Application.RS256_ALGORITHM,
|
||||
redirect_uris="http://localhost:8083/realms/people/broker/oidc-people-local/endpoint",
|
||||
skip_authorization=True,
|
||||
)
|
||||
application.clean()
|
||||
application.save()
|
||||
```
|
||||
|
||||
## Security concerns
|
||||
|
||||
### Failed login cooldown
|
||||
|
||||
To prevent brute force attacks, 5 failed login will trigger a cooldown of 5 minutes before being able to log in again.
|
||||
|
||||
### Password strength
|
||||
|
||||
There is currently no constraint on the password strength as it can only be defined by Django administrators,
|
||||
and later we will generate it.
|
||||
If at some point the user is allowed to set their password, we will need to enforce a password strength policy.
|
||||
|
||||
|
||||
## What is missing (next steps)
|
||||
|
||||
- `Mailbox` password can only be set through Django Admin panel (or shell), the next step will be to generate
|
||||
a secure password and send it or display it to the end user.
|
||||
- `MailboxDomain` organization can only be set through Django Admin panel (or shell), we need to provide a
|
||||
way to auto-select it or allow an administrator to do so.
|
||||
@@ -5,6 +5,8 @@ Tilt is a tool that helps you develop applications for Kubernetes.
|
||||
It watches your files for changes, rebuilds your containers, and restarts your pods.
|
||||
It's like having a conversation with your cluster.
|
||||
|
||||
This is particularly useful when working on integrations or to test your helm charts.
|
||||
Otherwise, you can use your good old docker configuration as described in README.md.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -13,113 +15,55 @@ This guide assumes you have the following tools installed:
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/)
|
||||
- [Kind](https://kind.sigs.k8s.io/docs/user/quick-start/)
|
||||
* [mkcert](https://github.com/FiloSottile/mkcert)
|
||||
* [mkcert](https://github.com/FiloSottile/mkcert)
|
||||
- [ctlptl](https://github.com/tilt-dev/ctlptl)
|
||||
- [Tilt](https://docs.tilt.dev/install.html)
|
||||
* [helm](https://helm.sh/docs/intro/install/)
|
||||
* [helmfile](https://github.com/helmfile/helmfile)
|
||||
* [secrets](https://github.com/jkroepke/helm-secrets/wiki/Installation)
|
||||
* [sops](https://github.com/getsops/sops)
|
||||
|
||||
|
||||
### SOPS configuration
|
||||
|
||||
**Generate a SOPS key**
|
||||
|
||||
For this specific step you need to have the `age-keygen` tool installed.
|
||||
See https://github.com/FiloSottile/age.
|
||||
Then generate a key:
|
||||
|
||||
```bash
|
||||
age-keygen -o my-age.key
|
||||
```
|
||||
|
||||
**Install the SOPS key**
|
||||
|
||||
Read the SOPS documentation on how to install the key in your environment.
|
||||
https://github.com/getsops/sops?tab=readme-ov-file#22encrypting-using-age
|
||||
|
||||
On Ubuntu it's like:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/sops/age/
|
||||
cp my-age.key ~/.config/sops/age/keys.txt
|
||||
chmod 400 ~/.config/sops/age/keys.txt
|
||||
```
|
||||
|
||||
**Add the SOPS key to the repository**
|
||||
|
||||
Update the [.sops.yaml](../.sops.yaml) file with the **public** key id you generated.
|
||||
|
||||
[Install_prereq script](https://github.com/numerique-gouv/dk8s/blob/main/scripts/install-prereq.sh) (not tested).
|
||||
|
||||
### Helmfile in Docker
|
||||
|
||||
If you use helmfile in Docker, you may need an additional configuration to make
|
||||
it work with you age key.
|
||||
|
||||
You need to mount `-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/"`
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
|
||||
docker run --rm --net=host \
|
||||
-v "${HOME}/.kube:/root/.kube" \
|
||||
-v "${HOME}/.config/helm:/root/.config/helm" \
|
||||
-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/" \
|
||||
-v "${HOME}/.minikube:/${HOME}/.minikube" \
|
||||
-v "${PWD}:/wd" \
|
||||
-e KUBECONFIG=/root/.kube/config \
|
||||
--workdir /wd ghcr.io/helmfile/helmfile:v0.150.0 helmfile "$@"
|
||||
```
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
### Create the kubernetes cluster
|
||||
## Create the kubernetes cluster
|
||||
|
||||
Run the following command to create a kubernetes cluster using kind:
|
||||
|
||||
```bash
|
||||
./bin/start-kind.sh
|
||||
```
|
||||
|
||||
**or** run the equivalent using the makefile
|
||||
|
||||
```bash
|
||||
make start-kind
|
||||
```
|
||||
|
||||
### [Optional] Install the secret
|
||||
|
||||
You don't need to do this if you are running the stack with keycloak.
|
||||
|
||||
```bash
|
||||
# import your secrets from credentials manager
|
||||
# ! don't forget "https" before your url
|
||||
make install-external-secrets
|
||||
```
|
||||
|
||||
### Deploy the application
|
||||
|
||||
```bash
|
||||
# Pro Connect environment
|
||||
tilt up -f ./bin/Tiltfile
|
||||
|
||||
# Standalone environment with keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
```
|
||||
|
||||
**or** run the equivalent using the makefile
|
||||
|
||||
```bash
|
||||
# Pro Connect environment
|
||||
make tilt-up
|
||||
|
||||
# Standalone environment with keycloak
|
||||
make tilt-up-keycloak
|
||||
```
|
||||
|
||||
That's it! You should now have a local development environment for Kubernetes.
|
||||
|
||||
You can access the application at https://desk.127.0.0.1.nip.io
|
||||
## Start the application
|
||||
|
||||
```bash
|
||||
# You can either start :
|
||||
# ProConnect stack (but secrets must be set on your local cluster)
|
||||
make tilt-up
|
||||
|
||||
# or standalone environment with keycloak
|
||||
make start-tilt-keycloak
|
||||
```
|
||||
|
||||
Access your application at https://desk.127.0.0.1.nip.io
|
||||
|
||||
## Management
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Releasing a new version
|
||||
|
||||
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
|
||||
|
||||
1. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
2. The next steps are automated in the `scripts/release.py`, you can run it using:
|
||||
|
||||
```bash
|
||||
make release
|
||||
```
|
||||
|
||||
This script will ask you for the version you want to release and the kind of release (patch, minor, major). It will then:
|
||||
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
|
||||
2. 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
|
||||
|
||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the 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.
|
||||
|
||||
4. Ask you to 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.
|
||||
|
||||
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.
|
||||
|
||||
7. The release is now done!
|
||||
|
||||
# Deploying
|
||||
|
||||
> [!TIP]
|
||||
> The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
|
||||
Making a new release doesn't publish it automatically in production.
|
||||
|
||||
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
|
||||
|
||||
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
|
||||
```
|
||||
@@ -10,7 +10,7 @@ PYTHONPATH=/app
|
||||
# People settings
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_HOST="maildev"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
|
||||
# Backend url
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# For the CI job test-e2e
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
|
||||
OAUTH2_PROVIDER_OIDC_ENABLED = True
|
||||
OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
|
||||
|
||||
INSTALLED_PLUGINS=plugins.la_suite
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
INSTALLED_PLUGINS=plugins.la_suite
|
||||
DNS_PROVISIONING_TARGET_ZONE=test.collectivite.fr
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint", "@hookform/resolvers"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+12
-10
@@ -76,7 +76,7 @@ def update_changelog(path, version):
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def deployment_part(version, kind):
|
||||
def deployment_part(version):
|
||||
""" Update helm file of preprod on deployment repository numerique-gouv/lasuite-deploiement
|
||||
"""
|
||||
# Move to lasuite-deploiement repository
|
||||
@@ -95,19 +95,19 @@ def deployment_part(version, kind):
|
||||
path = f"manifests/regie/env.d/preprod/values.desk.yaml.gotmpl"
|
||||
update_helm_files(path)
|
||||
run_command(f"git add {path}", shell=True)
|
||||
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}"""
|
||||
message = f"""🔖(regie) bump preprod version to {version}"""
|
||||
run_command(f"git commit -m '{message}'", shell=True)
|
||||
confirm = input(f"""\033[0;32m
|
||||
### DEPLOYMENT ###
|
||||
NEXT COMMAND on lasuite-deploiement repository:
|
||||
>> git push origin {deployment_branch}
|
||||
Continue ? (y,n)
|
||||
\x1b[0m""")
|
||||
\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:
|
||||
--> Please submit PR {deployment_branch} and merge code to main
|
||||
--> Please submit PR {deployment_branch} and merge code to main [https://github.com/numerique-gouv/lasuite-deploiement]
|
||||
\x1b[0m""")
|
||||
|
||||
|
||||
@@ -125,19 +125,21 @@ def project_part(version, kind):
|
||||
run_command("git add src/", shell=True)
|
||||
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
|
||||
|
||||
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
|
||||
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
|
||||
run_command(f"git commit -m '{message}'", shell=True)
|
||||
confirm = input(f"""\033[0;32m
|
||||
### 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
|
||||
--> A github action will download last translations from crowdin and create a commit to merge into the release branch.
|
||||
Please wait for this and merge this translations commit 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 +155,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, kind)
|
||||
deployment_part(version)
|
||||
|
||||
-1
Submodule secrets deleted from b7ab5f1411
@@ -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):
|
||||
@@ -60,7 +58,17 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Personal info"),
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"email",
|
||||
"language",
|
||||
"timezone",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -219,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,
|
||||
@@ -234,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
|
||||
@@ -244,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
|
||||
|
||||
@@ -275,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
|
||||
|
||||
@@ -251,6 +251,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
is_visible_all_services = serializers.BooleanField(required=False, default=True)
|
||||
service_providers = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ServiceProvider.objects.all(), many=True, required=False
|
||||
)
|
||||
@@ -263,6 +264,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"accesses",
|
||||
"created_at",
|
||||
"depth",
|
||||
"is_visible_all_services",
|
||||
"name",
|
||||
"numchild",
|
||||
"path",
|
||||
|
||||
@@ -7,6 +7,7 @@ from functools import reduce
|
||||
from django.conf import settings
|
||||
from django.db.models import OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
@@ -28,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
|
||||
|
||||
|
||||
@@ -144,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"]
|
||||
@@ -261,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
|
||||
@@ -605,10 +608,12 @@ class StatView(views.APIView):
|
||||
context = {
|
||||
"total_users": models.User.objects.count(),
|
||||
"mau": models.User.objects.filter(
|
||||
last_login__gte=datetime.datetime.now() - datetime.timedelta(30)
|
||||
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(),
|
||||
}
|
||||
return response.Response(context)
|
||||
|
||||
@@ -4,6 +4,8 @@ from django.core import exceptions
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
@@ -68,3 +70,28 @@ class TeamPermission(IsAuthenticated):
|
||||
|
||||
abilities = request.user.get_abilities()
|
||||
return abilities["teams"]["can_create"]
|
||||
|
||||
|
||||
class TeamInvitationCreationPermission(IsAuthenticated):
|
||||
"""Permission class that allows only team owners and admins to perform actions."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user is authenticated and has required role for the team."""
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
# Only check roles for edition operations
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
team_id = view.kwargs.get("team_id")
|
||||
if not team_id:
|
||||
return False
|
||||
|
||||
team_access = models.TeamAccess.objects.filter(
|
||||
team_id=team_id,
|
||||
user=request.user,
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
).exists()
|
||||
|
||||
return team_access
|
||||
|
||||
@@ -14,6 +14,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"created_at",
|
||||
"depth",
|
||||
"is_visible_all_services",
|
||||
"name",
|
||||
"numchild",
|
||||
"path",
|
||||
@@ -50,3 +51,28 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"service_providers": [service_provider],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize invitations."""
|
||||
|
||||
class Meta:
|
||||
model = models.Invitation
|
||||
fields = ["id", "created_at", "email", "team", "role", "issuer", "is_expired"]
|
||||
read_only_fields = ["id", "created_at", "team", "issuer", "is_expired"]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Fill team and issuer from request."""
|
||||
is_team_available_for_service_provider = models.Team.objects.filter(
|
||||
id=self.context["team_id"],
|
||||
service_providers__audience_id=self.context[
|
||||
"from_service_provider_audience"
|
||||
],
|
||||
).exists()
|
||||
if not is_team_available_for_service_provider:
|
||||
raise serializers.ValidationError({"team": "Team not found."})
|
||||
|
||||
attrs["team_id"] = self.context["team_id"]
|
||||
attrs["issuer"] = self.context["request"].user # User is authenticated
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -103,7 +103,8 @@ class TeamViewSet( # pylint: disable=too-many-ancestors
|
||||
for d in depth_path
|
||||
),
|
||||
),
|
||||
service_providers__audience_id=service_provider_audience,
|
||||
Q(service_providers__audience_id=service_provider_audience)
|
||||
| Q(is_visible_all_services=True),
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role for the team
|
||||
# and if the user does not have access, it's ok to consider them as a member
|
||||
@@ -123,3 +124,79 @@ class TeamViewSet( # pylint: disable=too-many-ancestors
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
|
||||
class InvitationViewset( # pylint: disable=too-many-ancestors
|
||||
ResourceServerMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""API ViewSet for user invitations to team via resource server.
|
||||
|
||||
GET /resource-server/v1.0/teams/<team_id>/invitations/:<invitation_id>/
|
||||
Return list of invitations related to that team or one
|
||||
team access if an id is provided.
|
||||
|
||||
POST /resource-server/v1.0/teams/<team_id>/invitations/ with expected data:
|
||||
- email: str
|
||||
- role: str [owner|admin|member]
|
||||
- issuer : User, automatically added from user making query, if allowed
|
||||
- team : Team, automatically added from requested URI
|
||||
Return newly created invitation
|
||||
|
||||
PUT / PATCH : Not permitted. Instead of updating your invitation,
|
||||
delete and create a new one.
|
||||
|
||||
DELETE /resource-server/v1.0/teams/<team_id>/invitations/<invitation_id>/
|
||||
Delete targeted invitation
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
serializer_class = serializers.InvitationSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set specific permissions based on the action."""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
elif self.action == "create":
|
||||
permission_classes = [permissions.TeamInvitationCreationPermission]
|
||||
else:
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["team_id"] = self.kwargs["team_id"]
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
service_provider_audience = self._get_service_provider_audience()
|
||||
|
||||
# Determine which role the logged-in user has in the team
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=self.kwargs["team_id"]
|
||||
).values("role")[:1]
|
||||
|
||||
queryset = (
|
||||
models.Invitation.objects.select_related("team")
|
||||
.filter(
|
||||
team=self.kwargs["team_id"],
|
||||
# The logged-in user should be part of a team to see its accesses
|
||||
team__accesses__user=self.request.user,
|
||||
# The team should be accessible by the service provider audience
|
||||
team__service_providers__audience_id=service_provider_audience,
|
||||
)
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -1,11 +1,69 @@
|
||||
"""People Core application"""
|
||||
# from django.apps import AppConfig
|
||||
# from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command, get_commands
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.utils.io import TeeStringIO
|
||||
|
||||
from people.celery_app import app as celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# class CoreConfig(AppConfig):
|
||||
# """Configuration class for the People core app."""
|
||||
def _register_management_commands_as_task():
|
||||
"""
|
||||
Register management command which are enabled via MANAGEMENT_COMMAND_AS_TASK setting.
|
||||
"""
|
||||
for command_name in settings.MANAGEMENT_COMMAND_AS_TASK:
|
||||
# Check if the command is a valid management command
|
||||
try:
|
||||
app_name = get_commands()[command_name]
|
||||
except KeyError:
|
||||
logging.error("Command %s is not a valid management command.", command_name)
|
||||
continue
|
||||
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("People core application")
|
||||
command_full_name = ".".join([app_name, command_name])
|
||||
|
||||
# Create a closure to capture the current value of command_full_name and command_name
|
||||
def create_task(cmd_name, cmd_full_name):
|
||||
@celery_app.task(name=cmd_full_name)
|
||||
def task_wrapper(*command_args, **command_options):
|
||||
stdout = TeeStringIO(logging.getLogger(cmd_full_name).info)
|
||||
stderr = TeeStringIO(logging.getLogger(cmd_full_name).error)
|
||||
|
||||
call_command(
|
||||
cmd_name,
|
||||
*command_args,
|
||||
no_color=True,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
**command_options,
|
||||
)
|
||||
|
||||
stdout.seek(0)
|
||||
stderr.seek(0)
|
||||
return {
|
||||
"stdout": str(stdout.read()),
|
||||
"stderr": str(stderr.read()),
|
||||
}
|
||||
|
||||
return task_wrapper
|
||||
|
||||
# Create the task with the current values
|
||||
create_task(command_name, command_full_name)
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
"""Configuration class for the People core app."""
|
||||
|
||||
name = "core"
|
||||
app_label = "core"
|
||||
verbose_name = _("People core application")
|
||||
|
||||
def ready(self):
|
||||
"""Run when the application is ready."""
|
||||
_register_management_commands_as_task()
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"""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,
|
||||
@@ -23,109 +23,52 @@ from core.models import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def get_domain_from_email(email: Optional[str]) -> Optional[str]:
|
||||
"""Extract domain from email."""
|
||||
try:
|
||||
return Address(addr_spec=email).domain
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User model, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
dict: A dictionary of extra claims.
|
||||
|
||||
"""
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Create a new user if no match is found.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
full_name = self.compute_full_name(user_info)
|
||||
email = user_info.get("email")
|
||||
|
||||
claims = {
|
||||
"sub": sub,
|
||||
"email": email,
|
||||
"name": full_name,
|
||||
}
|
||||
extra_claims = super().get_extra_claims(user_info)
|
||||
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
|
||||
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
extra_claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = (
|
||||
user_info.get(settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD)
|
||||
)
|
||||
return extra_claims
|
||||
|
||||
# if sub is absent, try matching on email
|
||||
user = self.get_existing_user(sub, email)
|
||||
def post_get_or_create_user(self, user, claims):
|
||||
"""
|
||||
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.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
|
||||
"""
|
||||
# Data cleaning, to be removed when user organization is null=False
|
||||
# or all users have an organization.
|
||||
# See https://github.com/numerique-gouv/people/issues/504
|
||||
# See https://github.com/suitenumerique/people/issues/504
|
||||
if not user.organization_id:
|
||||
organization_registration_id = claims.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
)
|
||||
domain = get_domain_from_email(email)
|
||||
domain = get_domain_from_email(claims["email"])
|
||||
try:
|
||||
organization, organization_created = (
|
||||
Organization.objects.get_or_create_from_user_claims(
|
||||
@@ -152,8 +95,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")
|
||||
@@ -165,8 +106,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:
|
||||
@@ -186,13 +128,8 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
logger.info("Creating user %s / %s", sub, email)
|
||||
|
||||
user = self.UserModel.objects.create(
|
||||
organization=organization,
|
||||
password="!", # noqa: S106
|
||||
sub=sub,
|
||||
email=email,
|
||||
name=name,
|
||||
)
|
||||
user = super().create_user(claims | {"organization": organization})
|
||||
|
||||
if organization_created:
|
||||
# Warning: we may remove this behavior in the near future when we
|
||||
# add a feature to claim the organization ownership.
|
||||
@@ -216,33 +153,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 mozzila_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
urlpatterns = [
|
||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
||||
path(
|
||||
"logout-callback/",
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozzila_oidc_urls,
|
||||
]
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
from mozilla_django_oidc.utils import (
|
||||
absolutify,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
|
||||
Adds support for handling logout callbacks from the identity provider (OP)
|
||||
by initiating the logout flow if the user has an active session.
|
||||
|
||||
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
|
||||
This parameter is crucial for maintaining the integrity of the logout flow between this call
|
||||
and the subsequent callback.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def persist_state(request, state):
|
||||
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
|
||||
|
||||
This method is used to store the OIDC state parameter in the session, according to the
|
||||
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
|
||||
utility function.
|
||||
"""
|
||||
|
||||
if "oidc_states" not in request.session or not isinstance(
|
||||
request.session["oidc_states"], dict
|
||||
):
|
||||
request.session["oidc_states"] = {}
|
||||
|
||||
request.session["oidc_states"][state] = {}
|
||||
request.session.save()
|
||||
|
||||
def construct_oidc_logout_url(self, request):
|
||||
"""Create the redirect URL for interfacing with the OIDC provider.
|
||||
|
||||
Retrieves the necessary parameters from the session and constructs the URL
|
||||
required to initiate logout with the OpenID Connect provider.
|
||||
|
||||
If no ID token is found in the session, the logout flow will not be initiated,
|
||||
and the method will return the default redirect URL.
|
||||
|
||||
The 'state' parameter is generated randomly and persisted in the session to ensure
|
||||
its integrity during the subsequent callback.
|
||||
"""
|
||||
|
||||
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
|
||||
|
||||
if not oidc_logout_endpoint:
|
||||
return self.redirect_url
|
||||
|
||||
reverse_url = reverse("oidc_logout_callback")
|
||||
id_token = request.session.get("oidc_id_token", None)
|
||||
|
||||
if not id_token:
|
||||
return self.redirect_url
|
||||
|
||||
query = {
|
||||
"id_token_hint": id_token,
|
||||
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
|
||||
"post_logout_redirect_uri": absolutify(request, reverse_url),
|
||||
}
|
||||
|
||||
self.persist_state(request, query["state"])
|
||||
|
||||
return f"{oidc_logout_endpoint}?{urlencode(query)}"
|
||||
|
||||
def post(self, request):
|
||||
"""Handle user logout.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, constructs the OIDC logout URL and redirects the user to start
|
||||
the logout process.
|
||||
|
||||
If the user is redirected to the default logout URL, ensure her Django session
|
||||
is terminated.
|
||||
"""
|
||||
|
||||
logout_url = self.redirect_url
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logout_url = self.construct_oidc_logout_url(request)
|
||||
|
||||
# If the user is not redirected to the OIDC provider, ensure logout
|
||||
if logout_url == self.redirect_url:
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(logout_url)
|
||||
|
||||
|
||||
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
|
||||
|
||||
Handles the callback after logout from the identity provider (OP).
|
||||
Verifies the state parameter and performs necessary logout actions.
|
||||
|
||||
The Django session is maintained during the logout process to ensure the integrity
|
||||
of the logout flow initiated in the previous step.
|
||||
"""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request):
|
||||
"""Handle the logout callback.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, verifies the state parameter and performs necessary logout actions.
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
state = request.GET.get("state")
|
||||
|
||||
if state not in request.session.get("oidc_states", {}):
|
||||
msg = "OIDC callback state not found in session `oidc_states`!"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
del request.session["oidc_states"][state]
|
||||
request.session.save()
|
||||
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-ancestors
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
@@ -240,7 +240,7 @@ class TeamWebhookFactory(factory.django.DjangoModelFactory):
|
||||
model = models.TeamWebhook
|
||||
|
||||
team = factory.SubFactory(TeamFactory)
|
||||
url = factory.Sequence(lambda n: f"https://example.com/Groups/{n!s}")
|
||||
url = factory.Sequence(lambda n: f"https://nosuchdomain.xyz/Groups/{n!s}")
|
||||
|
||||
|
||||
class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
@@ -277,3 +277,13 @@ class ServiceProviderFactory(factory.django.DjangoModelFactory):
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.organizations.set(extracted)
|
||||
|
||||
|
||||
class AccountServiceFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create account services for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.AccountService
|
||||
|
||||
name = factory.Sequence(lambda n: f"Account Service {n!s}")
|
||||
api_key = factory.Faker("uuid4")
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Organization Metadata",
|
||||
"properties": {
|
||||
"is_public_service": {
|
||||
"type": "boolean",
|
||||
"title": "Is public service"
|
||||
},
|
||||
"is_commune": {
|
||||
"type": "boolean",
|
||||
"title": "Is commune"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Management commands for core app."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Management commands for core app."""
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Management command for filling organization metadata with default values.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core import models
|
||||
from core.utils.json_schema import generate_default_from_schema
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Management command to fill organization metadata with default values."""
|
||||
|
||||
help = "Fill organization metadata with default values"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Fill organizations metadata missing values with default values."""
|
||||
organization_metadata_schema = models.get_organization_metadata_schema()
|
||||
|
||||
if not organization_metadata_schema:
|
||||
message = "No organization metadata schema defined."
|
||||
self.stdout.write(self.style.ERROR(message))
|
||||
return
|
||||
|
||||
default_metadata = generate_default_from_schema(organization_metadata_schema)
|
||||
for organization in models.Organization.objects.all():
|
||||
organization.metadata = {**default_metadata, **organization.metadata}
|
||||
|
||||
# Save the organization with the updated metadata
|
||||
# We don't use bulk update because we want to trigger the clean method
|
||||
organization.save(update_fields=["metadata", "updated_at"])
|
||||
|
||||
message = "Organization metadata filled with default values."
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Management command to list all plugins and their hooks."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.plugins.registry import registry
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Management command to list all plugins and their hooks."""
|
||||
|
||||
help = "List all plugins and their hooks"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Print plugin information."""
|
||||
self.stdout.write(self.style.NOTICE("# Listing plugins\n"))
|
||||
for plugin_app in settings.INSTALLED_PLUGINS:
|
||||
self.stdout.write(self.style.NOTICE(f" - {plugin_app}\n"))
|
||||
|
||||
self.stdout.write(self.style.NOTICE("# Listing loaded hooks\n"))
|
||||
for hook_name, callbacks in registry.get_registered_hooks():
|
||||
self.stdout.write(self.style.NOTICE(f" - {hook_name}: {callbacks}\n"))
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-11 13:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_team_depth_team_numchild_team_path_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='is_visible_all_services',
|
||||
field=models.BooleanField(default=False, help_text='Whether this team is visible to all service providers.', verbose_name='is visible for all SP'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-10 14:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_team_is_visible_all_services'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='A JSON object containing the organization metadata', verbose_name='metadata'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-27 10:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_organization_metadata'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True, verbose_name='active'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-03 20:37
|
||||
|
||||
import core.models
|
||||
import django.contrib.postgres.fields
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_organization_is_active'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccountService',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('api_key', models.CharField(default='Y9-IThtCrKASOvRAKO2MUBd_XvZrnSTGNMMlv-Z1_o8', max_length=255, verbose_name='api key')),
|
||||
('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, validators=[core.models.validate_account_service_scope]), help_text='Allowed scopes for this service', size=None, verbose_name='allowed scopes')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Account service',
|
||||
'verbose_name_plural': 'Account services',
|
||||
'db_table': 'people_account_service',
|
||||
},
|
||||
),
|
||||
]
|
||||
+197
-49
@@ -1,15 +1,18 @@
|
||||
"""
|
||||
Declare and configure the models for the People core application
|
||||
"""
|
||||
# pylint: disable=too-many-lines import-outside-toplevel
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import smtplib
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
from logging import getLogger
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
@@ -29,18 +32,44 @@ 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
|
||||
from core.plugins.registry import registry as plugin_hooks_registry
|
||||
from core.utils.webhooks import scim_synchronizer
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
contact_schema_path = os.path.join(current_dir, "jsonschema", "contact_data.json")
|
||||
with open(contact_schema_path, "r", encoding="utf-8") as contact_schema_file:
|
||||
contact_schema = json.load(contact_schema_file)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_organization_metadata_schema() -> Optional[dict]:
|
||||
"""Load the organization metadata schema from the settings."""
|
||||
if not settings.ORGANIZATION_METADATA_SCHEMA:
|
||||
logger.info("No organization metadata schema specified")
|
||||
return None
|
||||
|
||||
organization_metadata_schema_path = os.path.join(
|
||||
current_dir,
|
||||
"jsonschema",
|
||||
settings.ORGANIZATION_METADATA_SCHEMA,
|
||||
)
|
||||
with open(
|
||||
organization_metadata_schema_path,
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as organization_metadata_schema_file:
|
||||
organization_metadata_schema = json.load(organization_metadata_schema_file)
|
||||
|
||||
logger.info(
|
||||
"Loaded organization metadata schema from %s", organization_metadata_schema_path
|
||||
)
|
||||
return organization_metadata_schema
|
||||
|
||||
|
||||
class RoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
|
||||
"""Defines the possible roles a user can have in a team."""
|
||||
|
||||
@@ -294,7 +323,23 @@ 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
|
||||
|
||||
|
||||
class OrganizationAccessManager(models.Manager):
|
||||
"""
|
||||
Custom manager for the OrganizationAccess model, to manage complexity/automation.
|
||||
"""
|
||||
|
||||
def create(self, **kwargs):
|
||||
"""
|
||||
Create an organization access with the given kwargs.
|
||||
|
||||
This method is overridden to call the Organization plugins.
|
||||
"""
|
||||
instance = super().create(**kwargs)
|
||||
plugin_hooks_registry.execute_hook("organization_access_granted", instance)
|
||||
return instance
|
||||
|
||||
|
||||
@@ -337,11 +382,19 @@ class Organization(BaseModel):
|
||||
# list overlap validation is done in the validate_unique method
|
||||
)
|
||||
|
||||
metadata = models.JSONField(
|
||||
_("metadata"),
|
||||
help_text=_("A JSON object containing the organization metadata"),
|
||||
blank=True,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
service_providers = models.ManyToManyField(
|
||||
ServiceProvider,
|
||||
related_name="organizations",
|
||||
blank=True,
|
||||
)
|
||||
is_active = models.BooleanField(verbose_name=_("active"), default=True)
|
||||
|
||||
objects = OrganizationManager()
|
||||
|
||||
@@ -367,6 +420,22 @@ class Organization(BaseModel):
|
||||
def __str__(self):
|
||||
return f"{self.name} (# {self.pk})"
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
organization_metadata_schema = get_organization_metadata_schema()
|
||||
if not organization_metadata_schema:
|
||||
return
|
||||
|
||||
try:
|
||||
jsonschema.validate(self.metadata, organization_metadata_schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
# Specify the property in the data in which the error occurred
|
||||
field_path = ".".join(map(str, e.path))
|
||||
error_message = f"Validation error in '{field_path:s}': {e.message}"
|
||||
raise exceptions.ValidationError({"metadata": [error_message]}) from e
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""
|
||||
Validate Registration/Domain values in an array field are unique
|
||||
@@ -493,11 +562,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
If it's a new user, give her access to the relevant teams.
|
||||
If it's a new user, give them access to the relevant teams.
|
||||
"""
|
||||
|
||||
if self._state.adding:
|
||||
self._convert_valid_invitations()
|
||||
self._convert_valid_team_invitations()
|
||||
# a post_save signal to convert domain invitations to domain accesses
|
||||
# is triggered by the mailbox_manager app
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -507,7 +578,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
if self.email:
|
||||
self.email = User.objects.normalize_email(self.email)
|
||||
|
||||
def _convert_valid_invitations(self):
|
||||
def _convert_valid_team_invitations(self):
|
||||
"""
|
||||
Convert valid invitations to team accesses.
|
||||
Expired invitations are ignored.
|
||||
@@ -618,6 +689,8 @@ class OrganizationAccess(BaseModel):
|
||||
default=OrganizationRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
objects = OrganizationAccessManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "people_organization_access"
|
||||
verbose_name = _("Organization/user relation")
|
||||
@@ -653,7 +726,7 @@ class TeamManager(MP_NodeManager):
|
||||
return self.model.add_root(**kwargs)
|
||||
|
||||
# Retrieve parent object, because django-treebeard uses raw queries for most
|
||||
# write operations, and raw queries don’t update the django objects of the db
|
||||
# write operations, and raw queries don't update the django objects of the db
|
||||
# entries they modify. See caveats in the django-treebeard documentation.
|
||||
# This might be changed later if we never do any operation on the parent object
|
||||
# before creating the child.
|
||||
@@ -697,6 +770,11 @@ class Team(MP_Node, BaseModel):
|
||||
related_name="teams",
|
||||
blank=True,
|
||||
)
|
||||
is_visible_all_services = models.BooleanField(
|
||||
_("is visible for all SP"),
|
||||
default=False,
|
||||
help_text=_("Whether this team is visible to all service providers."),
|
||||
)
|
||||
|
||||
objects = TeamManager()
|
||||
|
||||
@@ -874,36 +952,21 @@ class TeamWebhook(BaseModel):
|
||||
return headers
|
||||
|
||||
|
||||
class Invitation(BaseModel):
|
||||
"""User invitation to teams."""
|
||||
class BaseInvitation(BaseModel):
|
||||
"""Abstract base invitation model, surcharged for teams or domains."""
|
||||
|
||||
email = models.EmailField(_("email address"), null=False, blank=False)
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||
)
|
||||
issuer = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_invitation"
|
||||
verbose_name = _("Team invitation")
|
||||
verbose_name_plural = _("Team invitations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["email", "team"], name="email_and_team_unique_together"
|
||||
)
|
||||
]
|
||||
MAIL_TEMPLATE_HTML = None
|
||||
MAIL_TEMPLATE_TXT = None
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email} invited to {self.team}"
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Make invitations read-only."""
|
||||
@@ -932,6 +995,79 @@ class Invitation(BaseModel):
|
||||
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||
return timezone.now() > (self.created_at + validity_duration)
|
||||
|
||||
def _get_mail_subject(self):
|
||||
"""Get the subject of the invitation."""
|
||||
return gettext("Invitation to join La Régie!")
|
||||
|
||||
def _get_mail_context(self):
|
||||
"""Get the template variables for the invitation."""
|
||||
return {
|
||||
"site": Site.objects.get_current(),
|
||||
}
|
||||
|
||||
def email_invitation(self):
|
||||
"""Email invitation to the user."""
|
||||
try:
|
||||
with override(self.issuer.language):
|
||||
subject = self._get_mail_subject()
|
||||
context = self._get_mail_context()
|
||||
msg_html = render_to_string(self.MAIL_TEMPLATE_HTML, context)
|
||||
msg_plain = render_to_string(self.MAIL_TEMPLATE_TXT, context)
|
||||
mail.send_mail(
|
||||
subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[self.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", self.email, exception)
|
||||
|
||||
|
||||
class Invitation(BaseInvitation):
|
||||
"""User invitation to teams."""
|
||||
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||
)
|
||||
|
||||
MAIL_TEMPLATE_HTML = "mail/html/team_invitation.html"
|
||||
MAIL_TEMPLATE_TXT = "mail/text/team_invitation.txt"
|
||||
|
||||
class Meta:
|
||||
db_table = "people_invitation"
|
||||
verbose_name = _("Team invitation")
|
||||
verbose_name_plural = _("Team invitations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["email", "team"], name="email_and_team_unique_together"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email} invited to {self.team}"
|
||||
|
||||
def _get_mail_subject(self):
|
||||
"""Get the subject of the team invitation."""
|
||||
return gettext(
|
||||
"[La Suite] You have been invited to become a %(role)s of a group"
|
||||
) % {"role": self.get_role_display().lower()}
|
||||
|
||||
def _get_mail_context(self):
|
||||
"""Get the template variables for the invitation."""
|
||||
return {
|
||||
**super()._get_mail_context(),
|
||||
"team": self.team.name,
|
||||
"role": self.get_role_display().lower(),
|
||||
}
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user."""
|
||||
can_delete = False
|
||||
@@ -957,25 +1093,37 @@ class Invitation(BaseModel):
|
||||
"put": False,
|
||||
}
|
||||
|
||||
def email_invitation(self):
|
||||
"""Email invitation to the user."""
|
||||
try:
|
||||
with override(self.issuer.language):
|
||||
subject = gettext("Invitation to join La Régie!")
|
||||
template_vars = {
|
||||
"title": subject,
|
||||
"site": Site.objects.get_current(),
|
||||
}
|
||||
msg_html = render_to_string("mail/html/invitation.html", template_vars)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||
mail.send_mail(
|
||||
subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[self.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", self.email, exception)
|
||||
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,
|
||||
default=secrets.token_urlsafe(32),
|
||||
)
|
||||
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
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
"""Indicate if the account service is authenticated."""
|
||||
return True
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
"""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.
|
||||
"""
|
||||
from .registry import registry # pylint: disable=import-outside-toplevel
|
||||
|
||||
registry.register_app(self.name) # pylint: disable=no-member
|
||||
|
||||
@@ -1,32 +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)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Registry for hooks."""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Dict, List, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HooksRegistry:
|
||||
"""Registry for hooks."""
|
||||
|
||||
_available_hooks = {
|
||||
"organization_created",
|
||||
"organization_access_granted",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the registry."""
|
||||
self._hooks: Dict[str, List[Callable]] = {
|
||||
hook_name: [] for hook_name in self._available_hooks
|
||||
}
|
||||
self._registered_apps: Set[str] = set()
|
||||
|
||||
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
||||
"""
|
||||
Register a hook callback.
|
||||
|
||||
Args:
|
||||
hook_name: The name of the hook.
|
||||
callback: The callback function to register.
|
||||
"""
|
||||
try:
|
||||
self._hooks[hook_name].append(callback)
|
||||
except KeyError as exc:
|
||||
logger.exception(
|
||||
"Failed to register hook '%s' is not a valid hook: %s", hook_name, exc
|
||||
)
|
||||
logger.info("Registered hook %s: %s", hook_name, callback)
|
||||
|
||||
def get_registered_hooks(self):
|
||||
"""Get all registered hooks."""
|
||||
return self._hooks.items()
|
||||
|
||||
def register_app(self, app_name: str) -> None:
|
||||
"""
|
||||
Register an app as having hooks.
|
||||
|
||||
Args:
|
||||
app_name: The name of the app.
|
||||
"""
|
||||
if app_name in self._registered_apps:
|
||||
return
|
||||
|
||||
self._registered_apps.add(app_name)
|
||||
try:
|
||||
# Try to import the hooks module from the app
|
||||
__import__(f"{app_name}.hooks")
|
||||
logger.info("Registered hooks from app: %s", app_name)
|
||||
except ImportError:
|
||||
# It's okay if the app doesn't have a hooks module
|
||||
logger.info("App %s has no hooks module", app_name)
|
||||
|
||||
def get_callbacks(self, hook_name: str) -> List[Callable]:
|
||||
"""
|
||||
Get all callbacks for a hook.
|
||||
|
||||
Args:
|
||||
hook_name: The name of the hook.
|
||||
|
||||
Returns:
|
||||
A list of callback functions.
|
||||
"""
|
||||
try:
|
||||
return self._hooks[hook_name]
|
||||
except KeyError as exc:
|
||||
logger.exception(
|
||||
"Failed to get callbacks for hook '%s' is not a valid hook: %s",
|
||||
hook_name,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
|
||||
def execute_hook(self, hook_name: str, *args, **kwargs):
|
||||
"""
|
||||
Execute all callbacks for a hook.
|
||||
|
||||
Args:
|
||||
hook_name: The name of the hook.
|
||||
*args: Positional arguments to pass to the callbacks.
|
||||
**kwargs: Keyword arguments to pass to the callbacks.
|
||||
|
||||
Returns:
|
||||
A list of results from the callbacks.
|
||||
"""
|
||||
results = []
|
||||
for callback in self.get_callbacks(hook_name):
|
||||
try:
|
||||
result = callback(*args, **kwargs)
|
||||
results.append(result)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.exception("Error executing hook %s: %s", hook_name, e)
|
||||
return results
|
||||
|
||||
def reset(self):
|
||||
"""Function to reset the registry, to be used in test only."""
|
||||
self._hooks = {hook_name: [] for hook_name in self._available_hooks}
|
||||
self._registered_apps = set()
|
||||
|
||||
|
||||
# Create a singleton instance of the registry
|
||||
registry = HooksRegistry()
|
||||
|
||||
|
||||
def register_hook(hook_name: str):
|
||||
"""
|
||||
Decorator to register a function as a hook callback.
|
||||
|
||||
Args:
|
||||
hook_name: The name of the hook.
|
||||
|
||||
Returns:
|
||||
A decorator function.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
registry.register_hook(hook_name, func)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,16 @@
|
||||
"""URLs for plugins"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
plugins_urlpatterns = []
|
||||
|
||||
# Try to import and include URLs from each installed plugin
|
||||
for app in settings.INSTALLED_PLUGINS:
|
||||
try:
|
||||
plugins_urlpatterns.append(path("", include(f"{app}.urls")))
|
||||
except (ImportError, AttributeError):
|
||||
# Skip if app doesn't have urls.py
|
||||
continue
|
||||
|
||||
urlpatterns = plugins_urlpatterns
|
||||
@@ -1 +0,0 @@
|
||||
"""Backend resource server module."""
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Resource Server Authentication"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from mozilla_django_oidc.contrib.drf import OIDCAuthentication
|
||||
|
||||
from .backend import ResourceServerBackend, ResourceServerImproperlyConfiguredBackend
|
||||
from .clients import AuthorizationServerClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceServerAuthentication(OIDCAuthentication):
|
||||
"""Authenticate clients using the token received from the authorization server."""
|
||||
|
||||
def __init__(self):
|
||||
"""Require authentication to be configured in order to instantiate."""
|
||||
super().__init__()
|
||||
|
||||
try:
|
||||
authorization_server_client = AuthorizationServerClient(
|
||||
url=settings.OIDC_OP_URL,
|
||||
verify_ssl=settings.OIDC_VERIFY_SSL,
|
||||
timeout=settings.OIDC_TIMEOUT,
|
||||
proxy=settings.OIDC_PROXY,
|
||||
url_jwks=settings.OIDC_OP_JWKS_ENDPOINT,
|
||||
url_introspection=settings.OIDC_OP_INTROSPECTION_ENDPOINT,
|
||||
)
|
||||
self.backend = ResourceServerBackend(authorization_server_client)
|
||||
|
||||
except ImproperlyConfigured as err:
|
||||
message = "Resource Server authentication is disabled"
|
||||
logger.debug("%s. Exception: %s", message, err)
|
||||
self.backend = ResourceServerImproperlyConfiguredBackend()
|
||||
|
||||
def get_access_token(self, request):
|
||||
"""Retrieve and decode the access token from the request.
|
||||
|
||||
This method overrides the 'get_access_token' method from the parent class,
|
||||
to support service providers that would base64 encode the bearer token.
|
||||
"""
|
||||
|
||||
access_token = super().get_access_token(request)
|
||||
|
||||
try:
|
||||
access_token = base64.b64decode(access_token).decode("utf-8")
|
||||
except (binascii.Error, TypeError):
|
||||
pass
|
||||
|
||||
return access_token
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the request and return a tuple of (user, token) or None.
|
||||
|
||||
We override the 'authenticate' method from the parent class to store
|
||||
the introspected token audience inside the request.
|
||||
"""
|
||||
result = super().authenticate(request) # Might raise AuthenticationFailed
|
||||
|
||||
if result is None: # Case when there is no access token
|
||||
return None
|
||||
|
||||
# Note: at this stage, the request is a "drf_request" object
|
||||
request.resource_server_token_audience = self.backend.token_origin_audience
|
||||
|
||||
return result
|
||||
@@ -1,240 +0,0 @@
|
||||
"""Resource Server Backend"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.errors import InvalidClaimError, InvalidTokenError
|
||||
from requests.exceptions import HTTPError
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from . import utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceServerBackend:
|
||||
"""Backend of an OAuth 2.0 resource server.
|
||||
|
||||
This backend is designed to authenticate resource owners to our API using the access token
|
||||
they received from the authorization server.
|
||||
|
||||
In the context of OAuth 2.0, a resource server is a server that hosts protected resources and
|
||||
is capable of accepting and responding to protected resource requests using access tokens.
|
||||
The resource server verifies the validity of the access tokens issued by the authorization
|
||||
server to ensure secure access to the resources.
|
||||
|
||||
For more information, visit: https://www.oauth.com/oauth2-servers/the-resource-server/
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
def __init__(self, authorization_server_client):
|
||||
"""Require client_id, client_secret set and authorization_server_client provided."""
|
||||
# pylint: disable=invalid-name
|
||||
self.UserModel = auth.get_user_model()
|
||||
|
||||
self._client_id = settings.OIDC_RS_CLIENT_ID
|
||||
self._client_secret = settings.OIDC_RS_CLIENT_SECRET
|
||||
self._encryption_encoding = settings.OIDC_RS_ENCRYPTION_ENCODING
|
||||
self._encryption_algorithm = settings.OIDC_RS_ENCRYPTION_ALGO
|
||||
self._signing_algorithm = settings.OIDC_RS_SIGNING_ALGO
|
||||
self._scopes = settings.OIDC_RS_SCOPES
|
||||
|
||||
if (
|
||||
not self._client_id
|
||||
or not self._client_secret
|
||||
or not authorization_server_client
|
||||
):
|
||||
raise ImproperlyConfigured(
|
||||
"Could not instantiate ResourceServerBackend, some parameters are missing."
|
||||
)
|
||||
|
||||
self._authorization_server_client = authorization_server_client
|
||||
|
||||
self._claims_registry = jose_jwt.JWTClaimsRegistry(
|
||||
iss={"essential": True, "value": self._authorization_server_client.url},
|
||||
aud={"essential": True, "value": self._client_id},
|
||||
token_introspection={"essential": True},
|
||||
)
|
||||
|
||||
# Declare the token origin audience: to know where the token comes from
|
||||
# and store it for further use in the application
|
||||
self.token_origin_audience = None
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Maintain API compatibility with OIDCAuthentication class from mozilla-django-oidc
|
||||
|
||||
Params 'id_token', 'payload' won't be used, and our implementation will only
|
||||
support 'get_user', not 'get_or_create_user'.
|
||||
"""
|
||||
|
||||
return self.get_user(access_token)
|
||||
|
||||
def get_user(self, access_token):
|
||||
"""Get user from an access token emitted by the authorization server.
|
||||
|
||||
This method will submit the access token to the authorization server for
|
||||
introspection, to ensure its validity and obtain the associated metadata.
|
||||
|
||||
It follows the specifications outlined in RFC7662 https://www.rfc-editor.org/info/rfc7662,
|
||||
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
|
||||
|
||||
In our eGovernment applications, the standard RFC 7662 doesn't provide sufficient security.
|
||||
Its introspection response is a plain JSON object. Therefore, we use the draft RFC
|
||||
that extends RFC 7662 by returning a signed and encrypted JWT for stronger assurance that
|
||||
the authorization server issued the token introspection response.
|
||||
"""
|
||||
self.token_origin_audience = None # Reset the token origin audience
|
||||
|
||||
jwt = self._introspect(access_token)
|
||||
claims = self._verify_claims(jwt)
|
||||
user_info = self._verify_user_info(claims["token_introspection"])
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if sub is None:
|
||||
message = "User info contained no recognizable user identification"
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
try:
|
||||
user = self.UserModel.objects.get(sub=sub)
|
||||
except self.UserModel.DoesNotExist:
|
||||
logger.debug("Login failed: No user with %s found", sub)
|
||||
return None
|
||||
|
||||
self.token_origin_audience = str(user_info["aud"])
|
||||
|
||||
return user
|
||||
|
||||
def _verify_user_info(self, introspection_response):
|
||||
"""Verify the 'introspection_response' to get valid and relevant user info.
|
||||
|
||||
The 'introspection_response' should be still active, and while authenticating
|
||||
the resource owner should have requested relevant scope to access her data in
|
||||
our resource server.
|
||||
|
||||
Scope should be configured to match between the AS and the RS. The AS will filter
|
||||
all the scopes the resource owner requested to expose only the relevant ones to
|
||||
our resource server.
|
||||
"""
|
||||
|
||||
active = introspection_response.get("active", None)
|
||||
|
||||
if not active:
|
||||
message = "Introspection response is not active."
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
requested_scopes = introspection_response.get("scope", None).split(" ")
|
||||
if set(self._scopes).isdisjoint(set(requested_scopes)):
|
||||
message = "Introspection response contains any required scopes."
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
audience = introspection_response.get("aud", None)
|
||||
if not audience:
|
||||
raise SuspiciousOperation(
|
||||
"Introspection response does not provide source audience."
|
||||
)
|
||||
|
||||
return introspection_response
|
||||
|
||||
def _introspect(self, token):
|
||||
"""Introspect an access token to the authorization server."""
|
||||
try:
|
||||
jwe = self._authorization_server_client.get_introspection(
|
||||
self._client_id,
|
||||
self._client_secret,
|
||||
token,
|
||||
)
|
||||
except HTTPError as err:
|
||||
message = "Could not fetch introspection"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
private_key = utils.import_private_key_from_settings()
|
||||
jws = self._decrypt(jwe, private_key=private_key)
|
||||
|
||||
try:
|
||||
public_key_set = self._authorization_server_client.import_public_keys()
|
||||
except (TypeError, ValueError, AttributeError, HTTPError) as err:
|
||||
message = "Could get authorization server JWKS"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
jwt = self._decode(jws, public_key_set)
|
||||
|
||||
return jwt
|
||||
|
||||
def _decrypt(self, encrypted_token, private_key):
|
||||
"""Decrypt the token encrypted by the Authorization Server (AS).
|
||||
|
||||
Resource Server (RS)'s public key is used for encryption, and its private
|
||||
key is used for decryption. The RS's public key is exposed to the AS via a JWKS endpoint.
|
||||
Encryption Algorithm and Encoding should be configured to match between the AS
|
||||
and the RS.
|
||||
"""
|
||||
|
||||
try:
|
||||
decrypted_token = jose_jwe.decrypt_compact(
|
||||
encrypted_token,
|
||||
private_key,
|
||||
algorithms=[self._encryption_algorithm, self._encryption_encoding],
|
||||
)
|
||||
except Exception as err:
|
||||
message = "Token decryption failed"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return decrypted_token
|
||||
|
||||
def _decode(self, encoded_token, public_key_set):
|
||||
"""Decode the token signed by the Authorization Server (AS).
|
||||
|
||||
AS's private key is used for signing, and its public key is used for decoding.
|
||||
The AS public key is exposed via a JWK endpoint.
|
||||
Signing Algorithm should be configured to match between the AS and the RS.
|
||||
"""
|
||||
try:
|
||||
token = jose_jwt.decode(
|
||||
encoded_token.plaintext,
|
||||
public_key_set,
|
||||
algorithms=[self._signing_algorithm],
|
||||
)
|
||||
except ValueError as err:
|
||||
message = "Token decoding failed"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return token
|
||||
|
||||
def _verify_claims(self, token):
|
||||
"""Verify the claims of the token to ensure authentication security.
|
||||
|
||||
By verifying these claims, we ensure that the token was issued by a
|
||||
trusted authorization server and is intended for this specific
|
||||
resource server. This prevents various types of attacks, such as
|
||||
token substitution or misuse of tokens issued for different clients.
|
||||
"""
|
||||
try:
|
||||
self._claims_registry.validate(token.claims)
|
||||
except (InvalidClaimError, InvalidTokenError) as err:
|
||||
message = "Failed to validate token's claims"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return token.claims
|
||||
|
||||
|
||||
class ResourceServerImproperlyConfiguredBackend:
|
||||
"""Fallback backend for improperly configured Resource Servers."""
|
||||
|
||||
token_origin_audience = None
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Indicate that the Resource Server is improperly configured."""
|
||||
raise AuthenticationFailed("Resource Server is improperly configured")
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Resource Server Clients classes"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import requests
|
||||
from joserfc.jwk import KeySet
|
||||
|
||||
|
||||
class AuthorizationServerClient:
|
||||
"""Client for interacting with an OAuth 2.0 authorization server.
|
||||
|
||||
An authorization server issues access tokens to client applications after authenticating
|
||||
and obtaining authorization from the resource owner. It also provides endpoints for token
|
||||
introspection and JSON Web Key Sets (JWKS) to validate and decode tokens.
|
||||
|
||||
This client facilitates communication with the authorization server, including:
|
||||
- Fetching token introspection responses.
|
||||
- Fetching JSON Web Key Sets (JWKS) for token validation.
|
||||
- Setting appropriate headers for secure communication as recommended by RFC drafts.
|
||||
"""
|
||||
|
||||
# ruff: noqa: PLR0913 PLR0917
|
||||
# pylint: disable=too-many-positional-arguments
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
url,
|
||||
url_jwks,
|
||||
url_introspection,
|
||||
verify_ssl,
|
||||
timeout,
|
||||
proxy,
|
||||
):
|
||||
"""Require at a minimum url, url_jwks and url_introspection."""
|
||||
|
||||
if not url or not url_jwks or not url_introspection:
|
||||
raise ImproperlyConfigured(
|
||||
"Could not instantiate AuthorizationServerClient, some parameters are missing."
|
||||
)
|
||||
|
||||
self.url = url
|
||||
self._url_introspection = url_introspection
|
||||
self._url_jwks = url_jwks
|
||||
self._verify_ssl = verify_ssl
|
||||
self._timeout = timeout
|
||||
self._proxy = proxy
|
||||
|
||||
@property
|
||||
def _introspection_headers(self):
|
||||
"""Get HTTP header for the introspection request.
|
||||
|
||||
Notify the authorization server that we expect a signed and encrypted response
|
||||
by setting the appropriate 'Accept' header.
|
||||
|
||||
This follows the recommendation from the draft RFC:
|
||||
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
|
||||
"""
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/token-introspection+jwt",
|
||||
}
|
||||
|
||||
def get_introspection(self, client_id, client_secret, token):
|
||||
"""Retrieve introspection response about a token."""
|
||||
response = requests.post(
|
||||
self._url_introspection,
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"token": token,
|
||||
},
|
||||
headers=self._introspection_headers,
|
||||
verify=self._verify_ssl,
|
||||
timeout=self._timeout,
|
||||
proxies=self._proxy,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
def get_jwks(self):
|
||||
"""Retrieve Authorization Server JWKS."""
|
||||
response = requests.get(
|
||||
self._url_jwks,
|
||||
verify=self._verify_ssl,
|
||||
timeout=self._timeout,
|
||||
proxies=self._proxy,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def import_public_keys(self):
|
||||
"""Retrieve and import Authorization Server JWKS."""
|
||||
|
||||
jwks = self.get_jwks()
|
||||
public_keys = KeySet.import_key_set(jwks)
|
||||
|
||||
return public_keys
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
Mixins for resource server views.
|
||||
"""
|
||||
|
||||
from rest_framework import exceptions as drf_exceptions
|
||||
|
||||
from .authentication import ResourceServerAuthentication
|
||||
|
||||
|
||||
class ResourceServerMixin:
|
||||
"""
|
||||
Mixin for resource server views:
|
||||
- Restrict the authentication class to ResourceServerAuthentication.
|
||||
- Adds the Service Provider ID to the serializer context.
|
||||
- Fetch the Service Provider ID from the OIDC introspected token stored
|
||||
in the request.
|
||||
|
||||
This Mixin *must* be used in every view that should act as a resource server.
|
||||
"""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# When used as a resource server, we need to know the audience to automatically:
|
||||
# - add the Service Provider to the team "scope" on creation
|
||||
context["from_service_provider_audience"] = (
|
||||
self._get_service_provider_audience()
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def _get_service_provider_audience(self):
|
||||
"""Return the audience of the Service Provider from the OIDC introspected token."""
|
||||
if not isinstance(
|
||||
self.request.successful_authenticator, ResourceServerAuthentication
|
||||
):
|
||||
# We could check request.resource_server_token_audience here, but it's
|
||||
# more explicit to check the authenticator type and assert the attribute
|
||||
# existence.
|
||||
return None
|
||||
|
||||
# When used as a resource server, the request has a token audience
|
||||
service_provider_audience = self.request.resource_server_token_audience
|
||||
|
||||
if not service_provider_audience: # should not happen
|
||||
raise drf_exceptions.AuthenticationFailed(
|
||||
"Resource server token audience not found in request"
|
||||
)
|
||||
|
||||
return service_provider_audience
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Resource Server URL Configuration"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from .views import JWKSView
|
||||
|
||||
urlpatterns = [
|
||||
path("jwks", JWKSView.as_view(), name="resource_server_jwks"),
|
||||
]
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Resource Server utils functions"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from joserfc.jwk import JWKRegistry
|
||||
|
||||
|
||||
def import_private_key_from_settings():
|
||||
"""Import the private key used by the resource server when interacting with the OIDC provider.
|
||||
|
||||
This private key is crucial; its public components are exposed in the JWK endpoints,
|
||||
while its private component is used for decrypting the introspection token retrieved
|
||||
from the OIDC provider.
|
||||
|
||||
By default, we recommend using RSAKey for asymmetric encryption,
|
||||
known for its strong security features.
|
||||
|
||||
Note:
|
||||
- The function requires the 'OIDC_RS_PRIVATE_KEY_STR' setting to be configured.
|
||||
- The 'OIDC_RS_ENCRYPTION_KEY_TYPE' and 'OIDC_RS_ENCRYPTION_ALGO' settings can be customized
|
||||
based on the chosen key type.
|
||||
|
||||
Raises:
|
||||
ImproperlyConfigured: If the private key setting is missing, empty, or incorrect.
|
||||
|
||||
Returns:
|
||||
joserfc.jwk.JWK: The imported private key as a JWK object.
|
||||
"""
|
||||
|
||||
private_key_str = getattr(settings, "OIDC_RS_PRIVATE_KEY_STR", None)
|
||||
if not private_key_str:
|
||||
raise ImproperlyConfigured(
|
||||
"OIDC_RS_PRIVATE_KEY_STR setting is missing or empty."
|
||||
)
|
||||
|
||||
private_key_pem = private_key_str.encode()
|
||||
|
||||
try:
|
||||
private_key = JWKRegistry.import_key(
|
||||
private_key_pem,
|
||||
key_type=settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
parameters={"alg": settings.OIDC_RS_ENCRYPTION_ALGO, "use": "enc"},
|
||||
)
|
||||
except ValueError as err:
|
||||
raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is wrong.") from err
|
||||
|
||||
return private_key
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Resource Server views"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from joserfc.jwk import KeySet
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from . import utils
|
||||
|
||||
|
||||
class JWKSView(APIView):
|
||||
"""
|
||||
API endpoint for retrieving a JSON Web Keys Set (JWKS).
|
||||
|
||||
Returns:
|
||||
Response: JSON response containing the JWKS data.
|
||||
"""
|
||||
|
||||
authentication_classes = [] # disable authentication
|
||||
permission_classes = [] # disable permission
|
||||
|
||||
def get(self, request):
|
||||
"""Handle GET requests to retrieve JSON Web Keys Set (JWKS).
|
||||
|
||||
Returns:
|
||||
Response: JSON response containing the JWKS data.
|
||||
"""
|
||||
|
||||
try:
|
||||
private_key = utils.import_private_key_from_settings()
|
||||
except (ImproperlyConfigured, ValueError) as err:
|
||||
return Response({"error": str(err)}, status=500)
|
||||
|
||||
try:
|
||||
jwk = KeySet([private_key]).as_dict(private=False)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return Response({"error": "Could not load key"}, status=500)
|
||||
|
||||
return Response(jwk)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@@ -2,11 +2,16 @@
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory, override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from core import factories, models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.authentication.backends import (
|
||||
AccountServiceAuthentication,
|
||||
OIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
User = get_user_model()
|
||||
@@ -155,7 +160,7 @@ 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(3): # user by email + user by sub + update sub
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -233,7 +238,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
|
||||
|
||||
|
||||
@@ -453,3 +458,60 @@ def test_authentication_getter_existing_user_with_registration_id(
|
||||
|
||||
assert user.organization is not None
|
||||
assert user.organization.registration_id_list == ["12345678901234"]
|
||||
|
||||
|
||||
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
|
||||
def test_account_service_authenticate_valid_api_key():
|
||||
"""Test the authenticate method with a valid API key."""
|
||||
request = RequestFactory().get("/")
|
||||
account_service = factories.AccountServiceFactory(
|
||||
name="test_service",
|
||||
api_key="test_api_key_123",
|
||||
scopes=["la-suite-list-organizations-siret"],
|
||||
)
|
||||
request.headers = {"Authorization": f"ApiKey {account_service.api_key}"}
|
||||
|
||||
result = AccountServiceAuthentication().authenticate(request)
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == account_service
|
||||
assert result[1] == account_service.api_key
|
||||
|
||||
|
||||
def test_account_service_authenticate_missing_api_key():
|
||||
"""Test the authenticate method with a missing API key."""
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {}
|
||||
|
||||
result = AccountServiceAuthentication().authenticate(request)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_account_service_authenticate_invalid_api_key():
|
||||
"""Test the authenticate method with an invalid API key."""
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "ApiKey invalid_key"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
AccountServiceAuthentication().authenticate(request)
|
||||
|
||||
|
||||
@override_settings(ACCOUNT_SERVICE_SCOPES=["la-suite-list-organizations-siret"])
|
||||
def test_account_service_authenticate_invalid_header():
|
||||
"""Test the authenticate method with an invalid header."""
|
||||
request = RequestFactory().get("/")
|
||||
account_service = factories.AccountServiceFactory(
|
||||
name="test_service",
|
||||
api_key="test_api_key_123",
|
||||
scopes=["la-suite-list-organizations-siret"],
|
||||
)
|
||||
request.headers = {"Authorization": f"Bearer {account_service.api_key}"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
AccountServiceAuthentication().authenticate(request)
|
||||
|
||||
request.headers = {"Authorization": account_service.api_key}
|
||||
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
AccountServiceAuthentication().authenticate(request)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Unit tests for the Authentication URLs."""
|
||||
|
||||
from core.authentication.urls import urlpatterns
|
||||
|
||||
|
||||
def test_urls_override_default_mozilla_django_oidc():
|
||||
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
|
||||
|
||||
url_names = [u.name for u in urlpatterns]
|
||||
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_anonymous():
|
||||
"""Anonymous users calling the logout url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
||||
)
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = client.get(url)
|
||||
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
||||
)
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
response = client.get(url)
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/default-redirect-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback_anonymous():
|
||||
"""Anonymous users calling the logout callback url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_oidc_states",
|
||||
[{}, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_oidc_states:
|
||||
request.session["oidc_states"] = initial_oidc_states
|
||||
request.session.save()
|
||||
|
||||
mocked_state = "mock_state"
|
||||
|
||||
OIDCLogoutView().persist_state(request, mocked_state)
|
||||
|
||||
assert "oidc_states" in request.session
|
||||
assert request.session["oidc_states"] == {
|
||||
"mock_state": {},
|
||||
**initial_oidc_states,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
||||
def test_view_logout_construct_oidc_logout_url(
|
||||
mocked_get_random_string, mocked_persist_state
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
||||
request.session.save()
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
mocked_persist_state.assert_called_once()
|
||||
mocked_get_random_string.assert_called_once()
|
||||
|
||||
params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
||||
assert params["state"][0] == "mocked_state"
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
assert url in params["post_logout_redirect_uri"][0]
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
||||
def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
assert redirect_url == "/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[None, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_state:
|
||||
request.session["oidc_states"] = initial_state
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
||||
callback_view(request)
|
||||
|
||||
assert (
|
||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
|
||||
request.session["oidc_states"] = {mocked_state: {}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
|
||||
def clear_user(request):
|
||||
# Assert state is cleared prior to logout
|
||||
assert request.session["oidc_states"] == {}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
mock_logout.side_effect = clear_user
|
||||
response = callback_view(request)
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
@@ -0,0 +1 @@
|
||||
"""Test module for the JSON schema validation."""
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Test module for the JSON schema validation."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_all_json_schemas_load_correctly(settings):
|
||||
"""Test that all JSON schema files in the jsonschema directory load correctly."""
|
||||
# Get the base directory for jsonschema files
|
||||
schema_dir = os.path.join(settings.BASE_DIR, "core", "jsonschema")
|
||||
|
||||
# List to store any errors encountered
|
||||
errors = []
|
||||
loaded_schemas = 0
|
||||
|
||||
# Walk through the jsonschema directory and its subdirectories
|
||||
for root, _, files in os.walk(schema_dir):
|
||||
for file in files:
|
||||
if file.endswith(".json"):
|
||||
schema_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(schema_path, schema_dir)
|
||||
|
||||
try:
|
||||
# Try to load the schema
|
||||
with open(schema_path, "r", encoding="utf-8") as schema_file:
|
||||
schema = json.load(schema_file)
|
||||
|
||||
# Verify it's a dictionary (basic schema validation)
|
||||
assert isinstance(schema, dict), (
|
||||
f"Schema in {rel_path} is not a dictionary"
|
||||
)
|
||||
|
||||
# Check for common schema properties
|
||||
if "$schema" not in schema:
|
||||
errors.append(
|
||||
f"Warning: {rel_path} does not contain a $schema property"
|
||||
)
|
||||
|
||||
loaded_schemas += 1
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f"Failed to decode {rel_path}: {e}")
|
||||
except Exception as e: # noqa: BLE001 pylint: disable=broad-except
|
||||
errors.append(f"Error loading {rel_path}: {e}")
|
||||
|
||||
# Ensure we found and loaded at least one schema
|
||||
assert loaded_schemas > 0, "No JSON schema files were found"
|
||||
|
||||
# If any errors were encountered, fail the test
|
||||
if errors:
|
||||
pytest.fail("\n".join(errors))
|
||||
@@ -0,0 +1 @@
|
||||
"""Test for management commands for core app."""
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for the fill_organization_metadata management command."""
|
||||
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.management import call_command
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(name="command_output")
|
||||
def command_output_fixture():
|
||||
"""Capture command output."""
|
||||
out = StringIO()
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fill_organization_metadata_no_schema(command_output):
|
||||
"""Test command behavior when no schema is available."""
|
||||
organization_1 = factories.OrganizationFactory(
|
||||
name="Org with empty metadata",
|
||||
metadata={},
|
||||
with_registration_id=True,
|
||||
)
|
||||
organization_2 = factories.OrganizationFactory(
|
||||
name="Org with partial metadata",
|
||||
metadata={"existing_key": "existing_value"},
|
||||
with_registration_id=True,
|
||||
)
|
||||
|
||||
# Mock the schema function to return None (no schema)
|
||||
with patch("core.models.get_organization_metadata_schema") as mock_get_schema:
|
||||
mock_get_schema.return_value = None
|
||||
|
||||
# Call the command
|
||||
call_command("fill_organization_metadata", stdout=command_output)
|
||||
|
||||
# Check the command output
|
||||
assert "No organization metadata schema defined" in command_output.getvalue()
|
||||
|
||||
organization_1.refresh_from_db()
|
||||
assert organization_1.metadata == {}
|
||||
|
||||
organization_2.refresh_from_db()
|
||||
assert organization_2.metadata == {"existing_key": "existing_value"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"existing_metadata,expected_result",
|
||||
[
|
||||
({}, {"field1": "default_value"}), # Empty metadata gets defaults
|
||||
({"field1": "custom"}, {"field1": "custom"}), # Existing values preserved
|
||||
(
|
||||
{"other_field": "value"},
|
||||
{"other_field": "value", "field1": "default_value"},
|
||||
), # Mixed case
|
||||
],
|
||||
)
|
||||
def test_metadata_merging_scenarios(existing_metadata, expected_result):
|
||||
"""Test various metadata merging scenarios."""
|
||||
# Create a simple schema with one field
|
||||
simple_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field1": {"type": "string", "default": "default_value"},
|
||||
},
|
||||
}
|
||||
|
||||
# Create an organization with the specified metadata
|
||||
organization = factories.OrganizationFactory(
|
||||
name="Test organization",
|
||||
metadata=existing_metadata,
|
||||
with_registration_id=True,
|
||||
)
|
||||
|
||||
# Mock the schema function to return our simple schema
|
||||
with patch("core.models.get_organization_metadata_schema") as mock_get_schema:
|
||||
mock_get_schema.return_value = simple_schema
|
||||
|
||||
# Call the command
|
||||
call_command("fill_organization_metadata")
|
||||
|
||||
# Refresh from DB and check
|
||||
organization.refresh_from_db()
|
||||
|
||||
# Check that the metadata has been merged correctly
|
||||
for key, value in expected_result.items():
|
||||
assert organization.metadata[key] == value
|
||||
@@ -11,16 +11,35 @@ from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.rfc7518.rsa_key import RSAKey
|
||||
from jwt.utils import to_base64url_uint
|
||||
from 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
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ def test_api_teams_create_authenticated_new_service_provider(
|
||||
assert response.json() == {
|
||||
"created_at": team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"id": str(team.pk),
|
||||
"is_visible_all_services": False,
|
||||
"depth": team.depth,
|
||||
"name": "my team",
|
||||
"numchild": team.numchild,
|
||||
@@ -164,3 +165,46 @@ def test_api_teams_create_cannot_override_service_provider(
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.service_providers.get().audience_id == service_provider.audience_id
|
||||
|
||||
|
||||
def test_api_teams_create_is_visible_all_services_team(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams visible in all service providers.
|
||||
"""
|
||||
user = UserFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
"is_visible_all_services": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.is_visible_all_services is True
|
||||
|
||||
|
||||
def test_api_teams_create_restricted_team(client, force_login_via_resource_server):
|
||||
"""Authenticated users should be able to create teams restricted to some service providers."""
|
||||
user = UserFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.is_visible_all_services is False
|
||||
|
||||
@@ -82,6 +82,7 @@ def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
|
||||
"created_at": team_1.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_1.depth,
|
||||
"id": str(team_1.pk),
|
||||
"is_visible_all_services": False,
|
||||
"name": team_1.name,
|
||||
"numchild": team_1.numchild,
|
||||
"path": team_1.path,
|
||||
@@ -91,6 +92,7 @@ def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
|
||||
"created_at": team_2.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_2.depth,
|
||||
"id": str(team_2.pk),
|
||||
"is_visible_all_services": False,
|
||||
"name": team_2.name,
|
||||
"numchild": team_2.numchild,
|
||||
"path": team_2.path,
|
||||
@@ -100,6 +102,7 @@ def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
|
||||
"created_at": team_3.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_3.depth,
|
||||
"id": str(team_3.pk),
|
||||
"is_visible_all_services": False,
|
||||
"name": team_3.name,
|
||||
"numchild": team_3.numchild,
|
||||
"path": team_3.path,
|
||||
@@ -109,6 +112,7 @@ def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
|
||||
"created_at": team_4.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_4.depth,
|
||||
"id": str(team_4.pk),
|
||||
"is_visible_all_services": False,
|
||||
"name": team_4.name,
|
||||
"numchild": team_4.numchild,
|
||||
"path": team_4.path,
|
||||
@@ -273,3 +277,48 @@ def test_api_teams_list_with_parent_teams_other_organization(
|
||||
team_ids = [team["id"] for team in response_data["results"]]
|
||||
assert len(team_ids) == 1
|
||||
assert set(team_ids) == {str(second_team.id)}
|
||||
|
||||
|
||||
def test_api_teams_list_is_visible_all_services_teams(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to see teams
|
||||
if they are associated with another requesting service provider.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
other_service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
# Create a public team visible to the requesting service provider
|
||||
service_team = factories.TeamFactory(
|
||||
is_visible_all_services=False,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
factories.TeamAccessFactory(user=user, team=service_team, role="member")
|
||||
|
||||
# Create a public team visible to another service provider (should be listed)
|
||||
other_public_team = factories.TeamFactory(
|
||||
is_visible_all_services=True,
|
||||
service_providers=[other_service_provider],
|
||||
)
|
||||
factories.TeamAccessFactory(user=user, team=other_public_team, role="member")
|
||||
|
||||
# Create a public team visible to the requesting service provider (should not be listed)
|
||||
private_team = factories.TeamFactory(
|
||||
is_visible_all_services=False,
|
||||
service_providers=[other_service_provider],
|
||||
)
|
||||
factories.TeamAccessFactory(user=user, team=private_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get("/resource-server/v1.0/teams/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data["count"] == 2
|
||||
|
||||
team_ids = [team["id"] for team in response_data["results"]]
|
||||
assert len(team_ids) == 2
|
||||
assert str(service_team.id) in team_ids
|
||||
assert str(other_public_team.id) in team_ids
|
||||
|
||||
@@ -69,6 +69,7 @@ def test_api_teams_retrieve_authenticated_related(
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 1,
|
||||
"id": str(team.id),
|
||||
"is_visible_all_services": False,
|
||||
"name": team.name,
|
||||
"numchild": 0,
|
||||
"path": team.path,
|
||||
@@ -143,6 +144,7 @@ def test_api_teams_retrieve_authenticated_related_parent_same_organization(
|
||||
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 2,
|
||||
"id": str(first_team.pk),
|
||||
"is_visible_all_services": False,
|
||||
"name": first_team.name,
|
||||
"numchild": 1,
|
||||
"path": first_team.path,
|
||||
@@ -229,3 +231,27 @@ def test_api_teams_retrieve_authenticated_related_child_same_organization(
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_is_visible_all_services_team(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to retrieve teams even
|
||||
if associated with the another requesting service provider.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
other_service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
public_team = factories.TeamFactory(
|
||||
is_visible_all_services=True,
|
||||
service_providers=[other_service_provider],
|
||||
)
|
||||
TeamAccessFactory(user=user, team=public_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(f"/resource-server/v1.0/teams/{public_team.id}/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["id"] == str(public_team.id)
|
||||
|
||||
@@ -336,3 +336,57 @@ def test_api_teams_update_child_team(client, force_login_via_resource_server, ro
|
||||
|
||||
second_team.refresh_from_db()
|
||||
assert second_team.name == "Second"
|
||||
|
||||
|
||||
def test_api_teams_update_is_visible_all_services_status(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Team administrators should be able to change the visibility status of their team."""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, "administrator")],
|
||||
service_providers=[service_provider],
|
||||
is_visible_all_services=True,
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.patch(
|
||||
f"/resource-server/v1.0/teams/{team.id}/",
|
||||
{
|
||||
"is_visible_all_services": False,
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
team.refresh_from_db()
|
||||
assert team.is_visible_all_services is False
|
||||
|
||||
|
||||
def test_api_teams_update_public_status_as_member(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Team members should not be able to change the visibility status of their team."""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, "member")],
|
||||
service_providers=[service_provider],
|
||||
is_visible_all_services=True,
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.patch(
|
||||
f"/resource-server/v1.0/teams/{team.id}/",
|
||||
{
|
||||
"is_visible_all_services": False,
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
team.refresh_from_db()
|
||||
assert team.is_visible_all_services is True
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the resource server Team Invitation API endpoints."""
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: create
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_201_CREATED,
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import (
|
||||
ServiceProviderFactory,
|
||||
TeamAccessFactory,
|
||||
TeamFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.models import Invitation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_invitations_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create team invitation."""
|
||||
response = APIClient().post(
|
||||
"/resource-server/v1.0/teams/ca143ed4-f83d-11ef-a8c5-af2e53ad69fb/invitations/",
|
||||
{
|
||||
"email": "toto@example.com",
|
||||
"role": "member",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert not Invitation.objects.exists()
|
||||
|
||||
|
||||
def test_api_teams_invitations_create_authenticated_outsider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Users outside of team should not be permitted to invite to team."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
{
|
||||
"email": "new@example.com",
|
||||
"role": "member",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert not Invitation.objects.exists()
|
||||
|
||||
|
||||
def test_api_teams_invitations_create_authenticated_member(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Team members should not be permitted to create invitations."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
{
|
||||
"email": "new@example.com",
|
||||
"role": "member",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert not Invitation.objects.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_teams_invitations_create_authenticated_privileged(
|
||||
client, force_login_via_resource_server, role
|
||||
):
|
||||
"""Owners and administrators should be able to create invitations."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role=role)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
{
|
||||
"email": "new@example.com",
|
||||
"role": "member",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
invitation = Invitation.objects.get()
|
||||
assert response.json() == {
|
||||
"id": str(invitation.id),
|
||||
"created_at": invitation.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"email": "new@example.com",
|
||||
"team": str(team.id),
|
||||
"role": "member",
|
||||
"issuer": str(user.id),
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_invitations_create_duplicate_email(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Should not be able to invite the same email twice."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="administrator")
|
||||
|
||||
# Create first invitation
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
client.post(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
{
|
||||
"email": "new@example.com",
|
||||
"role": "member",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
# Try to create duplicate invitation
|
||||
response = client.post(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
{
|
||||
"email": "new@example.com",
|
||||
"role": "member",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert Invitation.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_teams_invitations_create_wrong_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Should not create invitation when accessing with wrong service provider."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
wrong_service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="administrator")
|
||||
|
||||
with force_login_via_resource_server(
|
||||
client, user, wrong_service_provider.audience_id
|
||||
):
|
||||
response = client.post(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
{
|
||||
"email": "new@example.com",
|
||||
"role": "member",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json() == {"team": ["Team not found."]}
|
||||
assert not Invitation.objects.exists()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Tests for Teams Invitations API endpoint in People's core app: delete
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_204_NO_CONTENT,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import (
|
||||
InvitationFactory,
|
||||
ServiceProviderFactory,
|
||||
TeamAccessFactory,
|
||||
TeamFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.models import Invitation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_invitations_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to delete team invitations."""
|
||||
invitation = InvitationFactory()
|
||||
team = invitation.team
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert Invitation.objects.filter(id=invitation.id).exists()
|
||||
|
||||
|
||||
def test_api_teams_invitations_delete_authenticated_outsider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Users outside of team should not be permitted to delete team invitations."""
|
||||
user = UserFactory()
|
||||
invitation = InvitationFactory()
|
||||
team = invitation.team
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.delete(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Invitation matches the given query."}
|
||||
assert Invitation.objects.filter(id=invitation.id).exists()
|
||||
|
||||
|
||||
def test_api_teams_invitations_delete_authenticated_member(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Team members should not be permitted to delete invitations."""
|
||||
user = UserFactory()
|
||||
invitation = InvitationFactory()
|
||||
team = invitation.team
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.delete(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert Invitation.objects.filter(id=invitation.id).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_teams_invitations_delete_authenticated_privileged(
|
||||
client, force_login_via_resource_server, role
|
||||
):
|
||||
"""Owners and administrators should be able to delete invitations."""
|
||||
user = UserFactory()
|
||||
invitation = InvitationFactory()
|
||||
team = invitation.team
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role=role)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.delete(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_204_NO_CONTENT
|
||||
assert not Invitation.objects.filter(id=invitation.id).exists()
|
||||
|
||||
|
||||
def test_api_teams_invitations_delete_nonexistent(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Should return 404 when trying to delete non-existent invitation."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="administrator")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.delete(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/nonexistent-uuid/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_teams_invitations_delete_wrong_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Should not delete invitation when accessing with wrong service provider."""
|
||||
user = UserFactory()
|
||||
invitation = InvitationFactory()
|
||||
team = invitation.team
|
||||
service_provider = ServiceProviderFactory()
|
||||
wrong_service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="administrator")
|
||||
|
||||
with force_login_via_resource_server(
|
||||
client, user, wrong_service_provider.audience_id
|
||||
):
|
||||
response = client.delete(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert Invitation.objects.filter(id=invitation.id).exists()
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Tests for Teams Invitations API endpoint in People's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import (
|
||||
InvitationFactory,
|
||||
ServiceProviderFactory,
|
||||
TeamAccessFactory,
|
||||
TeamFactory,
|
||||
UserFactory,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_invitations_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list team invitations."""
|
||||
team = TeamFactory()
|
||||
InvitationFactory.create_batch(size=3, team=team)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_teams_invitations_list_authenticated_outsider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Users outside of team should not be permitted to list team invitations."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
InvitationFactory.create_batch(size=3, team=team)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["member", "administrator", "owner"])
|
||||
def test_api_teams_invitations_list_authenticated_team_member(
|
||||
client, force_login_via_resource_server, role
|
||||
):
|
||||
"""Team members should be able to list invitations."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role=role)
|
||||
|
||||
invitations = InvitationFactory.create_batch(size=3, team=team)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["count"] == 3
|
||||
assert len(data["results"]) == 3
|
||||
|
||||
# Check invitations are ordered by creation date (most recent first)
|
||||
assert [item["id"] for item in data["results"]] == [
|
||||
str(invitation.id) for invitation in reversed(invitations)
|
||||
]
|
||||
|
||||
|
||||
def test_api_teams_invitations_list_pagination(client, force_login_via_resource_server):
|
||||
"""Should properly paginate results."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="member")
|
||||
|
||||
InvitationFactory.create_batch(size=15, team=team)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
{"page_size": 10},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["count"] == 15
|
||||
assert len(data["results"]) == 10
|
||||
assert data["next"] is not None
|
||||
assert data["previous"] is None
|
||||
|
||||
|
||||
def test_api_teams_invitations_list_empty(client, force_login_via_resource_server):
|
||||
"""Should return empty list when no invitations exist."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["count"] == 0
|
||||
assert len(data["results"]) == 0
|
||||
|
||||
|
||||
def test_api_teams_invitations_list_wrong_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Should not list invitations when accessing with wrong service provider."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
wrong_service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="member")
|
||||
|
||||
InvitationFactory.create_batch(size=3, team=team)
|
||||
|
||||
with force_login_via_resource_server(
|
||||
client, user, wrong_service_provider.audience_id
|
||||
):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Tests for Teams Invitations API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_404_NOT_FOUND,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import (
|
||||
InvitationFactory,
|
||||
ServiceProviderFactory,
|
||||
TeamAccessFactory,
|
||||
TeamFactory,
|
||||
UserFactory,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_invitations_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve team invitations."""
|
||||
invitation = InvitationFactory()
|
||||
team = invitation.team
|
||||
|
||||
response = APIClient().get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_teams_invitations_retrieve_authenticated_outsider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Users outside of team should not be permitted to retrieve team invitations."""
|
||||
user = UserFactory()
|
||||
invitation = InvitationFactory()
|
||||
team = invitation.team
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Invitation matches the given query."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["member", "administrator", "owner"])
|
||||
def test_api_teams_invitations_retrieve_authenticated_team_member(
|
||||
client, force_login_via_resource_server, role
|
||||
):
|
||||
"""Team members should be able to retrieve invitations."""
|
||||
user = UserFactory()
|
||||
invitation = InvitationFactory()
|
||||
team = invitation.team
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role=role)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(invitation.id),
|
||||
"created_at": invitation.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"email": invitation.email,
|
||||
"team": str(team.id),
|
||||
"role": invitation.role,
|
||||
"issuer": str(invitation.issuer.id),
|
||||
"is_expired": invitation.is_expired,
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_invitations_retrieve_nonexistent(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Should return 404 when trying to retrieve non-existent invitation."""
|
||||
user = UserFactory()
|
||||
team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/nonexistent-uuid/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_teams_invitations_retrieve_wrong_team(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Should return 404 when trying to retrieve invitation from wrong team."""
|
||||
user = UserFactory()
|
||||
invitation = InvitationFactory()
|
||||
wrong_team = TeamFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
wrong_team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=wrong_team, user=user, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{wrong_team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_teams_invitations_retrieve_wrong_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""Should not retrieve invitation when accessing with wrong service provider."""
|
||||
user = UserFactory()
|
||||
invitation = InvitationFactory()
|
||||
team = invitation.team
|
||||
service_provider = ServiceProviderFactory()
|
||||
wrong_service_provider = ServiceProviderFactory()
|
||||
team.service_providers.add(service_provider)
|
||||
TeamAccessFactory(team=team, user=user, role="member")
|
||||
|
||||
with force_login_via_resource_server(
|
||||
client, user, wrong_service_provider.audience_id
|
||||
):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
@@ -16,6 +16,7 @@ pytestmark = pytest.mark.django_db
|
||||
def test_openapi_client_schema():
|
||||
"""
|
||||
Generated and served OpenAPI client schema should be correct.
|
||||
The spectacular command reloads test env.
|
||||
"""
|
||||
# Start by generating the swagger.json file
|
||||
output = StringIO()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,6 +61,7 @@ def test_api_teams_create_authenticated(settings):
|
||||
assert team.name == "my team"
|
||||
assert team.organization == organization
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
assert team.is_visible_all_services is True
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_feature_disabled(settings):
|
||||
@@ -120,3 +121,32 @@ def test_api_teams_create_cannot_override_organization():
|
||||
assert team.name == "my team"
|
||||
assert team.organization == organization
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
assert team.is_visible_all_services is True
|
||||
|
||||
|
||||
def test_api_teams_create_not_is_visible_all_services():
|
||||
"""
|
||||
Authenticated users should be able to create teams and
|
||||
make is restricted to the services which can view it.
|
||||
"""
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
"is_visible_all_services": False,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.organization == organization
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
assert team.is_visible_all_services is False
|
||||
|
||||
@@ -192,6 +192,7 @@ def test_api_teams_list_authenticated_team_tree(client, role, local_team_abiliti
|
||||
"created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": 3,
|
||||
"id": str(second_team.pk),
|
||||
"is_visible_all_services": False,
|
||||
"name": "Second",
|
||||
"numchild": 1,
|
||||
"path": second_team.path,
|
||||
@@ -211,6 +212,7 @@ def test_api_teams_list_authenticated_team_tree(client, role, local_team_abiliti
|
||||
"created_at": first_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": 2,
|
||||
"id": str(first_team.pk),
|
||||
"is_visible_all_services": False,
|
||||
"name": "First",
|
||||
"numchild": 1,
|
||||
"path": first_team.path,
|
||||
@@ -230,6 +232,7 @@ def test_api_teams_list_authenticated_team_tree(client, role, local_team_abiliti
|
||||
"created_at": root_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": 1,
|
||||
"id": str(root_team.pk),
|
||||
"is_visible_all_services": False,
|
||||
"name": "Root",
|
||||
"numchild": 1,
|
||||
"path": root_team.path,
|
||||
@@ -316,6 +319,7 @@ def test_api_teams_list_authenticated_team_different_organization(
|
||||
"created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": 3,
|
||||
"id": str(second_team.pk),
|
||||
"is_visible_all_services": False,
|
||||
"name": "Second",
|
||||
"numchild": 1,
|
||||
"path": second_team.path,
|
||||
|
||||
@@ -71,6 +71,7 @@ def test_api_teams_retrieve_authenticated_related():
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 1,
|
||||
"id": str(team.id),
|
||||
"is_visible_all_services": False,
|
||||
"name": team.name,
|
||||
"numchild": 0,
|
||||
"path": team.path,
|
||||
@@ -112,6 +113,7 @@ def test_api_teams_retrieve_authenticated_related_parent(client, role):
|
||||
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 2,
|
||||
"id": str(first_team.pk),
|
||||
"is_visible_all_services": False,
|
||||
"name": first_team.name,
|
||||
"numchild": 1,
|
||||
"path": first_team.path,
|
||||
|
||||
@@ -128,6 +128,27 @@ def test_api_teams_update_authenticated_administrators():
|
||||
assert value == new_values[key]
|
||||
|
||||
|
||||
def test_api_teams_update_is_visible_all_services():
|
||||
"""Administrators of a team should be allowed to update the visibility to all services."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, "administrator")], is_visible_all_services=False
|
||||
)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
{"is_visible_all_services": True},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
team.refresh_from_db()
|
||||
assert team.is_visible_all_services is True
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_owners():
|
||||
"""Administrators of a team should be allowed to update it,
|
||||
apart from read-only fields."""
|
||||
|
||||
@@ -19,7 +19,7 @@ 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)
|
||||
|
||||
# clear cache to allow stats count
|
||||
@@ -30,7 +30,7 @@ def test_api_stats__anonymous(django_assert_num_queries):
|
||||
assert response.json() == {
|
||||
"total_users": 0,
|
||||
"mau": 0,
|
||||
"domains": 5,
|
||||
"active_domains": 5,
|
||||
"mailboxes": 0,
|
||||
"teams": 3,
|
||||
}
|
||||
@@ -41,7 +41,7 @@ def test_api_stats__anonymous(django_assert_num_queries):
|
||||
assert response.json() == {
|
||||
"total_users": 0,
|
||||
"mau": 0,
|
||||
"domains": 5,
|
||||
"active_domains": 5,
|
||||
"mailboxes": 0,
|
||||
"teams": 3,
|
||||
}
|
||||
@@ -57,7 +57,8 @@ def test_api_stats__expected_count():
|
||||
client.force_login(user)
|
||||
|
||||
core_factories.TeamFactory.create_batch(3)
|
||||
domains_factories.MailDomainFactory.create_batch(2)
|
||||
domains_factories.MailDomainFactory.create_batch(1)
|
||||
domains_factories.MailDomainEnabledFactory.create_batch(2)
|
||||
domains_factories.MailboxFactory.create_batch(
|
||||
10, domain=domains_models.MailDomain.objects.all()[1]
|
||||
)
|
||||
@@ -69,7 +70,7 @@ def test_api_stats__expected_count():
|
||||
assert response.json() == {
|
||||
"total_users": 10,
|
||||
"mau": 6,
|
||||
"domains": 2,
|
||||
"active_domains": 2,
|
||||
"mailboxes": 10,
|
||||
"teams": 3,
|
||||
}
|
||||
|
||||
@@ -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="")
|
||||
@@ -144,7 +144,7 @@ def test_models_invitation__new_user__filter_expired_invitations():
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 4), (1, 7), (20, 7)])
|
||||
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 5), (1, 8), (20, 8)])
|
||||
def test_models_invitation__new_user__user_creation_constant_num_queries(
|
||||
django_assert_num_queries, num_invitations, num_queries
|
||||
):
|
||||
@@ -241,15 +241,13 @@ def test_models_team_invitations_get_abilities_member():
|
||||
def test_models_team_invitations_email():
|
||||
"""Check email invitation during invitation creation."""
|
||||
|
||||
member_access = factories.TeamAccessFactory(role="member")
|
||||
team = member_access.team
|
||||
team = factories.TeamFactory()
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
factories.TeamAccessFactory(team=team)
|
||||
invitation = factories.InvitationFactory(
|
||||
team=team, email="john@people.com", issuer__language="fr-fr"
|
||||
role="member", team=team, email="john@people.com", issuer__language="fr-fr"
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
@@ -259,10 +257,15 @@ def test_models_team_invitations_email():
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == [invitation.email]
|
||||
assert email.subject == "Invitation à rejoindre La Régie!"
|
||||
assert (
|
||||
email.subject == "[La Suite] Vous avez été invité(e) à être membre d'un groupe"
|
||||
)
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Invitation à rejoindre La Régie!" in email_content
|
||||
assert (
|
||||
f"""Vous avez été invité(e) à être membre du groupe "{team.name}" au sein de la Suite."""
|
||||
in email_content
|
||||
)
|
||||
assert "[//example.com]" in email_content
|
||||
|
||||
|
||||
|
||||
@@ -125,3 +125,77 @@ def test_models_organization_registration_id_validators():
|
||||
name="hi",
|
||||
registration_id_list=["a12345678912345"],
|
||||
)
|
||||
|
||||
|
||||
def test_models_organization_metadata_schema_valid(settings):
|
||||
"""When a schema is provided, valid metadata should pass validation."""
|
||||
settings.ORGANIZATION_METADATA_SCHEMA = "fr/organization_metadata.json"
|
||||
# Clear the cache to reload the schema
|
||||
models.get_organization_metadata_schema.cache_clear()
|
||||
|
||||
organization = models.Organization(
|
||||
name="Valid Metadata Org",
|
||||
registration_id_list=["12345678901234"],
|
||||
metadata={"is_public_service": True, "is_commune": False},
|
||||
)
|
||||
|
||||
# This should not raise any validation errors
|
||||
organization.full_clean()
|
||||
organization.save()
|
||||
|
||||
# Verify the metadata was saved correctly
|
||||
org = models.Organization.objects.get(pk=organization.pk)
|
||||
assert org.metadata["is_public_service"] is True
|
||||
assert org.metadata["is_commune"] is False
|
||||
|
||||
settings.ORGANIZATION_METADATA_SCHEMA = None
|
||||
# Clear the cache to reload the schema
|
||||
models.get_organization_metadata_schema.cache_clear()
|
||||
|
||||
|
||||
def test_models_organization_metadata_schema_invalid(settings):
|
||||
"""When a schema is provided, invalid metadata should fail validation."""
|
||||
settings.ORGANIZATION_METADATA_SCHEMA = "fr/organization_metadata.json"
|
||||
# Clear the cache to reload the schema
|
||||
models.get_organization_metadata_schema.cache_clear()
|
||||
|
||||
# Integer instead of boolean for is_public_service
|
||||
organization = models.Organization(
|
||||
name="Invalid Metadata Org",
|
||||
registration_id_list=["12345678901234"],
|
||||
metadata={"is_public_service": 1, "is_commune": False},
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
organization.full_clean()
|
||||
|
||||
assert "metadata" in str(excinfo.value)
|
||||
assert "is_public_service" in str(excinfo.value)
|
||||
assert "is_commune" not in str(excinfo.value)
|
||||
|
||||
settings.ORGANIZATION_METADATA_SCHEMA = None
|
||||
# Clear the cache to reload the schema
|
||||
models.get_organization_metadata_schema.cache_clear()
|
||||
|
||||
|
||||
def test_models_organization_no_metadata_schema(settings):
|
||||
"""When no schema is provided, any metadata should be allowed."""
|
||||
settings.ORGANIZATION_METADATA_SCHEMA = None
|
||||
# Clear the cache to reload the schema
|
||||
models.get_organization_metadata_schema.cache_clear()
|
||||
|
||||
# Random metadata that wouldn't match the schema
|
||||
organization = models.Organization(
|
||||
name="No Schema Org",
|
||||
registration_id_list=["12345678901234"],
|
||||
metadata={"random_field": "anything", "numeric_value": 123},
|
||||
)
|
||||
|
||||
# This should not raise any validation errors
|
||||
organization.full_clean()
|
||||
organization.save()
|
||||
|
||||
# Verify the metadata was saved correctly
|
||||
org = models.Organization.objects.get(pk=organization.pk)
|
||||
assert org.metadata["random_field"] == "anything"
|
||||
assert org.metadata["numeric_value"] == 123
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user