Compare commits
240 Commits
fix/groups
...
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c54f010989 | |||
| 841a6c27ba | |||
| b060bbea51 | |||
| ac24dd91a7 | |||
| 867c89a2e6 | |||
| 54120eb179 | |||
| cf155dc033 | |||
| e623c32c9a | |||
| a1892d2693 | |||
| 632a96ccf4 | |||
| d7c654e1aa | |||
| 99b6181944 | |||
| 514414de16 | |||
| f1956f4e86 | |||
| f13e2c18b9 | |||
| 4ba1901355 | |||
| e821163269 | |||
| fc9499e211 | |||
| 9dde2fe7e2 | |||
| 5dad700c1c | |||
| f759d318b9 | |||
| 60ab61d125 | |||
| 625f122ad5 | |||
| 6d5d0f7ebb | |||
| b7908801d4 | |||
| 0785c3a5bc | |||
| 51eb3474e4 | |||
| 10be01f6af | |||
| 8e20f86d2c | |||
| 03c79b0412 | |||
| a33822b38d | |||
| edbd1f0061 | |||
| 5692c50f21 | |||
| 6fe4818743 | |||
| 02c6048d2c | |||
| a6f409f6ed | |||
| ccb06b3abf | |||
| 3469764697 | |||
| 892a5f10d6 | |||
| 0357caa75a | |||
| 9785ce295d | |||
| a26e10909d | |||
| d6f1cae9e9 | |||
| 478a3ffbd1 | |||
| 8e6b6318c9 | |||
| b524369add | |||
| a991737a59 | |||
| a041296f8a | |||
| 512d9fe82c | |||
| b205ad1d16 | |||
| 0227231370 | |||
| a57070bfb8 | |||
| 17a1c39dbf | |||
| dfecb83c0a | |||
| 8414a7af4d | |||
| 33b364d386 | |||
| b7f61e73c2 | |||
| a8e3d8d20e | |||
| 43c18cb4e6 | |||
| bbe8f32b96 | |||
| 1e025f034f | |||
| 2f7449f727 | |||
| 863c85e3f0 | |||
| 28a972e19e | |||
| 90a3e26c7f | |||
| 59f3499799 | |||
| 6123e11dd4 | |||
| 6be1b63277 | |||
| ac853299d3 | |||
| 4d3097e322 | |||
| a10f65a51f | |||
| 33e05f7a2d | |||
| 1e45f1ffd1 | |||
| 9c894bdbe9 | |||
| 20a8edd3aa | |||
| 0b0b77cead | |||
| 21bf431940 | |||
| 8f30264445 | |||
| bde91d55da | |||
| a328e16e53 | |||
| edde9c8d15 | |||
| a18f06ed27 | |||
| 72abe04c72 | |||
| 79e92214ab | |||
| e5f1151f58 | |||
| ca886c19b0 | |||
| b602478406 | |||
| 8b0f942f9b | |||
| bd6fe584c6 | |||
| 821db276bc | |||
| 639490d41e | |||
| 748e24cab6 | |||
| 55c0815c31 | |||
| 989239082e | |||
| 988a091e53 | |||
| 20a19d36b5 | |||
| 7d695ab81c | |||
| a42e7a10db | |||
| ad4065e682 | |||
| ababcde0d6 | |||
| faf8dcc7e5 | |||
| 2bbed8de0c | |||
| a736a9143f | |||
| c4ea62dc1f | |||
| 1d1f5cfbb6 | |||
| 3934a0bc28 | |||
| fd3ac00ea7 | |||
| 2d46b7d504 | |||
| 54e5be81e2 | |||
| 31944e8083 | |||
| d1421dbe8c | |||
| c8800259ae | |||
| bfc2462103 | |||
| 230ed75d8d | |||
| 5ac3454c5a | |||
| bbac34b62a | |||
| 38508c272e | |||
| 10a81f14e6 | |||
| a08689a64d | |||
| 30229e11f9 | |||
| d11d1f3bd0 | |||
| 6a22169dae | |||
| 6e7a6e9d51 | |||
| 95fb476041 | |||
| 6b4ea1a2e7 | |||
| be55d6ea09 | |||
| 9c9216bb51 | |||
| 017f52a0dc | |||
| d635c484ae | |||
| 0e48bc0f90 | |||
| f243a2423f | |||
| 763eb254a8 | |||
| 9b613e63a9 | |||
| 6b7f00651f | |||
| fe249c5b6f | |||
| 579657afa4 | |||
| ce21a7552b | |||
| 4a63a26135 | |||
| 8e4472befc | |||
| 315a6ab931 | |||
| 2894b9a999 | |||
| aea15292ee | |||
| de46a50e8d | |||
| e6ed3c3be2 | |||
| f04c8bc6aa | |||
| ae05b430db | |||
| c98d173c0a | |||
| dddc281778 | |||
| bb2279b6d5 | |||
| 5420b20f43 | |||
| fbb2accefb | |||
| 75008d3e9a | |||
| c4c3e9de96 | |||
| 00816e097c | |||
| 976c955126 | |||
| 2967f1bff2 | |||
| a15d548ac4 | |||
| 7c6b3307fa | |||
| 5ded297df6 | |||
| b69ce001c8 | |||
| c0cd136618 | |||
| ee5a785d43 | |||
| 3d7dfa019c | |||
| cc9303bc9c | |||
| 34341e6f74 | |||
| 01abc66e59 | |||
| 55d7e846d8 | |||
| 232ea97d87 | |||
| a7afa68afa | |||
| 932ffb3bcb | |||
| d0254ce963 | |||
| cd1dcf11d5 | |||
| 59468aaa12 | |||
| dd8bd2a89b | |||
| cc86a3bd61 | |||
| 7f31a2b820 | |||
| aaca8819b3 | |||
| 77cc64a6c7 | |||
| aaad48480a | |||
| 00dafd4b15 | |||
| 864702d0ee | |||
| 29904ef7b6 | |||
| ba30b1d3ee | |||
| 9503b073b6 | |||
| e4aed82ff2 | |||
| 25898bbb64 | |||
| b5d8e92d1e | |||
| 237d64b4c5 | |||
| 0d157d3f2b | |||
| d291e55a9e | |||
| e7aebfe59e | |||
| bd6cd59df6 | |||
| 874ce18134 | |||
| 4fe74733a5 | |||
| 4b47f80cab | |||
| ba631fafb9 | |||
| ce15e8a3ed | |||
| 55dc342a8b | |||
| 05c8f636dd | |||
| 675ba4b557 | |||
| f5c7abcd20 | |||
| 8c54e701c1 | |||
| ff2cbe3aed | |||
| 7f3f25c61d | |||
| bd36983e3c | |||
| e8cc6a6f23 | |||
| 9adb2d4cf4 | |||
| bb5058c478 | |||
| 78818ba541 | |||
| f1a2b7c603 | |||
| 5634a7f390 | |||
| 9c05167d80 | |||
| 21371dbd1b | |||
| b40aefc505 | |||
| 591b3eedff | |||
| 09cb7ff6f1 | |||
| 87966fa062 | |||
| e4dd3395bb | |||
| 93681b0030 | |||
| e04a994d37 | |||
| ba46d7de54 | |||
| b79b4b1853 | |||
| e3f8633931 | |||
| b84e8b89f7 | |||
| 32889b9e8c | |||
| 75647fe289 | |||
| 03bfef6061 | |||
| 85c789bb1a | |||
| 049d8695be | |||
| 49c238155c | |||
| b79725acbe | |||
| 439ddb9d4a | |||
| a7a923e790 | |||
| 5ed63fc091 | |||
| f55cb3a813 | |||
| 2c82f38c59 | |||
| 8963f0bb3d | |||
| 733a1d8861 | |||
| 7916f7d7d0 | |||
| 45dbdd6c4c |
@@ -1,4 +1,5 @@
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -15,6 +16,44 @@ env:
|
||||
DOCKER_USER: 1001:127
|
||||
|
||||
jobs:
|
||||
trivy-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
lasuite/people-backend
|
||||
lasuite/people-frontend
|
||||
-
|
||||
name: Run trivy scan (backend)
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/people-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Run trivy scan (frontend)
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target frontend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
|
||||
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -48,9 +87,16 @@ jobs:
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
- name: create-version-json
|
||||
id: create-version-json
|
||||
uses: jsdaniell/create-json@v1.2.3
|
||||
with:
|
||||
name: "version.json"
|
||||
json: '{"source":"${{github.repository}}", "version":"${{github.ref_name}}", "commit":"${{github.sha}}", "build": "NA"}'
|
||||
dir: 'src/backend'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
@@ -88,13 +134,20 @@ jobs:
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
- name: create-version-json
|
||||
id: create-version-json
|
||||
uses: jsdaniell/create-json@v1.2.3
|
||||
with:
|
||||
name: "version.json"
|
||||
json: '{"source":"${{github.repository}}", "version":"${{github.ref_name}}", "commit":"${{github.sha}}", "build": "NA"}'
|
||||
dir: 'src/frontend/apps/desk'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: frontend-production
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "people,secrets"
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 0
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
|
||||
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && yarn lint
|
||||
|
||||
@@ -147,10 +147,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-mails, build-front]
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make create-env-files
|
||||
@@ -175,7 +180,7 @@ jobs:
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
|
||||
- name: Build and Start Docker Servers
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
@@ -183,7 +188,7 @@ jobs:
|
||||
run: |
|
||||
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
make run
|
||||
|
||||
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
@@ -192,19 +197,43 @@ jobs:
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Setup Dimail DB
|
||||
run: |
|
||||
make dimail-setup-db
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test
|
||||
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
|
||||
- name: Save logs
|
||||
if: always()
|
||||
run: docker compose logs > src/frontend/apps/e2e/report/logs.txt
|
||||
|
||||
- name: Save Dimail logs
|
||||
if: always()
|
||||
run: docker compose logs dimail > src/frontend/apps/e2e/report/dimail-logs.txt
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
name: playwright-report-${{ matrix.shardIndex }}
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
|
||||
tests-e2e-feedback:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-e2e]
|
||||
if: always()
|
||||
steps:
|
||||
- name: All tests ok
|
||||
if: ${{ !(contains(needs.*.result, 'failure')) }}
|
||||
run: exit 0
|
||||
- name: Some tests failed
|
||||
if: ${{ contains(needs.*.result, 'failure') }}
|
||||
run: exit 1
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -226,8 +255,8 @@ jobs:
|
||||
- name: Persist mails' templates
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -306,79 +335,3 @@ jobs:
|
||||
run: python manage.py compilemessages
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
|
||||
i18n-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
- name: Install gettext (required to make messages)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install development dependencies
|
||||
working-directory: src/backend
|
||||
run: pip install --user .[dev]
|
||||
|
||||
- name: Generate the translation base file
|
||||
run: ~/.local/bin/django-admin makemessages --keep-pot --all
|
||||
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: src/frontend/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Download sources from Crowdin to stay synchronized
|
||||
run: |
|
||||
docker run \
|
||||
--rm \
|
||||
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
|
||||
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
|
||||
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
|
||||
-v "${{ github.workspace }}:/app" \
|
||||
crowdin/cli:3.16.0 \
|
||||
crowdin download sources -c /app/crowdin/config.yml
|
||||
|
||||
- name: Extract the frontend translation
|
||||
run: make frontend-i18n-extract
|
||||
|
||||
- name: Upload files to Crowdin
|
||||
run: |
|
||||
docker run \
|
||||
--rm \
|
||||
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
|
||||
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
|
||||
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
|
||||
-v "${{ github.workspace }}:/app" \
|
||||
crowdin/cli:3.16.0 \
|
||||
crowdin upload sources -c /app/crowdin/config.yml
|
||||
|
||||
@@ -42,7 +42,6 @@ env.bak/
|
||||
venv.bak/
|
||||
env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/terraform
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
@@ -59,11 +58,6 @@ src/frontend/tsclient
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Terraform
|
||||
.terraform
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
|
||||
# Test & lint
|
||||
.coverage
|
||||
coverage.json
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
creation_rules:
|
||||
- path_regex: ./*
|
||||
key_groups:
|
||||
- age:
|
||||
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
|
||||
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
|
||||
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
|
||||
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
|
||||
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
|
||||
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
|
||||
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
|
||||
+216
@@ -7,3 +7,219 @@ and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.8.0] - 2024-12-12
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(dimail) automate allows requests to dimail
|
||||
- ✨(contacts) add notes & force full_name #565
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(contacts) move user profile to contact #572
|
||||
- ♻️(contacts) split api test module in actions #573
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) fix manage roles on domain admin view
|
||||
- 🐛(mailbox) fix activate mailbox feature
|
||||
- 🔧(helm) fix the configuration environment #579
|
||||
|
||||
## [1.7.1] - 2024-11-28
|
||||
|
||||
## [1.7.0] - 2024-11-28
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(mailbox) allow to activate mailbox
|
||||
- ✨(mailbox) allow to disable mailbox
|
||||
- ✨(backend) add ServiceProvider #522
|
||||
- 💄(admin) allow header color customization #552
|
||||
- ✨(organization) add API endpoints #551
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(admin) add organization on user #555
|
||||
|
||||
## [1.6.1] - 2024-11-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(mailbox) fix status of current mailboxes
|
||||
- 🚑️(backend) fix claim contains non-user field #548
|
||||
|
||||
## [1.6.0] - 2024-11-20
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(teams) remove search by trigram for team access and contact
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(domains) allow creation of "pending" mailboxes
|
||||
- ✨(teams) allow team management for team admins/owners #509
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔊(backend) update logger config to info #542
|
||||
|
||||
## [1.5.0] - 2024-11-14
|
||||
|
||||
### Removed
|
||||
|
||||
- ⬆️(dependencies) remove unneeded dependencies
|
||||
- 🔥(teams) remove pagination of teams listing
|
||||
- 🔥(teams) remove search users by trigram
|
||||
- 🗃️(teams) remove `slug` field
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(dimail) send domain creation requests to dimail #454
|
||||
- ✨(dimail) synchronize mailboxes from dimail to our db #453
|
||||
- ✨(ci) add security scan #429
|
||||
- ✨(teams) register contacts on admin views
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(mail) fix display button on outlook
|
||||
- 💚(ci) improve E2E tests #492
|
||||
- 🔧(sentry) restore default integrations
|
||||
- 🔇(backend) remove Sentry duplicated warning/errors
|
||||
- 👷(ci) add sharding e2e tests #467
|
||||
- 🐛(dimail) fix unexpected status_code for proper debug #454
|
||||
|
||||
## [1.4.1] - 2024-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(frontend) fix MailDomainsLayout
|
||||
|
||||
## [1.4.0] - 2024-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add tabs inside #466
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✏️(mail) fix typo into mailbox creation email
|
||||
- 🐛(sentry) fix duplicated sentry errors #479
|
||||
- 🐛(script) improve and fix release script
|
||||
|
||||
## [1.3.1] - 2024-10-18
|
||||
|
||||
## [1.3.0] - 2024-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add RELEASE version on config endpoint #459
|
||||
- ✨(backend) manage roles on domain admin view
|
||||
- ✨(frontend) show version number in footer #369
|
||||
- 👔(backend) add Organization model
|
||||
|
||||
### Changed
|
||||
|
||||
- 🛂(backend) match email if no existing user matches the sub
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💄(mail) improve mailbox creation email #462
|
||||
- 🐛(frontend) fix update accesses form #448
|
||||
- 🛂(backend) do not duplicate user when disabled
|
||||
|
||||
## [1.2.1] - 2024-10-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔧(mail) use new scaleway email gateway #435
|
||||
|
||||
|
||||
## [1.2.0] - 2024-09-30
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(ci) add helmfile linter and fix argocd sync #424
|
||||
- ✨(domains) add endpoint to list and retrieve domain accesses #404
|
||||
- 🍱(dev) embark dimail-api as container #366
|
||||
- ✨(dimail) allow la regie to request a token for another user #416
|
||||
- ✨(frontend) show username on AccountDropDown #412
|
||||
- 🥅(frontend) improve add & update group forms error handling #387
|
||||
- ✨(frontend) allow group members filtering #363
|
||||
- ✨(mailbox) send new mailbox confirmation email #397
|
||||
- ✨(domains) domain accesses update API #423
|
||||
- ✨(backend) domain accesses create API #428
|
||||
- 🥅(frontend) catch new errors on mailbox creation #392
|
||||
- ✨(api) domain accesses delete API #433
|
||||
- ✨(frontend) add mail domain access management #413
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿️(frontend) fix left nav panel #396
|
||||
- 🔧(backend) fix configuration to avoid different ssl warning #432
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(serializers) move business logic to serializers #414
|
||||
|
||||
## [1.1.0] - 2024-09-10
|
||||
|
||||
### Added
|
||||
|
||||
- 📈(monitoring) configure sentry monitoring #378
|
||||
- 🥅(frontend) improve api error handling #355
|
||||
|
||||
### Changed
|
||||
|
||||
- 🗃️(models) move dimail 'secret' to settings #372
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(dimail) improve handling of dimail errors on failed mailbox creation #377
|
||||
- 💬(frontend) fix group member removal text #382
|
||||
- 💬(frontend) fix add mail domain text #382
|
||||
- 🐛(frontend) fix keyboard navigation #379
|
||||
- 🐛(frontend) fix add mail domain form submission #355
|
||||
|
||||
## [1.0.2] - 2024-08-30
|
||||
|
||||
### Added
|
||||
|
||||
- 🔧Runtime config for the frontend (#345)
|
||||
- 🔧(helm) configure resource server in staging
|
||||
|
||||
### Fixed
|
||||
|
||||
- 👽️(mailboxes) fix mailbox creation after dimail api improvement (#360)
|
||||
|
||||
## [1.0.1] - 2024-08-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✨(frontend) user can add mail domains
|
||||
|
||||
## [1.0.0] - 2024-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(domains) create and manage domains on admin + API
|
||||
- ✨(domains) mailbox creation + link to email provisioning API
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.8.0...main
|
||||
[1.8.0]: https://github.com/numerique-gouv/people/releases/v1.8.0
|
||||
[1.7.1]: https://github.com/numerique-gouv/people/releases/v1.7.1
|
||||
[1.7.0]: https://github.com/numerique-gouv/people/releases/v1.7.0
|
||||
[1.6.1]: https://github.com/numerique-gouv/people/releases/v1.6.1
|
||||
[1.6.0]: https://github.com/numerique-gouv/people/releases/v1.6.0
|
||||
[1.5.0]: https://github.com/numerique-gouv/people/releases/v1.5.0
|
||||
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
|
||||
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
|
||||
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
|
||||
[1.3.0]: https://github.com/numerique-gouv/people/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/numerique-gouv/people/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/numerique-gouv/people/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/numerique-gouv/people/releases/v1.1.0
|
||||
[1.0.2]: https://github.com/numerique-gouv/people/releases/v1.0.2
|
||||
[1.0.1]: https://github.com/numerique-gouv/people/releases/v1.0.1
|
||||
[1.0.0]: https://github.com/numerique-gouv/people/releases/v1.0.0
|
||||
|
||||
+25
-33
@@ -1,18 +1,17 @@
|
||||
# Django People
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.10-slim-bullseye as base
|
||||
FROM python:3.12.6-alpine3.20 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apt-get update && \
|
||||
apt-get -y upgrade && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
### ---- Front-end dependencies image ----
|
||||
FROM node:20 as frontend-deps
|
||||
FROM node:20 AS frontend-deps
|
||||
|
||||
WORKDIR /deps
|
||||
|
||||
@@ -25,7 +24,7 @@ COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint
|
||||
RUN yarn --frozen-lockfile
|
||||
|
||||
### ---- Front-end builder dev image ----
|
||||
FROM node:20 as frontend-builder-dev
|
||||
FROM node:20 AS frontend-builder-dev
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -35,12 +34,12 @@ COPY ./src/frontend .
|
||||
WORKDIR ./apps/desk
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-builder-dev as frontend-builder
|
||||
FROM frontend-builder-dev AS frontend-builder
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
@@ -61,7 +60,7 @@ CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base as back-builder
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -73,7 +72,7 @@ RUN mkdir /install && \
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 as mail-builder
|
||||
FROM node:20 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
@@ -84,15 +83,13 @@ RUN yarn install --frozen-lockfile && \
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
FROM base as link-collector
|
||||
FROM base AS link-collector
|
||||
ARG PEOPLE_STATIC_ROOT=/data/static
|
||||
|
||||
# Install libpangocairo & rdfind
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
libpangocairo-1.0-0 \
|
||||
rdfind && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
@@ -111,21 +108,18 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${PEOPLE_STATIC_ROOT}
|
||||
|
||||
# ---- Core application image ----
|
||||
FROM base as core
|
||||
FROM base AS core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
gettext \
|
||||
libcairo2 \
|
||||
libffi-dev \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
shared-mime-info && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add \
|
||||
gettext \
|
||||
cairo \
|
||||
libffi-dev \
|
||||
gdk-pixbuf \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -149,15 +143,13 @@ WORKDIR /app
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM core as backend-development
|
||||
FROM core AS backend-development
|
||||
|
||||
# Switch back to the root user to install development dependencies
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
RUN apt-get update && \
|
||||
apt-get install -y postgresql-client && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall people and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
@@ -177,7 +169,7 @@ ENV DB_HOST=postgresql \
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core as backend-production
|
||||
FROM core AS backend-production
|
||||
|
||||
ARG PEOPLE_STATIC_ROOT=/data/static
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ bootstrap: \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build \
|
||||
dimail-setup-db \
|
||||
install-front-desk
|
||||
.PHONY: bootstrap
|
||||
|
||||
@@ -109,6 +110,7 @@ run: ## start the wsgi (production) and development server
|
||||
@$(COMPOSE) up --force-recreate -d app-dev
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d keycloak
|
||||
@$(COMPOSE) up -d dimail
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_KC_DB)
|
||||
@$(WAIT_DB)
|
||||
@@ -129,6 +131,7 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
lint: \
|
||||
@@ -272,6 +275,17 @@ i18n-generate-and-upload: \
|
||||
crowdin-upload
|
||||
.PHONY: i18n-generate-and-upload
|
||||
|
||||
# -- INTEROPERABILTY
|
||||
# -- Dimail configuration
|
||||
|
||||
recreate-dimail-container:
|
||||
@$(COMPOSE) up --force-recreate -d dimail
|
||||
.PHONY: recreate-dimail-container
|
||||
|
||||
dimail-setup-db:
|
||||
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
|
||||
@$(MANAGE) setup_dimail_db
|
||||
.PHONY: dimail-setup-db
|
||||
|
||||
# -- Mail generator
|
||||
|
||||
@@ -343,3 +357,7 @@ start-kind: ## Create the kubernetes cluster
|
||||
tilt-up: ## start tilt - k8s local development
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: tilt-up
|
||||
|
||||
release: ## helper for release and deployment
|
||||
python scripts/release.py
|
||||
.PHONY: release
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# People
|
||||
|
||||
People is an application to handle users and teams.
|
||||
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
|
||||
|
||||
As of today, this project is **not yet ready for production**. Expect breaking changes.
|
||||
|
||||
People is built on top of [Django Rest
|
||||
It is built on top of [Django Rest
|
||||
Framework](https://www.django-rest-framework.org/).
|
||||
|
||||
All interoperabilities will be described in `docs/interoperability`.
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisite
|
||||
@@ -25,7 +25,7 @@ $ docker compose -v
|
||||
> ⚠️ You may need to run the following commands with `sudo` but this can be
|
||||
> avoided by assigning your user to the `docker` group.
|
||||
|
||||
### Project bootstrap
|
||||
### Bootstrap project
|
||||
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
@@ -38,7 +38,7 @@ database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
dependency-related or migration-related issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
Your Docker services should now be up and running! 🎉
|
||||
|
||||
Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||
|
||||
@@ -46,13 +46,7 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||
$ make run
|
||||
```
|
||||
|
||||
### Adding content
|
||||
|
||||
You can create a basic demo site by running:
|
||||
|
||||
$ make demo
|
||||
|
||||
Finally, you can check all available Make rules using:
|
||||
You can check all available Make rules using:
|
||||
|
||||
```bash
|
||||
$ make help
|
||||
@@ -71,6 +65,23 @@ $ make superuser
|
||||
|
||||
You can then login with sub `admin` and password `admin`.
|
||||
|
||||
### Adding demo content
|
||||
|
||||
You can create a basic demo site by running:
|
||||
|
||||
```bash
|
||||
$ make demo
|
||||
```
|
||||
|
||||
### Setting dimail database
|
||||
|
||||
To ease local development when working on interoperability between people and dimail, we embark dimail-api in a container running in "fake" mode.
|
||||
|
||||
To populate dimail local database with users/domains/permissions needed for basic development:
|
||||
- log in with "people" user
|
||||
- run `make dimail-setup-db`
|
||||
|
||||
|
||||
### Run frontend
|
||||
|
||||
Run the front with:
|
||||
@@ -79,14 +90,10 @@ Run the front with:
|
||||
$ make run-front-desk
|
||||
```
|
||||
|
||||
Then access at
|
||||
[http://localhost:3000](http://localhost:3000)
|
||||
|
||||
Then access [http://localhost:3000](http://localhost:3000) with :
|
||||
user: people
|
||||
|
||||
password: people
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
|
||||
@@ -5,7 +5,6 @@ set -eo pipefail
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||
COMPOSE_PROJECT="people"
|
||||
|
||||
@@ -92,66 +91,3 @@ function _dc_exec() {
|
||||
function _django_manage() {
|
||||
_dc_run "app-dev" python manage.py "$@"
|
||||
}
|
||||
|
||||
# _set_openstack_project: select an OpenStack project from the openrc files defined in the
|
||||
# terraform directory.
|
||||
#
|
||||
# usage: _set_openstack_project
|
||||
#
|
||||
# If necessary the script will prompt the user to choose a project from those available
|
||||
function _set_openstack_project() {
|
||||
|
||||
declare prompt
|
||||
declare -a projects
|
||||
declare -i default=1
|
||||
declare -i choice=0
|
||||
declare -i n_projects
|
||||
|
||||
# List projects by looking in the "./env.d/terraform" directory
|
||||
# and store them in an array
|
||||
read -r -a projects <<< "$(
|
||||
find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d |
|
||||
sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' |
|
||||
xargs
|
||||
)"
|
||||
nb_projects=${#projects[@]}
|
||||
|
||||
if [[ ${nb_projects} -le 0 ]]; then
|
||||
echo "There are no OpenStack projects defined..." >&2
|
||||
echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \
|
||||
"of your project and copy your \"openrc.sh\" file into it." >&2
|
||||
exit 10
|
||||
fi
|
||||
|
||||
if [[ ${nb_projects} -gt 1 ]]; then
|
||||
prompt="Select an OpenStack project to target:\\n"
|
||||
for (( i=0; i<nb_projects; i++ )); do
|
||||
prompt+="[$((i+1))] ${projects[$i]}"
|
||||
if [[ $((i+1)) -eq ${default} ]]; then
|
||||
prompt+=" (default)"
|
||||
fi
|
||||
prompt+="\\n"
|
||||
done
|
||||
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
|
||||
prompt+="Your choice: "
|
||||
read -r -p "$(echo -e "${prompt}")" choice
|
||||
|
||||
if [[ ${choice} -gt nb_projects ]]; then
|
||||
(>&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})")
|
||||
exit 11
|
||||
fi
|
||||
|
||||
if [[ ${choice} -le 0 ]]; then
|
||||
choice=${default}
|
||||
fi
|
||||
fi
|
||||
|
||||
project=${projects[$((choice-1))]}
|
||||
# Check that the openrc.sh file exists for this project
|
||||
if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then
|
||||
(>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.")
|
||||
exit 12
|
||||
fi
|
||||
|
||||
echo "${project}"
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
project=$(_set_openstack_project)
|
||||
echo "Using \"${project}\" project..."
|
||||
|
||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
||||
|
||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
||||
# shellcheck disable=SC2068
|
||||
DOCKER_USER="$(id -u):$(id -g)" \
|
||||
PROJECT="${project}" \
|
||||
docker compose run --rm \
|
||||
-e OS_AUTH_URL \
|
||||
-e OS_IDENTITY_API_VERSION \
|
||||
-e OS_USER_DOMAIN_NAME \
|
||||
-e OS_PROJECT_DOMAIN_NAME \
|
||||
-e OS_TENANT_ID \
|
||||
-e OS_TENANT_NAME \
|
||||
-e OS_USERNAME \
|
||||
-e OS_PASSWORD \
|
||||
-e OS_REGION_NAME \
|
||||
terraform-state "$@"
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
project=$(_set_openstack_project)
|
||||
echo "Using \"${project}\" project..."
|
||||
|
||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
||||
|
||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
||||
# shellcheck disable=SC2068
|
||||
DOCKER_USER="$(id -u):$(id -g)" \
|
||||
PROJECT="${project}" \
|
||||
docker compose run --rm \
|
||||
-e OS_AUTH_URL \
|
||||
-e OS_IDENTITY_API_VERSION \
|
||||
-e OS_USER_DOMAIN_NAME \
|
||||
-e OS_PROJECT_DOMAIN_NAME \
|
||||
-e OS_TENANT_ID \
|
||||
-e OS_TENANT_NAME \
|
||||
-e OS_USERNAME \
|
||||
-e OS_PASSWORD \
|
||||
-e OS_REGION_NAME \
|
||||
-e TF_VAR_user_name \
|
||||
terraform "$@"
|
||||
+11
-18
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
@@ -37,6 +35,7 @@ services:
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- dimail
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
@@ -118,22 +117,6 @@ services:
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
terraform-state:
|
||||
image: hashicorp/terraform:1.6
|
||||
environment:
|
||||
- TF_WORKSPACE=${PROJECT:-} # avoid env conflict in local state
|
||||
user: ${DOCKER_USER:-1000}
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./src/terraform/create_state_bucket:/app
|
||||
|
||||
terraform:
|
||||
image: hashicorp/terraform:1.6
|
||||
user: ${DOCKER_USER:-1000}
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./src/terraform:/app
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
@@ -168,3 +151,13 @@ services:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
|
||||
dimail:
|
||||
entrypoint: /opt/dimail-api/start-dev.sh
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.0.16
|
||||
pull_policy: always
|
||||
environment:
|
||||
DIMAIL_MODE: FAKE
|
||||
DIMAIL_JWT_SECRET: fake_jwt_secret
|
||||
ports:
|
||||
- "8001:8000"
|
||||
|
||||
@@ -99,6 +99,216 @@
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-member",
|
||||
"email": "e2e.team-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-administrator",
|
||||
"email": "e2e.team-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-owner",
|
||||
"email": "e2e.team-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.mail-member",
|
||||
"email": "e2e.mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.mail-administrator",
|
||||
"email": "e2e.mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.mail-owner",
|
||||
"email": "e2e.mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-member-mail-member",
|
||||
"email": "e2e.team-member-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-member-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-member-mail-administrator",
|
||||
"email": "e2e.team-member-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-member-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-member-mail-owner",
|
||||
"email": "e2e.team-member-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-member-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-administrator-mail-member",
|
||||
"email": "e2e.team-administrator-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-administrator-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-administrator-mail-administrator",
|
||||
"email": "e2e.team-administrator-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-administrator-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-administrator-mail-owner",
|
||||
"email": "e2e.team-administrator-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-administrator-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-owner-mail-member",
|
||||
"email": "e2e.team-owner-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-owner-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-owner-mail-administrator",
|
||||
"email": "e2e.team-owner-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-owner-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-owner-mail-owner",
|
||||
"email": "e2e.team-owner-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-owner-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# dimail
|
||||
|
||||
## What is dimail ?
|
||||
|
||||
The mailing solution provided in La Suite is [Open-XChange](https://www.open-xchange.com/) (OX).
|
||||
OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
|
||||
|
||||
API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.fr/docs#/).
|
||||
|
||||
## Architectural links of dimail
|
||||
|
||||
As dimail's primary goal is to act as an interface between People and OX, its architecture is similar to that of People. A series of requests are sent from People to dimail upon creating domains, users, permissions and mailboxes.
|
||||
|
||||
### Domains
|
||||
|
||||
Upon creating a domain on People, the same domain is created on dimail and will undergo a series of checks. When all checks have passed, the domain is considered enabled.
|
||||
|
||||
Domains in OX have a field called "context". Context is a shared space between domains, allowing users to discover users not only on their domain but on their entire context.
|
||||
|
||||
### Mailboxes
|
||||
|
||||
Mailboxes can be created by a domain owners or administrators in People's domain tab.
|
||||
|
||||
On enabled domains, mailboxes are created at the same time on dimail (and a confirmation email is sent to the secondary email).
|
||||
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's activation.
|
||||
On disabled domains, mailboxes creation is not allowed.
|
||||
|
||||
### Users
|
||||
|
||||
The ones issuing requests. Dimail users will reflect domains owners and administrators on People, for logging purposes and to allow direct use of dimail, if desired. User reconciliation is made on user uuid provided by ProConnect.
|
||||
|
||||
### Permissions
|
||||
|
||||
As for People, an access - a permissions (or an "allow" in dimail) - grants an user permission to create objects on a domain.
|
||||
|
||||
Permissions requests are sent automatically upon :
|
||||
- dimail database initialisation:
|
||||
+ permission for dimail user People to create users and domains
|
||||
- domain creation :
|
||||
+ permission for dimail user People to manage domain
|
||||
+ permission for new owner on new domain
|
||||
- user creation:
|
||||
+ permission for People to manage user
|
||||
- access creation, if owner or admin:
|
||||
+ permission for this user to manage domain
|
||||
+103
-73
@@ -6,16 +6,56 @@ interact efficiently.
|
||||
|
||||
Let's see how we could interact with Django's shell to recreate David's environment in the app.
|
||||
|
||||
## Base contacts from the organization records
|
||||
## Profile contact
|
||||
|
||||
David Bowman is an exemplary employee at Space Odyssey Corporation. His email is
|
||||
`david.bowman@spaceodyssey.com` and he is registered in the organization's records via a base
|
||||
contact as follows:
|
||||
The system identifies Dave through the `sub` associated with his OIDC session.
|
||||
The OIDC should also provide his email address.
|
||||
|
||||
Note: as some Identity Providers do not provide a constant `sub`, the system should be able to
|
||||
fallback to the email address to find it the user is already in the database.
|
||||
|
||||
When Dave logs in for the first time, the system checks if he is already in the database. If not
|
||||
a new user is created for him.
|
||||
|
||||
Users needs to be linked to an Organization (see [organizations.md](./organizations.md)).
|
||||
|
||||
```python
|
||||
david_base_contact = Contact.objects.create(
|
||||
space_odyssey, _created = Organization.objects.get_or_create(...)
|
||||
|
||||
david_user = User.objects.create(
|
||||
organization=space_odyssey,
|
||||
language="en-us",
|
||||
timezone="America/Los_Angeles",
|
||||
name="David Bowman",
|
||||
sub="1234567890",
|
||||
email="david.bowman@spaceodyssey.com",
|
||||
)
|
||||
```
|
||||
|
||||
The system then creates a profile contact for Dave, based on the information provided by the OIDC:
|
||||
|
||||
```python
|
||||
david_profile_contact = Contact.objects.create(
|
||||
owner=david_user,
|
||||
user=david_user, # this means it's the user's profile contact
|
||||
full_name="David Bowman",
|
||||
short_name="David",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**Future feature:**
|
||||
Dave will be prompted to confirm the details of the base contact stored in the database
|
||||
and fill the details for a better profile.
|
||||
|
||||
His profile contact will be updated with the new information.
|
||||
|
||||
```python
|
||||
Contact.objects.filter(user=david_user, owner=david_user).update(
|
||||
short_name="Dave",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
|
||||
@@ -48,91 +88,81 @@ david_base_contact = Contact.objects.create(
|
||||
)
|
||||
```
|
||||
|
||||
When David logs-in to the People application for the first time using the corporation's OIDC
|
||||
Single Sign-On service. A user is created for him on the fly by the system, together with an
|
||||
identity record representing the OIDC session:
|
||||
David's profile contact is available to all users of his company/organization.
|
||||
|
||||
```python
|
||||
david_user = User.objects.create(
|
||||
language="en-us",
|
||||
timezone="America/Los_Angeles",
|
||||
)
|
||||
david_identity = Identity.objects.create(
|
||||
"user": david_user,
|
||||
"sub": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
"email" : "david.bowman@spaceodyssey.com",
|
||||
"is_main": True,
|
||||
)
|
||||
```
|
||||
|
||||
## Profile contact
|
||||
|
||||
The system identifies Dave through the email associated with his OIDC session and prompts him to
|
||||
confirm the details of the base contact stored in the database.
|
||||
|
||||
When David confirms, giving an alternative short name that he prefers, a contact override is
|
||||
created on top of the organization's base contact. This new contact is marked as David's profile
|
||||
on the user:
|
||||
|
||||
```python
|
||||
david_contact = Contact.objects.create(
|
||||
base=david_base_contact,
|
||||
owner=david_user,
|
||||
full_name="David Bowman",
|
||||
short_name="Dave",
|
||||
data={}
|
||||
)
|
||||
david_user.profile_contact = david_contact
|
||||
david_user.save()
|
||||
```
|
||||
|
||||
If Dave had not had any existing contact in the organization's records, the profile contact would
|
||||
have been created independently, without any connection to a base contact:
|
||||
|
||||
```python
|
||||
david_contact = Contact.objects.create(
|
||||
base=None,
|
||||
owner=david_user,
|
||||
full_name="David Bowman",
|
||||
short_name="Dave",
|
||||
data={}
|
||||
)
|
||||
```
|
||||
|
||||
Now, Dave feels like sharing his mobile phone number with his colleagues. He can do this
|
||||
by editing his contact in the application:
|
||||
|
||||
```python
|
||||
contact.data["phones"] = [
|
||||
{"type": "Mobile", "value": "(123) 456-7890"},
|
||||
]
|
||||
contact.save()
|
||||
```
|
||||
|
||||
## Contact override
|
||||
|
||||
During a Space conference he attended, Dave met Dr Ryan Stone, a medical engineer who gave him
|
||||
her professional email address. Ryan is already present in the system but her email is missing.
|
||||
her personal email address (because she will quit the company she works for). Ryan is already
|
||||
present in the system.
|
||||
|
||||
Dave can add it to his private version of the contact:
|
||||
|
||||
```python
|
||||
ryan_base_contact = Contact.objects.create(
|
||||
full_name="Ryan Stone",
|
||||
data={}
|
||||
ryan_user = User.objects.create(
|
||||
organization=space_odyssey,
|
||||
language="en-us",
|
||||
timezone="America/Los_Angeles",
|
||||
name="Ryan Stone",
|
||||
sub="0987654321",
|
||||
email="ryan.stone@spaceodyssey.com",
|
||||
)
|
||||
ryan_contact = Contact.objects.create(
|
||||
base=ryan_base_contact,
|
||||
owner=david_user,
|
||||
|
||||
ryan_profile_contact = Contact.objects.create(
|
||||
user=ryan_user,
|
||||
owner=ryan_user,
|
||||
full_name="Ryan Stone",
|
||||
short_name="Dr Ryan",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "ryan.stone@hubblestation.com"},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
david_ryan_contact = Contact.objects.create(
|
||||
override=ryan_profile_contact,
|
||||
owner=david_user,
|
||||
full_name="Ryan Stone",
|
||||
short_name="Dr Ryan",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Home", "value": "ryan@stone.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Personal contacts
|
||||
|
||||
Dave met a lot of people during the last Opensource Experience event. He can add them to his personal contacts:
|
||||
|
||||
```python
|
||||
julie_personal_contact = Contact.objects.create(
|
||||
owner=david_user,
|
||||
full_name="Julie Powell",
|
||||
short_name="Julie",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "julie.powell@example.com"},
|
||||
],
|
||||
"phones": [
|
||||
{"type": "Work", "value": "(123) 456-7890"},
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
These contacts are only available to Dave.
|
||||
|
||||
|
||||
## Contacts from shared directories
|
||||
|
||||
This is a future feature that will allow Dave to access contacts from shared directories.
|
||||
Shared directories will provide contacts for all users from several organizations.
|
||||
|
||||
|
||||
## Team Collaboration
|
||||
|
||||
Dave wants to form a team with Ryan and other colleagues to work together better on using the organization's digital tools for their projects.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Organization model
|
||||
|
||||
## Purpose
|
||||
|
||||
The initial `Organization` model allows to regroup `User` and `Team` under a same object.
|
||||
|
||||
While the purpose was purely technical in the first place, few features emerged afterward.
|
||||
|
||||
|
||||
## Link with the `User` model
|
||||
|
||||
Each user must have a "default" organization.
|
||||
|
||||
When a user logs in:
|
||||
|
||||
- The backend tries to get an existing organization from a "registration ID" or from the user's mail domain
|
||||
- If the organization does not exist, it is created using the "registration ID" and fallbacks on the user's mail domain
|
||||
- The user organization is set to this organization.
|
||||
|
||||
**Note:** While deploying the new code, we allow the already existing user to not have one.
|
||||
|
||||
|
||||
## Link with the `Team` model
|
||||
|
||||
Each team must have an organization. This organization is not defined by the user, but automatically set
|
||||
using the user's organization.
|
||||
|
||||
The team's organization does not restrict the users who can be added to the team.
|
||||
|
||||
There is currently no feature relying on the team organization.
|
||||
|
||||
**Note:** While deploying the new code, we allow the teams to not have one.
|
||||
|
||||
|
||||
## Organization-related features
|
||||
|
||||
### Organization UI
|
||||
|
||||
A new interface can be created to allow users to see all members of an organization.
|
||||
This could be used along with the contacts.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
## Resource Server
|
||||
|
||||
For detailed information, please refer to the [OAuth 2.0 Resource Server documentation](https://www.oauth.com/oauth2-servers/the-resource-server/) and review the relevant commits that implement the resource server backend.
|
||||
|
||||
---
|
||||
#### Overview
|
||||
|
||||
A resource server is a crucial component in an OAuth 2.0 ecosystem. It is responsible for accepting access tokens issued by the authorization server (in this case, Agent Connect), introspecting and validating those tokens, and then returning the requested protected resources to the client.
|
||||
|
||||
By implementing a resource server, we can securely share data between different services within La Suite. This ensures that only clients with the appropriate scopes and permissions can access specific resources, thereby enhancing security and maintaining proper access control across services.
|
||||
|
||||
---
|
||||
#### Disclaimer
|
||||
|
||||
- Currently compatible only with Agent Connect.
|
||||
- The development setup requires simplification, with dependencies on Agent Connect ideally mocked.
|
||||
- Terminology aligns with the specification: what is referred to as a "resource server" is known as a "data provider" in Agent Connect.
|
||||
- This documentation is WIP.
|
||||
|
||||
---
|
||||
## Running Locally
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **Agent Connect Stack**: Ensure the local Agent Connect stack is running. A solid understanding of its configuration and operation is recommended (advanced level).
|
||||
- **People Stack**: Make sure the People stack is up and running.
|
||||
- **Ngrok**: Install and set up Ngrok for secure tunneling.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Update People's configurations
|
||||
|
||||
#### Environment variables
|
||||
|
||||
|
||||
Agent Connect includes two pre-configured mocked data providers in its default stack (`bdd-fca-low`).
|
||||
|
||||
Use the client ID and client secret from one of these data providers. **Note:** Make sure to retrieve the decrypted secret, as it is stored encrypted in the database. You can find these values in the `dp.js` fixture file, where they are exposed in a comment.
|
||||
|
||||
Configure your environment with the following values from Agent Connect:
|
||||
|
||||
```
|
||||
OIDC_RS_CLIENT_ID=<your-client-id-from-ac>
|
||||
OIDC_RS_CLIENT_SECRET=<your-decrypted-client-secret-from-ac>
|
||||
|
||||
# In development, the resource server use insecure settings
|
||||
OIDC_VERIFY_SSL=False
|
||||
|
||||
# Update the endpoints as follows
|
||||
OIDC_OP_JWKS_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/jwks
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/checktoken
|
||||
OIDC_OP_URL=https://core-fca-low.docker.dev-franceconnect.fr/api/v2
|
||||
```
|
||||
|
||||
#### Docker Network Configuration
|
||||
|
||||
To enable communication between the Docker networks for People and Agent Connect, update your docker-compose configuration. This setup is required because the Authorization Server and Resource Server will exchange requests over a back-channel, necessitating their accessibility to each other.
|
||||
|
||||
1. **Create a Network**: Define a new network to bridge the two Docker networks.
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
authorization_server:
|
||||
external: true
|
||||
driver: bridge
|
||||
name: "${DESK_NETWORK:-fc_public}"
|
||||
```
|
||||
|
||||
2. **Update Network for `app-dev`**: Ensure your `app-dev` service is connected to both the default network and the new `authorization_server` network.
|
||||
|
||||
```yaml
|
||||
app-dev:
|
||||
...
|
||||
networks:
|
||||
- default
|
||||
- authorization_server
|
||||
```
|
||||
|
||||
|
||||
#### Ngrok
|
||||
|
||||
To expose your local resource server through an HTTP tunnel, use Ngrok. This is necessary because, in the Agent Connect development stack, the resource server needs to be accessible to a user agent via a publicly accessible URL.
|
||||
|
||||
```
|
||||
$ ngrok http 8071
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Update AgentConnect's configurations
|
||||
|
||||
Modify the AgentConnect configuration to include the local resource server settings.
|
||||
|
||||
**Update `fsa1-low` Environment File**:
|
||||
|
||||
Add your Ngrok URL and configure the `DATA_APIS` list with the appropriate values.
|
||||
|
||||
```env
|
||||
App_DATA_APIS=[{"name":"Data Provider 1","url":"https://your-ngrok-url/api/v1.0/any-path","secret":"***"}, ...]
|
||||
```
|
||||
|
||||
**Update Fixture in `dp.js`**:
|
||||
|
||||
Adjust the configuration for the data provider to match your local setup. Ensure that the `client_id`, `client_secret`, and other parameters are correctly set and aligned with your environment. This can be configured through environment variables.
|
||||
|
||||
```javascript
|
||||
const dps = [
|
||||
// Data Provider Configuration
|
||||
{
|
||||
uid: "6f21b751-ed06-48b6-a59c-36e1300a368a",
|
||||
title: "Mock Data Provider - 1",
|
||||
active: true,
|
||||
slug: "DESK",
|
||||
client_id: "***",
|
||||
client_secret: "***",
|
||||
// client_secret decrypted : ****
|
||||
jwks_uri: "https://your-ngrok-url/api/v1.0/jwks", // Update this line
|
||||
checktoken_signed_response_alg: "ES256",
|
||||
checktoken_encrypted_response_alg: "RSA-OAEP",
|
||||
checktoken_encrypted_response_enc: "A256GCM",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**Note**: Ensure that the `jwks_uri` and other cryptographic parameters (e.g., `checktoken_signed_response_alg`, `checktoken_encrypted_response_alg`, and `checktoken_encrypted_response_enc`) match your actual setup and are configured via environment variables where necessary.
|
||||
|
||||
---
|
||||
|
||||
### Usage
|
||||
|
||||
This section is a work in progress. Please note the following important points:
|
||||
|
||||
#### User `sub` Matching
|
||||
|
||||
Ensure that the `sub` (subject) field for users in AgentConnect matches the corresponding value in the People database. To synchronize this, you can run `make demo`, then edit the user's `sub` field to match the value returned by AgentConnect. For this, you'll need to update the editable field in Django Admin, specifically in `admin.py`. Adjust the `get_readonly_fields` method as follows:
|
||||
|
||||
```python
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""The 'sub' field should only be editable during creation, not for updates."""
|
||||
if obj:
|
||||
return self.readonly_fields
|
||||
return self.readonly_fields + ["sub"] # update this line adding 'sub'
|
||||
```
|
||||
|
||||
#### Scope for `groups`
|
||||
Ensure that the `groups` scope is requested from the service provider during authentication with AgentConnect.
|
||||
|
||||
#### Resource Server Requests
|
||||
By default, the `fsa1-low` environment calls the resource server using a POST request.
|
||||
|
||||
#### Testing
|
||||
|
||||
Most of the testing has been done using the `/users/me` endpoint. Update the `api/viewset.py` configuration to allow both GET and POST methods for this endpoint:
|
||||
|
||||
```python
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["get", "post"], # update this line adding 'post'
|
||||
url_name="me",
|
||||
url_path="me",
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# ServiceProvider model
|
||||
|
||||
## Purpose
|
||||
|
||||
The `ServiceProvider` model represents a ... service provider, also known as "tools using some data from this project".
|
||||
|
||||
|
||||
## Link with the `Organization` model
|
||||
|
||||
An organization can be linked to several service providers.
|
||||
The goal here, is to allow users of an organization to have access, to a service provider or not.
|
||||
The first implementation does not have any feature related, but the first feature will probably be
|
||||
to list applications visible in the "all application menu" (aka "la gaufre").
|
||||
|
||||
|
||||
## Link with the `Team` model
|
||||
|
||||
A team can be linked to several service providers.
|
||||
This is used as a filter when a service provider calls the resource server, only the teams linked to
|
||||
this service provider are returned. This is mandatory for data segregation: we don't want all service
|
||||
providers to be able to list all data regarding other service providers.
|
||||
|
||||
|
||||
## Limitations
|
||||
|
||||
There is currently no way to provision all the service providers automatically. So when a service provider
|
||||
creates a team via the resource server, we create the `ServiceProvider` on the fly, without any understandable name.
|
||||
This will need to be improved later.
|
||||
@@ -0,0 +1,51 @@
|
||||
# E2E tests
|
||||
|
||||
## Run E2E tests
|
||||
|
||||
``` bash
|
||||
# you need the dockers to be up and running
|
||||
make bootstrap
|
||||
|
||||
# you will need to have few accounts in the database
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
# run the tests
|
||||
cd src/frontend/apps/e2e
|
||||
yarn test:ui --workers=1
|
||||
```
|
||||
|
||||
A new browser window will open and you will be able to run the tests.
|
||||
|
||||
## Available accounts
|
||||
|
||||
The `make demo` command creates the following accounts:
|
||||
- `e2e.team-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only belong to a team with the specified role.
|
||||
- `e2e.mail-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only have a mailbox with the specified role access.
|
||||
- `e2e.team-<team_role>-mail-<domain_role>@example.com` with a combination of roles as for the
|
||||
previous accounts.
|
||||
|
||||
For each account, the password is `password-e2e.<role>`, for instance `password-e2e.team-member`.
|
||||
|
||||
In the E2E tests you can use these accounts to benefit from there accesses,
|
||||
using the `keyCloakSignIn(page, browserName, <account_name>)`. The account name is the
|
||||
username without the prefix `e2e.`.
|
||||
|
||||
``` typescript jsx
|
||||
await keyCloakSignIn(page, browserName, 'mail-owner');
|
||||
```
|
||||
|
||||
The `keyCloakSignIn` function will sign in the user on Keycloak using the proper username and password.
|
||||
|
||||
.. note::
|
||||
This only works because the OIDC setting is set to fallback on user email.
|
||||
|
||||
## Add a new account
|
||||
|
||||
In case you need to add a new account for specific tests you need:
|
||||
- to create a new user with the same format in the backend database:
|
||||
update `[create_demo.py](../src/backend/demo/management/commands/create_demo.py)`
|
||||
- to create a new account in Keycloak: update [realm.json](../docker/auth/realm.json)
|
||||
- if the keycloak was running locally, you need to destroy its database and
|
||||
restart the database and the keycloak containers.
|
||||
@@ -33,3 +33,35 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
OIDC_RS_CLIENT_ID=people
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RS_PRIVATE_KEY_STR="-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
|
||||
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
|
||||
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
|
||||
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
|
||||
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
|
||||
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
|
||||
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
|
||||
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
|
||||
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
|
||||
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
|
||||
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
|
||||
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
|
||||
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
|
||||
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
|
||||
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
|
||||
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
|
||||
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
|
||||
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
|
||||
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
|
||||
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
|
||||
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
|
||||
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
|
||||
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
|
||||
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
|
||||
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
|
||||
MbyqKyC6DAzv4jEEhHaN7oY=
|
||||
-----END PRIVATE KEY-----
|
||||
"
|
||||
|
||||
+1
-7
@@ -13,13 +13,7 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"fetch-mock",
|
||||
"node",
|
||||
"node-fetch",
|
||||
"i18next-parser",
|
||||
"eslint"
|
||||
]
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from utils import run_command
|
||||
|
||||
|
||||
RELEASE_KINDS = {'p': 'patch', 'm': 'minor', 'mj': 'major'}
|
||||
|
||||
|
||||
def update_files(version):
|
||||
"""Update all files needed with new release version"""
|
||||
# pyproject.toml
|
||||
sys.stdout.write("Update pyproject.toml...\n")
|
||||
path = "src/backend/pyproject.toml"
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if line.startswith("version = "):
|
||||
lines[index] = re.sub(r'\"(.*?)\"', f'"{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
# helm files
|
||||
sys.stdout.write("Update helm files...\n")
|
||||
for env in ["preprod", "production"]:
|
||||
path = f"src/helm/env.d/{env}/values.desk.yaml.gotmpl"
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "tag:" in line:
|
||||
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
# frontend package.json
|
||||
sys.stdout.write("Update package.json...\n")
|
||||
files_to_modify = []
|
||||
filename = "package.json"
|
||||
for root, _dir, files in os.walk("src/frontend"):
|
||||
if filename in files and "node_modules" not in root and ".next" not in root:
|
||||
files_to_modify.append(os.path.join(root, filename))
|
||||
|
||||
for path in files_to_modify:
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "version" in line:
|
||||
lines[index] = re.sub(r'"version": \"(.*?)\"', f'"version": "{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
return
|
||||
|
||||
|
||||
def update_changelog(path, version):
|
||||
"""Update changelog file with release info
|
||||
"""
|
||||
sys.stdout.write("Update CHANGELOG...\n")
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "## [Unreleased]" in line:
|
||||
today = datetime.date.today()
|
||||
lines.insert(index + 1, f"\n## [{version}] - {today}\n")
|
||||
if line.startswith("[unreleased]"):
|
||||
last_version = lines[index + 1].split("]")[0][1:]
|
||||
new_unreleased_line = line.replace(last_version, version)
|
||||
new_release_line = lines[index + 1].replace(last_version, version)
|
||||
lines[index] = new_unreleased_line
|
||||
lines.insert(index + 1, new_release_line)
|
||||
break
|
||||
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def prepare_release(version, kind):
|
||||
sys.stdout.write('Let\'s go to create branch to release\n')
|
||||
branch_to_release = f"release/{version}"
|
||||
run_command(f"git checkout -b {branch_to_release}", shell=True)
|
||||
run_command("git pull --rebase origin main", shell=True)
|
||||
|
||||
update_changelog("CHANGELOG.md", version)
|
||||
update_files(version)
|
||||
|
||||
run_command("git add CHANGELOG.md", shell=True)
|
||||
run_command("git add src/", shell=True)
|
||||
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
|
||||
|
||||
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
|
||||
run_command(f"git commit -m '{message}'", shell=True)
|
||||
confirm = input(f"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
|
||||
if confirm == 'y':
|
||||
run_command(f"git push origin {branch_to_release}", shell=True)
|
||||
sys.stdout.write(f"""
|
||||
PLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Please submit PR and merge code to main
|
||||
--> Then do:
|
||||
>> git checkout main
|
||||
>> git pull
|
||||
>> git tag v{version}
|
||||
>> git push origin v{version}
|
||||
--> Please check and wait for the docker image v{version} to be published here: https://hub.docker.com/r/lasuite/people-backend/tags
|
||||
>> git tag -d preprod
|
||||
>> git tag preprod
|
||||
>> git push -f origin preprod
|
||||
--> Now please generate release on github interface for the current tag v{version}
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
version, kind = None, None
|
||||
while not version:
|
||||
version = input("Enter your release version:")
|
||||
while kind not in RELEASE_KINDS:
|
||||
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
|
||||
prepare_release(version, kind)
|
||||
@@ -0,0 +1,14 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_command(cmd, msg=None, shell=False, cwd='.', stdout=None):
|
||||
if msg is None:
|
||||
msg = f"# Running: {cmd}"
|
||||
if stdout is not None:
|
||||
stdout.write(msg + '\n')
|
||||
if stdout != sys.stdout:
|
||||
sys.stdout(msg)
|
||||
subprocess.check_call(cmd, shell=shell, cwd=cwd, stdout=stdout, stderr=stdout)
|
||||
if stdout is not None:
|
||||
stdout.flush()
|
||||
+1
-1
Submodule secrets updated: d7cfe7bcdc...b7ab5f1411
@@ -363,7 +363,8 @@ name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
# Ignore: private stuff and `Params` class from FactoryBoy
|
||||
no-docstring-rgx=(^_|^Params$)
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Root module."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People custom admin site."""
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Custom Django admin site application configuration."""
|
||||
|
||||
from django.contrib.admin.apps import AdminConfig
|
||||
|
||||
|
||||
class PeopleAdminConfig(AdminConfig):
|
||||
"""Declare our custom Django admin site."""
|
||||
|
||||
default_site = "admin.sites.PeopleAdminSite"
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Custom Django admin site for the People app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
class PeopleAdminSite(admin.AdminSite):
|
||||
"""People custom admin site."""
|
||||
|
||||
def each_context(self, request):
|
||||
"""Add custom context to the admin site."""
|
||||
return super().each_context(request) | {
|
||||
"ADMIN_HEADER_BACKGROUND": settings.ADMIN_HEADER_BACKGROUND,
|
||||
"ADMIN_HEADER_COLOR": settings.ADMIN_HEADER_COLOR,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
|
||||
{% block extrastyle %}{{ block.super }}
|
||||
<style>
|
||||
html[data-theme="light"], :root {
|
||||
{% if ADMIN_HEADER_BACKGROUND %}--header-bg: {{ ADMIN_HEADER_BACKGROUND }};{% endif %}
|
||||
{% if ADMIN_HEADER_COLOR %}--header-color: {{ ADMIN_HEADER_COLOR }};{% endif %}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
"""Core root module."""
|
||||
|
||||
@@ -4,6 +4,8 @@ from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from mailbox_manager.admin import MailDomainAccessInline
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@@ -16,6 +18,15 @@ class TeamAccessInline(admin.TabularInline):
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
class OrganizationAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for organization accesses."""
|
||||
|
||||
autocomplete_fields = ["user", "organization"]
|
||||
extra = 0
|
||||
model = models.OrganizationAccess
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
class TeamWebhookInline(admin.TabularInline):
|
||||
"""Inline admin class for team webhooks."""
|
||||
|
||||
@@ -29,6 +40,7 @@ class TeamWebhookInline(admin.TabularInline):
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Admin class for the User model"""
|
||||
|
||||
autocomplete_fields = ["organization"]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -37,10 +49,11 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"id",
|
||||
"sub",
|
||||
"password",
|
||||
"organization",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("email", "language", "timezone")}),
|
||||
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -65,10 +78,10 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = (TeamAccessInline,)
|
||||
inlines = (TeamAccessInline, MailDomainAccessInline, OrganizationAccessInline)
|
||||
list_display = (
|
||||
"sub",
|
||||
"email",
|
||||
"get_user",
|
||||
"organization",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_active",
|
||||
@@ -79,7 +92,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
search_fields = ("id", "email", "sub")
|
||||
search_fields = ("id", "email", "sub", "name")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""The sub should only be editable for a create, not for updates."""
|
||||
@@ -87,15 +100,31 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
return self.readonly_fields + ["sub"]
|
||||
return self.readonly_fields
|
||||
|
||||
def get_user(self, obj):
|
||||
"""Provide a nice display for user"""
|
||||
return (
|
||||
obj.name if obj.name else (obj.email if obj.email else f"[sub] {obj.sub}")
|
||||
)
|
||||
|
||||
get_user.short_description = _("User")
|
||||
|
||||
|
||||
class TeamServiceProviderInline(admin.TabularInline):
|
||||
"""Inline admin class for service providers."""
|
||||
|
||||
can_delete = False
|
||||
model = models.Team.service_providers.through
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(models.Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
"""Team admin interface declaration."""
|
||||
|
||||
inlines = (TeamAccessInline, TeamWebhookInline)
|
||||
inlines = (TeamAccessInline, TeamWebhookInline, TeamServiceProviderInline)
|
||||
exclude = ("service_providers",) # Handled by the inline
|
||||
list_display = (
|
||||
"name",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
@@ -139,8 +168,8 @@ class InvitationAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Mark all fields read only, i.e. disable update."""
|
||||
if obj:
|
||||
# all fields read only = disable update
|
||||
return self.fields
|
||||
return self.readonly_fields
|
||||
|
||||
@@ -153,5 +182,67 @@ class InvitationAdmin(admin.ModelAdmin):
|
||||
return super().change_view(request, object_id, extra_context=extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Fill in current logged-in user as issuer."""
|
||||
obj.issuer = request.user
|
||||
obj.save()
|
||||
|
||||
|
||||
@admin.register(models.Contact)
|
||||
class ContactAdmin(admin.ModelAdmin):
|
||||
"""Contact admin interface declaration."""
|
||||
|
||||
list_display = (
|
||||
"full_name",
|
||||
"owner",
|
||||
"override",
|
||||
)
|
||||
|
||||
|
||||
class OrganizationServiceProviderInline(admin.TabularInline):
|
||||
"""Inline admin class for service providers."""
|
||||
|
||||
can_delete = False
|
||||
model = models.Organization.service_providers.through
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(models.Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for organizations."""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
inlines = (OrganizationAccessInline, OrganizationServiceProviderInline)
|
||||
exclude = ("service_providers",) # Handled by the inline
|
||||
|
||||
|
||||
@admin.register(models.OrganizationAccess)
|
||||
class OrganizationAccessAdmin(admin.ModelAdmin):
|
||||
"""Organization access admin interface declaration."""
|
||||
|
||||
autocomplete_fields = ("user", "organization")
|
||||
list_display = (
|
||||
"user",
|
||||
"organization",
|
||||
"role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.ServiceProvider)
|
||||
class ServiceProviderAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for service providers."""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"audience_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name", "audience_id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People core client API endpoints"""
|
||||
@@ -4,6 +4,7 @@ from rest_framework import exceptions, serializers
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models
|
||||
from core.models import ServiceProvider
|
||||
|
||||
|
||||
class ContactSerializer(serializers.ModelSerializer):
|
||||
@@ -13,17 +14,21 @@ class ContactSerializer(serializers.ModelSerializer):
|
||||
model = models.Contact
|
||||
fields = [
|
||||
"id",
|
||||
"base",
|
||||
"override",
|
||||
"data",
|
||||
"full_name",
|
||||
"notes",
|
||||
"owner",
|
||||
"short_name",
|
||||
]
|
||||
read_only_fields = ["id", "owner"]
|
||||
extra_kwargs = {
|
||||
"override": {"required": False},
|
||||
}
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "base" field readonly but only for update/patch."""
|
||||
validated_data.pop("base", None)
|
||||
"""Make "override" field readonly but only for update/patch."""
|
||||
validated_data.pop("override", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
@@ -34,6 +39,8 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Pass arguments to superclass except 'fields', then drop fields not listed therein."""
|
||||
|
||||
# Don't pass the 'fields' arg up to the superclass
|
||||
fields = kwargs.pop("fields", None)
|
||||
|
||||
@@ -48,12 +55,40 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
self.fields.pop(field_name)
|
||||
|
||||
|
||||
class OrganizationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize organizations."""
|
||||
|
||||
abilities = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
fields = ["id", "name", "registration_id_list", "domain_list", "abilities"]
|
||||
read_only_fields = ["id", "registration_id_list", "domain_list"]
|
||||
|
||||
def get_abilities(self, organization) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return organization.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class UserOrganizationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize organizations for users."""
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
fields = ["id", "name"]
|
||||
read_only_fields = ["id", "name"]
|
||||
|
||||
|
||||
class UserSerializer(DynamicFieldsModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
|
||||
email = serializers.ReadOnlyField()
|
||||
name = serializers.ReadOnlyField()
|
||||
organization = UserOrganizationSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
@@ -62,6 +97,7 @@ class UserSerializer(DynamicFieldsModelSerializer):
|
||||
"email",
|
||||
"language",
|
||||
"name",
|
||||
"organization",
|
||||
"timezone",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
@@ -69,6 +105,41 @@ class UserSerializer(DynamicFieldsModelSerializer):
|
||||
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
|
||||
|
||||
|
||||
class UserMeSerializer(UserSerializer):
|
||||
"""
|
||||
Serialize the current user.
|
||||
|
||||
Same as the `UserSerializer` but with abilities.
|
||||
"""
|
||||
|
||||
abilities = serializers.SerializerMethodField()
|
||||
organization = UserOrganizationSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"email",
|
||||
"id",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
"language",
|
||||
"name",
|
||||
"organization",
|
||||
"timezone",
|
||||
# added fields
|
||||
"abilities",
|
||||
]
|
||||
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
|
||||
|
||||
def get_abilities(self, user: models.User) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
if user != self.context["request"].user: # Should not happen
|
||||
raise RuntimeError(
|
||||
"UserMeSerializer.get_abilities: user is not the same as the request user",
|
||||
)
|
||||
return user.get_abilities()
|
||||
|
||||
|
||||
class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize team accesses."""
|
||||
|
||||
@@ -170,7 +241,9 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
slug = serializers.SerializerMethodField()
|
||||
service_providers = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ServiceProvider.objects.all(), many=True, required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
@@ -179,19 +252,33 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"name",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"service_providers",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new team with organization enforcement."""
|
||||
# When called as a resource server, we enforce the team service provider
|
||||
if sp_audience := self.context.get("from_service_provider_audience", None):
|
||||
service_provider, _created = models.ServiceProvider.objects.get_or_create(
|
||||
audience_id=sp_audience
|
||||
)
|
||||
validated_data["service_providers"] = [service_provider]
|
||||
|
||||
# Note: this is not the purpose of this API to check the user has an organization
|
||||
return super().create(
|
||||
validated_data=validated_data
|
||||
| {"organization_id": self.context["request"].user.organization_id}
|
||||
)
|
||||
|
||||
def get_abilities(self, team) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
@@ -199,10 +286,6 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
return team.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
def get_slug(self, instance):
|
||||
"""Return slug from the team's name."""
|
||||
return instance.get_slug()
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize invitations."""
|
||||
@@ -237,3 +320,12 @@ class InvitationSerializer(serializers.ModelSerializer):
|
||||
attrs["team_id"] = team_id
|
||||
attrs["issuer"] = user
|
||||
return attrs
|
||||
|
||||
|
||||
class ServiceProviderSerializer(serializers.ModelSerializer):
|
||||
"""Serialize service providers."""
|
||||
|
||||
class Meta:
|
||||
model = models.ServiceProvider
|
||||
fields = ["id", "audience_id", "name"]
|
||||
read_only_fields = ["id", "audience_id"]
|
||||
@@ -1,8 +1,7 @@
|
||||
"""API endpoints"""
|
||||
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.conf import settings
|
||||
from django.db.models import OuterRef, Q, Subquery
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
@@ -12,12 +11,14 @@ from rest_framework import (
|
||||
pagination,
|
||||
response,
|
||||
throttling,
|
||||
views,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from core import models
|
||||
|
||||
from . import permissions, serializers
|
||||
from core.api import permissions
|
||||
from core.api.client import serializers
|
||||
|
||||
SIMILARITY_THRESHOLD = 0.04
|
||||
|
||||
@@ -82,16 +83,23 @@ class SerializerPerActionMixin:
|
||||
|
||||
This mixin is useful to avoid to define a serializer class for each action in the
|
||||
`get_serializer_class` method.
|
||||
"""
|
||||
|
||||
serializer_classes: dict[str, type] = {}
|
||||
default_serializer_class: type = None
|
||||
Example:
|
||||
```
|
||||
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
|
||||
serializer_class = MySerializer
|
||||
list_serializer_class = MyListSerializer
|
||||
retrieve_serializer_class = MyRetrieveSerializer
|
||||
```
|
||||
"""
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
Return the serializer class to use depending on the action.
|
||||
"""
|
||||
return self.serializer_classes.get(self.action, self.default_serializer_class)
|
||||
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
|
||||
return serializer_class
|
||||
return super().get_serializer_class()
|
||||
|
||||
|
||||
class Pagination(pagination.PageNumberPagination):
|
||||
@@ -144,22 +152,14 @@ class ContactViewSet(
|
||||
# - are profile contacts for a user
|
||||
user__isnull=True,
|
||||
# - are overriden base contacts
|
||||
overriding_contacts__isnull=True,
|
||||
overridden_by__isnull=True,
|
||||
)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive trigram similarity
|
||||
# Search by case-insensitive and accent-insensitive similarity
|
||||
if query := self.request.GET.get("q", ""):
|
||||
query = Func(Value(query), function="unaccent")
|
||||
similarity = TrigramSimilarity(
|
||||
Func("full_name", function="unaccent"),
|
||||
query,
|
||||
) + TrigramSimilarity(Func("short_name", function="unaccent"), query)
|
||||
queryset = (
|
||||
queryset.annotate(similarity=similarity)
|
||||
.filter(
|
||||
similarity__gte=0.05
|
||||
) # Value determined by testing (test_api_contacts.py)
|
||||
.order_by("-similarity")
|
||||
queryset = queryset.filter(
|
||||
Q(full_name__unaccent__icontains=query)
|
||||
| Q(short_name__unaccent__icontains=query)
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
@@ -172,21 +172,64 @@ class ContactViewSet(
|
||||
return super().perform_create(serializer)
|
||||
|
||||
|
||||
class OrganizationViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
Organization ViewSet
|
||||
|
||||
GET /api/organizations/<organization_id>/
|
||||
Return the organization details for the given id.
|
||||
|
||||
PUT /api/organizations/<organization_id>/
|
||||
Update the organization details for the given id.
|
||||
|
||||
PATCH /api/organizations/<organization_id>/
|
||||
Partially update the organization details for the given id.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.Organization.objects.all()
|
||||
serializer_class = serializers.OrganizationSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Limit listed organizations to the one the user belongs to."""
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(pk=self.request.user.organization_id)
|
||||
.annotate(
|
||||
user_role=Subquery(
|
||||
models.OrganizationAccess.objects.filter(
|
||||
user=self.request.user, organization=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
|
||||
SerializerPerActionMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
):
|
||||
"""
|
||||
User viewset for all interactions with user infos and teams.
|
||||
|
||||
GET /api/users/&q=query
|
||||
Return a list of users whose email matches the query. Similarity is
|
||||
calculated using trigram similarity, allowing for partial,
|
||||
case-insensitive matches and accented queries.
|
||||
Return a list of users whose email or name matches the query.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all().order_by("-created_at")
|
||||
queryset = (
|
||||
models.User.objects.select_related("organization").all().order_by("-created_at")
|
||||
)
|
||||
serializer_class = serializers.UserSerializer
|
||||
get_me_serializer_class = serializers.UserMeSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
|
||||
@@ -204,22 +247,11 @@ class UserViewSet(
|
||||
if team_id := self.request.GET.get("team_id", ""):
|
||||
queryset = queryset.exclude(teams__id=team_id)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive trigram similarity
|
||||
# Search by case-insensitive and accent-insensitive
|
||||
if query := self.request.GET.get("q", ""):
|
||||
similarity = Max(
|
||||
TrigramSimilarity(
|
||||
Coalesce(Func("email", function="unaccent"), Value("")),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
+ TrigramSimilarity(
|
||||
Coalesce(Func("name", function="unaccent"), Value("")),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
)
|
||||
queryset = (
|
||||
queryset.annotate(similarity=similarity)
|
||||
.filter(similarity__gte=SIMILARITY_THRESHOLD)
|
||||
.order_by("-similarity")
|
||||
queryset = queryset.filter(
|
||||
Q(name__unaccent__icontains=query)
|
||||
| Q(email__unaccent__icontains=query)
|
||||
)
|
||||
|
||||
return queryset
|
||||
@@ -235,9 +267,7 @@ class UserViewSet(
|
||||
Return information on currently logged user
|
||||
"""
|
||||
user = request.user
|
||||
return response.Response(
|
||||
self.serializer_class(user, context={"request": request}).data
|
||||
)
|
||||
return response.Response(self.get_serializer(user).data)
|
||||
|
||||
|
||||
class TeamViewSet(
|
||||
@@ -256,14 +286,20 @@ class TeamViewSet(
|
||||
ordering_fields = ["created_at"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related teams."""
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
return models.Team.objects.filter(accesses__user=self.request.user).annotate(
|
||||
user_role=Subquery(user_role_query)
|
||||
|
||||
return (
|
||||
models.Team.objects.prefetch_related("accesses", "service_providers")
|
||||
.filter(
|
||||
accesses__user=self.request.user,
|
||||
)
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -337,6 +373,7 @@ class TeamAccessViewSet(
|
||||
return context
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Chooses list or detail serializer according to the action."""
|
||||
if self.action in {"list", "retrieve"}:
|
||||
return self.list_serializer_class
|
||||
return self.detail_serializer_class
|
||||
@@ -348,20 +385,9 @@ class TeamAccessViewSet(
|
||||
|
||||
if self.action in {"list", "retrieve"}:
|
||||
if query := self.request.GET.get("q", ""):
|
||||
similarity = Max(
|
||||
TrigramSimilarity(
|
||||
Coalesce(Func("user__email", function="unaccent"), Value("")),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
+ TrigramSimilarity(
|
||||
Coalesce(Func("user__name", function="unaccent"), Value("")),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
)
|
||||
queryset = (
|
||||
queryset.annotate(similarity=similarity)
|
||||
.filter(similarity__gte=SIMILARITY_THRESHOLD)
|
||||
.order_by("-similarity")
|
||||
queryset = queryset.filter(
|
||||
Q(user__email__unaccent__icontains=query)
|
||||
| Q(user__name__unaccent__icontains=query)
|
||||
)
|
||||
|
||||
# Determine which role the logged-in user has in the team
|
||||
@@ -491,3 +517,68 @@ class InvitationViewset(
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
class ConfigView(views.APIView):
|
||||
"""API ViewSet for sharing some public settings."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/config/
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = ["LANGUAGES", "FEATURES", "RELEASE", "COMMIT"]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
return response.Response(dict_settings)
|
||||
|
||||
|
||||
class ServiceProviderFilter(filters.BaseFilterBackend):
|
||||
"""
|
||||
Filter service providers.
|
||||
"""
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
"""
|
||||
Filter service providers by audience or name.
|
||||
"""
|
||||
if name := request.GET.get("name"):
|
||||
queryset = queryset.filter(name__icontains=name)
|
||||
if audience_id := request.GET.get("audience_id"):
|
||||
queryset = queryset.filter(audience_id=audience_id)
|
||||
return queryset
|
||||
|
||||
|
||||
class ServiceProviderViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with service providers.
|
||||
|
||||
GET /api/v1.0/service-providers/
|
||||
Return a list of service providers.
|
||||
|
||||
GET /api/v1.0/service-providers/<service_provider_id>/
|
||||
Return a service provider.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
queryset = models.ServiceProvider.objects.all()
|
||||
serializer_class = serializers.ServiceProviderSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
filter_backends = [filters.OrderingFilter, ServiceProviderFilter]
|
||||
ordering = ["name"]
|
||||
ordering_fields = ["name", "created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter the queryset to limit results to user's organization."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(organizations__id=self.request.user.organization_id)
|
||||
return queryset
|
||||
@@ -12,13 +12,13 @@ class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check auth token first."""
|
||||
return bool(request.auth) if request.auth else request.user.is_authenticated
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
of the auth token to avoid hitting the database.
|
||||
Allows access only to user's own data.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People core resource server API endpoints"""
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Client serializers for the People core app resource server API."""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Create a new team with organization enforcement.
|
||||
|
||||
In this context, called as a resource server,
|
||||
the team service provider is enforced.
|
||||
|
||||
When the service provider audience is unknown it is created on the fly.
|
||||
"""
|
||||
sp_audience = self.context["from_service_provider_audience"]
|
||||
service_provider, _created = models.ServiceProvider.objects.get_or_create(
|
||||
audience_id=sp_audience
|
||||
)
|
||||
|
||||
# Note: this is not the purpose of this API to check the user has an organization
|
||||
return super().create(
|
||||
validated_data=validated_data
|
||||
| {
|
||||
"organization_id": self.context["request"].user.organization_id,
|
||||
"service_providers": [service_provider],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Resource server API endpoints"""
|
||||
|
||||
from django.db.models import OuterRef, Prefetch, Subquery
|
||||
|
||||
from rest_framework import (
|
||||
filters,
|
||||
mixins,
|
||||
viewsets,
|
||||
)
|
||||
|
||||
from core import models
|
||||
from core.api import permissions
|
||||
from core.api.client.viewsets import Pagination
|
||||
from core.resource_server.mixins import ResourceServerMixin
|
||||
|
||||
from . import serializers
|
||||
|
||||
|
||||
class TeamViewSet( # pylint: disable=too-many-ancestors
|
||||
ResourceServerMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
Team ViewSet dedicated to the resource server.
|
||||
|
||||
The DELETE method is not allowed for now, because the use case is
|
||||
not clear yet and it comes with complexity to know if we can delete
|
||||
a team or not (eg. if a team has other SP, it might not be deleted
|
||||
but what do we do then, only remove the current SP?).
|
||||
|
||||
GET /resource-server/v1.0/teams/
|
||||
Return list of Teams of the user and available for the audience.
|
||||
|
||||
POST /resource-server/v1.0/teams/
|
||||
Create a new Team for the user for the audience.
|
||||
|
||||
GET /resource-server/v1.0/teams/{team_id}/
|
||||
Return the Team details if available for the audience.
|
||||
|
||||
PUT /resource-server/v1.0/teams/{team_id}/
|
||||
Update the Team details (only name for now).
|
||||
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
serializer_class = serializers.TeamSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
pagination_class = Pagination
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related teams."""
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
|
||||
service_provider_audience = self._get_service_provider_audience()
|
||||
service_provider_prefetch = Prefetch(
|
||||
"service_providers",
|
||||
queryset=models.ServiceProvider.objects.filter(
|
||||
audience_id=service_provider_audience
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
models.Team.objects.prefetch_related(
|
||||
"accesses",
|
||||
service_provider_prefetch,
|
||||
)
|
||||
.filter(
|
||||
accesses__user=self.request.user,
|
||||
service_providers__audience_id=service_provider_audience,
|
||||
)
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created team."""
|
||||
team = serializer.save()
|
||||
models.TeamAccess.objects.create(
|
||||
team=team,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Authentication module."""
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Authentication Backends for the People core app."""
|
||||
|
||||
import logging
|
||||
from email.headerregistry import Address
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -9,6 +14,25 @@ from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import (
|
||||
Contact,
|
||||
Organization,
|
||||
OrganizationAccess,
|
||||
OrganizationRoleChoices,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def get_domain_from_email(email: Optional[str]) -> Optional[str]:
|
||||
"""Extract domain from email."""
|
||||
try:
|
||||
return Address(addr_spec=email).domain
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
@@ -48,7 +72,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
"""Return a User based on userinfo. Create a new user if no match is found.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
@@ -64,30 +88,69 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
# Compute user name from OIDC name fields as defined in settings
|
||||
names_list = [
|
||||
user_info[field]
|
||||
for field in settings.USER_OIDC_FIELDS_TO_NAME
|
||||
if user_info.get(field)
|
||||
]
|
||||
user_info["name"] = " ".join(names_list) or None
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if sub is None:
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
try:
|
||||
user = self.UserModel.objects.get(sub=sub)
|
||||
except self.UserModel.DoesNotExist:
|
||||
if self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(user_info)
|
||||
else:
|
||||
email = user_info.get("email")
|
||||
name = user_info.get("name")
|
||||
if email and email != user.email or name and name != user.name:
|
||||
self.UserModel.objects.filter(sub=sub).update(email=email, name=name)
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
full_name = self.compute_full_name(user_info)
|
||||
email = user_info.get("email")
|
||||
|
||||
claims = {
|
||||
"sub": sub,
|
||||
"email": email,
|
||||
"name": full_name,
|
||||
}
|
||||
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
|
||||
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
)
|
||||
|
||||
# if sub is absent, try matching on email
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
if user:
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
self.update_user_if_needed(user, claims)
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(claims)
|
||||
|
||||
# Data cleaning, to be removed when user organization is null=False
|
||||
# or all users have an organization.
|
||||
# See https://github.com/numerique-gouv/people/issues/504
|
||||
if not user.organization_id:
|
||||
organization_registration_id = claims.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
)
|
||||
domain = get_domain_from_email(email)
|
||||
try:
|
||||
organization, organization_created = (
|
||||
Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id=organization_registration_id,
|
||||
domain=domain,
|
||||
)
|
||||
)
|
||||
if organization_created:
|
||||
logger.info("Organization %s created", organization)
|
||||
# For this case, we don't create an OrganizationAccess we will
|
||||
# manage this manually later, because we don't want the first
|
||||
# user who log in after the release to be the admin of their
|
||||
# organization. We will keep organization without admin, and
|
||||
# we will have to manually clean things up (while there is
|
||||
# not that much organization in the database).
|
||||
except ValueError as exc:
|
||||
# Raised when there is no recognizable organization
|
||||
# identifier (domain or registration_id)
|
||||
logger.warning("Unable to update user organization: %s", exc)
|
||||
else:
|
||||
user.organization = organization
|
||||
user.save()
|
||||
logger.info(
|
||||
"User %s updated with organization %s", user.pk, organization
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -98,10 +161,88 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable user identification")
|
||||
)
|
||||
email = claims.get("email")
|
||||
name = claims.get("name")
|
||||
|
||||
return self.UserModel.objects.create(
|
||||
# Extract or create the organization from the data
|
||||
organization_registration_id = claims.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
)
|
||||
domain = get_domain_from_email(email)
|
||||
try:
|
||||
organization, organization_created = (
|
||||
Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id=organization_registration_id,
|
||||
domain=domain,
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable organization identification")
|
||||
) from exc
|
||||
|
||||
if organization_created:
|
||||
logger.info("Organization %s created", organization)
|
||||
|
||||
logger.info("Creating user %s / %s", sub, email)
|
||||
|
||||
user = self.UserModel.objects.create(
|
||||
organization=organization,
|
||||
password="!", # noqa: S106
|
||||
sub=sub,
|
||||
email=claims.get("email"),
|
||||
name=claims.get("name"),
|
||||
email=email,
|
||||
name=name,
|
||||
)
|
||||
if organization_created:
|
||||
# Warning: we may remove this behavior in the near future when we
|
||||
# add a feature to claim the organization ownership.
|
||||
OrganizationAccess.objects.create(
|
||||
organization=organization,
|
||||
user=user,
|
||||
role=OrganizationRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
# Initiate the user's profile
|
||||
Contact.objects.create(
|
||||
owner=user,
|
||||
user=user,
|
||||
full_name=name or email,
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": email},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def compute_full_name(self, user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
name_fields = settings.USER_OIDC_FIELDS_TO_NAME
|
||||
full_name = " ".join(
|
||||
user_info[field] for field in name_fields if user_info.get(field)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
updated_claims = {}
|
||||
for key in ["email", "name"]:
|
||||
claim_value = claims.get(key)
|
||||
if claim_value and claim_value != getattr(user, key):
|
||||
updated_claims[key] = claim_value
|
||||
|
||||
if updated_claims:
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -115,8 +115,52 @@ class ContactFactory(BaseContactFactory):
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
|
||||
base = factory.SubFactory("core.factories.ContactFactory", base=None, owner=None)
|
||||
owner = factory.SubFactory("core.factories.UserFactory", profile_contact=None)
|
||||
owner = factory.SubFactory("core.factories.UserFactory")
|
||||
|
||||
|
||||
class OverrideContactFactory(BaseContactFactory):
|
||||
"""A factory to create contacts for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
|
||||
override = factory.SubFactory("core.factories.ContactFactory", owner=None)
|
||||
owner = factory.SubFactory("core.factories.UserFactory")
|
||||
|
||||
|
||||
class OrganizationFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory to create organizations for testing purposes."""
|
||||
|
||||
name = factory.Faker("company")
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
|
||||
class Params: # pylint: disable=missing-class-docstring
|
||||
with_registration_id = factory.Trait(
|
||||
registration_id_list=factory.List(
|
||||
[factory.Sequence(lambda n: f"{n:014d}")]
|
||||
),
|
||||
)
|
||||
with_domain = factory.Trait(
|
||||
domain_list=factory.List([factory.Faker("domain_name")]),
|
||||
)
|
||||
|
||||
|
||||
class OrganizationAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory to create organization accesses for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.OrganizationAccess
|
||||
|
||||
user = factory.SubFactory(
|
||||
"core.factories.UserFactory",
|
||||
organization=factory.SelfAttribute("..organization"),
|
||||
)
|
||||
organization = factory.SubFactory(
|
||||
"core.factories.OrganizationFactory", with_registration_id=True
|
||||
)
|
||||
role = factory.fuzzy.FuzzyChoice(models.OrganizationRoleChoices.values)
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
@@ -126,6 +170,13 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
model = models.User
|
||||
django_get_or_create = ("sub",)
|
||||
|
||||
class Params:
|
||||
with_organization = factory.Trait(
|
||||
organization=factory.SubFactory(
|
||||
OrganizationFactory, with_registration_id=True
|
||||
),
|
||||
)
|
||||
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
name = factory.Faker("name")
|
||||
@@ -154,6 +205,13 @@ class TeamFactory(factory.django.DjangoModelFactory):
|
||||
else:
|
||||
TeamAccessFactory(team=self, user=user_entry[0], role=user_entry[1])
|
||||
|
||||
@factory.post_generation
|
||||
def service_providers(self, create, extracted, **kwargs):
|
||||
"""Add service providers to team from a given list of service providers."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.service_providers.set(extracted)
|
||||
|
||||
|
||||
class TeamAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake team user accesses for testing."""
|
||||
@@ -186,3 +244,27 @@ class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
email = factory.Faker("email")
|
||||
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
||||
issuer = factory.SubFactory(UserFactory)
|
||||
|
||||
|
||||
class ServiceProviderFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create service providers for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.ServiceProvider
|
||||
skip_postgeneration_save = True
|
||||
|
||||
audience_id = factory.Faker("uuid4")
|
||||
|
||||
@factory.post_generation
|
||||
def teams(self, create, extracted, **kwargs):
|
||||
"""Add teams to service provider from a given list."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.teams.set(extracted)
|
||||
|
||||
@factory.post_generation
|
||||
def organizations(self, create, extracted, **kwargs):
|
||||
"""Add organization to service provider from a given list."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.organizations.set(extracted)
|
||||
|
||||
@@ -146,11 +146,11 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
|
||||
constraint=models.CheckConstraint(condition=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
|
||||
constraint=models.CheckConstraint(condition=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-22 10:07
|
||||
|
||||
import core.models
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Organization',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('name', models.CharField(max_length=100, verbose_name='name')),
|
||||
('registration_id_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=128), blank=True, default=list, size=None, verbose_name='registration ID list')),
|
||||
('domain_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=256), blank=True, default=list, size=None, verbose_name='domain list')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'organization',
|
||||
'verbose_name_plural': 'organizations',
|
||||
'db_table': 'people_organization',
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('registration_id_list__len__gt', 0), ('domain_list__len__gt', 0), _connector='OR'), name='registration_id_or_domain', violation_error_message='An organization must have at least a registration ID or a domain.')],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='organization',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='teams', to='core.organization'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='organization',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='core.organization'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganizationAccess',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('role', models.CharField(choices=[('administrator', 'Administrator')], default='administrator', max_length=20)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_accesses', to='core.organization')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_accesses', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organization/user relation',
|
||||
'verbose_name_plural': 'Organization/user relations',
|
||||
'db_table': 'people_organization_access',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'organization'), name='unique_organization_user', violation_error_message='This user is already in this organization.')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-04 14:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_add_organization_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-20 10:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_team_slug_nullable'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='team',
|
||||
name='slug',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-07 16:24
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_remove_team_slug'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ServiceProvider',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('name', models.CharField(max_length=256, unique=True, verbose_name='name')),
|
||||
('audience_id', models.CharField(db_index=True, max_length=256, unique=True, verbose_name='audience id')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'service provider',
|
||||
'verbose_name_plural': 'service providers',
|
||||
'db_table': 'people_service_provider',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='service_providers',
|
||||
field=models.ManyToManyField(blank=True, related_name='organizations', to='core.serviceprovider'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='service_providers',
|
||||
field=models.ManyToManyField(blank=True, related_name='teams', to='core.serviceprovider'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-28 09:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_add_serviceprovider'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, default='', verbose_name='notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contact',
|
||||
name='full_name',
|
||||
field=models.CharField(default='-', max_length=150, verbose_name='full name'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 10:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_contact_notes_alter_contact_full_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='contact',
|
||||
name='base_owner_constraint',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='contact',
|
||||
name='base_not_self',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='override',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='overridden_by', to='core.contact'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('override__isnull', False), ('owner__isnull', True), _negated=True), name='override_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('override', models.F('id')), _negated=True), name='override_not_self', violation_error_message='A contact cannot override itself.'),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contact',
|
||||
name='base',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
unique_together={('owner', 'override')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 10:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def init_profile_contact(apps, schema_editor):
|
||||
"""Create a Contact for each User to be used as their profile."""
|
||||
User = apps.get_model("core", "User")
|
||||
Contact = apps.get_model("core", "Contact")
|
||||
|
||||
for user in User.objects.all(): # This is a small table for now
|
||||
Contact.objects.create(
|
||||
owner=user,
|
||||
user=user,
|
||||
full_name=user.name or user.email,
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": user.email},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_rename_contact_base_to_override'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='profile_contact',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='user',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='profile_contact', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('user__isnull', True), models.Q(('owner', models.F('user')), ('owner__isnull', False)), _connector='OR'), name='profile_contact_owner_constraint', violation_error_message='Users can only declare as profile a contact they own.'),
|
||||
),
|
||||
migrations.RunPython(init_profile_contact, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
+362
-38
@@ -6,19 +6,22 @@ import json
|
||||
import os
|
||||
import smtplib
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
from typing import Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import override
|
||||
|
||||
@@ -27,6 +30,7 @@ from timezone_field import TimeZoneField
|
||||
|
||||
from core.enums import WebhookStatusChoices
|
||||
from core.utils.webhooks import scim_synchronizer
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -36,7 +40,7 @@ with open(contact_schema_path, "r", encoding="utf-8") as contact_schema_file:
|
||||
contact_schema = json.load(contact_schema_file)
|
||||
|
||||
|
||||
class RoleChoices(models.TextChoices):
|
||||
class RoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
|
||||
"""Defines the possible roles a user can have in a team."""
|
||||
|
||||
MEMBER = "member", _("Member")
|
||||
@@ -44,6 +48,17 @@ class RoleChoices(models.TextChoices):
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
|
||||
class OrganizationRoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
|
||||
"""
|
||||
Defines the possible roles a user can have in an organization.
|
||||
For now, we only have one role, but we might add more in the future.
|
||||
|
||||
administrator: The user can manage the organization: change name, add/remove users.
|
||||
"""
|
||||
|
||||
ADMIN = "administrator", _("Administrator")
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -84,10 +99,10 @@ class BaseModel(models.Model):
|
||||
class Contact(BaseModel):
|
||||
"""User contacts"""
|
||||
|
||||
base = models.ForeignKey(
|
||||
override = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="overriding_contacts",
|
||||
related_name="overridden_by",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
@@ -98,11 +113,19 @@ class Contact(BaseModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
full_name = models.CharField(_("full name"), max_length=150, null=True, blank=True)
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="profile_contact",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
full_name = models.CharField(_("full name"), max_length=150)
|
||||
short_name = models.CharField(_("short name"), max_length=30, null=True, blank=True)
|
||||
|
||||
# avatar =
|
||||
# notes =
|
||||
notes = models.TextField(_("notes"), blank=True, default="")
|
||||
data = models.JSONField(
|
||||
_("contact information"),
|
||||
help_text=_("A JSON object containing the contact information"),
|
||||
@@ -121,31 +144,38 @@ class Contact(BaseModel):
|
||||
ordering = ("full_name", "short_name")
|
||||
verbose_name = _("contact")
|
||||
verbose_name_plural = _("contacts")
|
||||
unique_together = ("owner", "base")
|
||||
unique_together = ("owner", "override")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=~models.Q(base__isnull=False, owner__isnull=True),
|
||||
name="base_owner_constraint",
|
||||
condition=~models.Q(override__isnull=False, owner__isnull=True),
|
||||
name="override_owner_constraint",
|
||||
violation_error_message="A contact overriding a base contact must be owned.",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=~models.Q(base=models.F("id")),
|
||||
name="base_not_self",
|
||||
violation_error_message="A contact cannot be based on itself.",
|
||||
condition=~models.Q(override=models.F("id")),
|
||||
name="override_not_self",
|
||||
violation_error_message="A contact cannot override itself.",
|
||||
),
|
||||
# When a user is set, the owner must be the user
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(user__isnull=True)
|
||||
| models.Q(owner=models.F("user"), owner__isnull=False),
|
||||
name="profile_contact_owner_constraint",
|
||||
violation_error_message="Users can only declare as profile a contact they own.",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name or self.short_name
|
||||
return self.full_name
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
# Check if the contact points to a base contact that itself points to another base contact
|
||||
if self.base_id and self.base.base_id:
|
||||
# Check if the contact overrides a contact that itself overrides another base contact
|
||||
if self.override_id and self.override.override_id:
|
||||
raise exceptions.ValidationError(
|
||||
"A contact cannot point to a base contact that itself points to a base contact."
|
||||
"A contact cannot override a contact that itself overrides a contact."
|
||||
)
|
||||
|
||||
# Validate the content of the "data" field against our jsonschema definition
|
||||
@@ -158,6 +188,203 @@ class Contact(BaseModel):
|
||||
raise exceptions.ValidationError({"data": [error_message]}) from e
|
||||
|
||||
|
||||
class ServiceProvider(BaseModel):
|
||||
"""
|
||||
Represents a service provider that will consume our information.
|
||||
|
||||
Organization uses this model to define the list of SP available to their users.
|
||||
Team uses this model to define their visibility to the various SP.
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=256, unique=True)
|
||||
audience_id = models.CharField(
|
||||
_("audience id"), max_length=256, unique=True, db_index=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_service_provider"
|
||||
verbose_name = _("service provider")
|
||||
verbose_name_plural = _("service providers")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Enforce name (even if ugly) from the `audience_id` field."""
|
||||
if not self.name:
|
||||
self.name = self.audience_id # ok, same length
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class OrganizationManager(models.Manager):
|
||||
"""
|
||||
Custom manager for the Organization model, to manage complexity/automation.
|
||||
"""
|
||||
|
||||
def get_or_create_from_user_claims(
|
||||
self, registration_id: str = None, domain: str = None, **kwargs
|
||||
) -> Tuple["Organization", bool]:
|
||||
"""
|
||||
Get or create an organization using the most fitting information from the user's claims.
|
||||
|
||||
We expect to have only one organization per registration_id, but
|
||||
the registration_id might not be provided.
|
||||
When the registration_id is not provided, we use the domain to identify the organization.
|
||||
|
||||
If both are provided, we use the registration_id first to create missing organization.
|
||||
|
||||
Dev note: When a registration_id is provided by the Identity Provider, we don't want
|
||||
to use the domain to create the organization, because it is less reliable: for example,
|
||||
a professional user, may have a personal email address, and the domain would be gmail.com
|
||||
which is not a good identifier for an organization. The domain email is just a fallback
|
||||
when the registration_id is not provided by the Identity Provider. We can use the domain
|
||||
to create the organization manually when we are sure about the "safety" of it.
|
||||
"""
|
||||
if not any([registration_id, domain]):
|
||||
raise ValueError("You must provide either a registration_id or a domain.")
|
||||
|
||||
filters = models.Q()
|
||||
if registration_id:
|
||||
filters |= models.Q(registration_id_list__icontains=registration_id)
|
||||
if domain:
|
||||
filters |= models.Q(domain_list__icontains=domain)
|
||||
|
||||
with suppress(self.model.DoesNotExist):
|
||||
# If there are several organizations, we must raise an error and fix the data
|
||||
# If there is an organization, we return it
|
||||
return self.get(filters, **kwargs), False
|
||||
|
||||
# Manage the case where the organization does not exist: we create one
|
||||
if registration_id:
|
||||
return self.create(
|
||||
name=registration_id, registration_id_list=[registration_id], **kwargs
|
||||
), True
|
||||
|
||||
if domain:
|
||||
return self.create(name=domain, domain_list=[domain], **kwargs), True
|
||||
|
||||
raise ValueError("Should never reach this point.")
|
||||
|
||||
|
||||
class Organization(BaseModel):
|
||||
"""
|
||||
Organization model used to regroup Teams.
|
||||
|
||||
Each User have an Organization, which corresponds actually to a default organization
|
||||
because a user can belong to a Team from another organization.
|
||||
Each Team have an Organization, which is the Organization from the User who created
|
||||
the Team.
|
||||
|
||||
Organization is managed automatically, the User should never choose their Organization.
|
||||
When creating a User, you must use the `get_or_create` method from the
|
||||
OrganizationManager to find the proper Organization.
|
||||
|
||||
An Organization can have several registration IDs and domains but during automatic
|
||||
creation process, only one will be used. We may want to allow (manual) organization merge
|
||||
later, to regroup several registration IDs or domain in the same Organization.
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=100)
|
||||
registration_id_list = ArrayField(
|
||||
models.CharField(
|
||||
max_length=128,
|
||||
validators=get_field_validators_from_setting(
|
||||
"ORGANIZATION_REGISTRATION_ID_VALIDATORS"
|
||||
),
|
||||
),
|
||||
verbose_name=_("registration ID list"),
|
||||
default=list,
|
||||
blank=True,
|
||||
# list overlap validation is done in the validate_unique method
|
||||
)
|
||||
domain_list = ArrayField(
|
||||
models.CharField(max_length=256),
|
||||
verbose_name=_("domain list"),
|
||||
default=list,
|
||||
blank=True,
|
||||
# list overlap validation is done in the validate_unique method
|
||||
)
|
||||
|
||||
service_providers = models.ManyToManyField(
|
||||
ServiceProvider,
|
||||
related_name="organizations",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = OrganizationManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "people_organization"
|
||||
verbose_name = _("organization")
|
||||
verbose_name_plural = _("organizations")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
name="registration_id_or_domain",
|
||||
condition=models.Q(registration_id_list__len__gt=0)
|
||||
| models.Q(domain_list__len__gt=0),
|
||||
violation_error_message=_(
|
||||
"An organization must have at least a registration ID or a domain."
|
||||
),
|
||||
),
|
||||
# Check a registration ID str can only be present in one
|
||||
# organization registration ID list
|
||||
# Check a domain str can only be present in one organization domain list
|
||||
# Those checks cannot be done with Django constraints
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} (# {self.pk})"
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""
|
||||
Validate Registration/Domain values in an array field are unique
|
||||
across all instances.
|
||||
|
||||
This can't be done in the field validator because we need to
|
||||
exclude the current object if already in database.
|
||||
"""
|
||||
super().validate_unique(exclude=exclude)
|
||||
|
||||
if self.pk:
|
||||
organization_qs = Organization.objects.exclude(pk=self.pk)
|
||||
else:
|
||||
organization_qs = Organization.objects.all()
|
||||
|
||||
# Check a registration ID can only be present in one organization
|
||||
if (
|
||||
self.registration_id_list
|
||||
and organization_qs.filter(
|
||||
registration_id_list__overlap=self.registration_id_list
|
||||
).exists()
|
||||
):
|
||||
raise ValidationError(
|
||||
"registration_id_list value must be unique across all instances."
|
||||
)
|
||||
|
||||
# Check a domain can only be present in one organization
|
||||
if (
|
||||
self.domain_list
|
||||
and organization_qs.filter(domain_list__overlap=self.domain_list).exists()
|
||||
):
|
||||
raise ValidationError(
|
||||
"domain_list value must be unique across all instances."
|
||||
)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the organization.
|
||||
"""
|
||||
# Use the role from queryset annotation will raise on purpose if not used properly
|
||||
is_admin = self.user_role == OrganizationRoleChoices.ADMIN # pylint: disable=no-member
|
||||
|
||||
return {
|
||||
"get": user.organization_id == self.pk,
|
||||
"patch": is_admin,
|
||||
"put": is_admin,
|
||||
"delete": False,
|
||||
}
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
@@ -180,13 +407,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
)
|
||||
email = models.EmailField(_("email address"), null=True, blank=True)
|
||||
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
|
||||
profile_contact = models.OneToOneField(
|
||||
Contact,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="user",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
@@ -218,6 +438,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"Unselect this instead of deleting accounts."
|
||||
),
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
Organization,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="users",
|
||||
null=True, # Need to be set to False when everything is migrated
|
||||
blank=True, # Need to be set to False when everything is migrated
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
@@ -248,11 +475,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
if self.email:
|
||||
self.email = User.objects.normalize_email(self.email)
|
||||
|
||||
if self.profile_contact_id and not self.profile_contact.owner == self:
|
||||
raise exceptions.ValidationError(
|
||||
"Users can only declare as profile a contact they own."
|
||||
)
|
||||
|
||||
def _convert_valid_invitations(self):
|
||||
"""
|
||||
Convert valid invitations to team accesses.
|
||||
@@ -284,14 +506,113 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
raise ValueError("You must first set an email for the user.")
|
||||
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
|
||||
def get_abilities(self):
|
||||
"""
|
||||
Return the user permissions at the application level (ie feature availability).
|
||||
|
||||
Note: this is for display purposes only for now.
|
||||
|
||||
- Contacts view and creation is globally available (or not) for now.
|
||||
- Teams view is available if the user is owner or admin of at least
|
||||
one Team for now. It allows to restrict users knowing the existence of
|
||||
this feature.
|
||||
- Teams creation is globally available (or not) for now.
|
||||
- Mailboxes view is available if the user has access to at least one
|
||||
MailDomain for now. It allows to restrict users knowing the existence of
|
||||
this feature.
|
||||
- Mailboxes creation is globally available (or not) for now.
|
||||
"""
|
||||
user_info = (
|
||||
self._meta.model.objects.filter(pk=self.pk)
|
||||
.annotate(
|
||||
teams_can_view=models.Exists(
|
||||
self.accesses.model.objects.filter( # pylint: disable=no-member
|
||||
user=models.OuterRef("pk"),
|
||||
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN],
|
||||
)
|
||||
),
|
||||
mailboxes_can_view=models.Exists(
|
||||
self.mail_domain_accesses.model.objects.filter( # pylint: disable=no-member
|
||||
user=models.OuterRef("pk")
|
||||
)
|
||||
),
|
||||
)
|
||||
.only("id")
|
||||
.get()
|
||||
)
|
||||
|
||||
teams_can_view = user_info.teams_can_view
|
||||
mailboxes_can_view = user_info.mailboxes_can_view
|
||||
|
||||
return {
|
||||
"contacts": {
|
||||
"can_view": settings.FEATURES["CONTACTS_DISPLAY"],
|
||||
"can_create": (
|
||||
settings.FEATURES["CONTACTS_DISPLAY"]
|
||||
and settings.FEATURES["CONTACTS_CREATE"]
|
||||
),
|
||||
},
|
||||
"teams": {
|
||||
"can_view": teams_can_view and settings.FEATURES["TEAMS_DISPLAY"],
|
||||
"can_create": teams_can_view and settings.FEATURES["TEAMS_CREATE"],
|
||||
},
|
||||
"mailboxes": {
|
||||
"can_view": mailboxes_can_view,
|
||||
"can_create": mailboxes_can_view
|
||||
and settings.FEATURES["MAILBOXES_CREATE"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class OrganizationAccess(BaseModel):
|
||||
"""
|
||||
Link table between organization and users,
|
||||
only for user with specific rights on Organization.
|
||||
"""
|
||||
|
||||
organization = models.ForeignKey(
|
||||
Organization,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="organization_accesses",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="organization_accesses",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=OrganizationRoleChoices.choices,
|
||||
default=OrganizationRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_organization_access"
|
||||
verbose_name = _("Organization/user relation")
|
||||
verbose_name_plural = _("Organization/user relations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "organization"],
|
||||
name="unique_organization_user",
|
||||
violation_error_message=_("This user is already in this organization."),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user!s} is {self.role:s} in organization {self.organization!s}"
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
"""
|
||||
Represents the link between teams and users, specifying the role a user has in a team.
|
||||
|
||||
When a team is created from here, the user have to choose which Service Providers
|
||||
can see it.
|
||||
When a team is created from a Service Provider this one is automatically set in the
|
||||
Team `service_providers`.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(max_length=100, unique=True, null=False, editable=False)
|
||||
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
@@ -299,6 +620,18 @@ class Team(BaseModel):
|
||||
through_fields=("team", "user"),
|
||||
related_name="teams",
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
Organization,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="teams",
|
||||
null=True, # Need to be set to False when everything is migrated
|
||||
blank=True, # Need to be set to False when everything is migrated
|
||||
)
|
||||
service_providers = models.ManyToManyField(
|
||||
ServiceProvider,
|
||||
related_name="teams",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_team"
|
||||
@@ -309,15 +642,6 @@ class Team(BaseModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save function to compute the slug."""
|
||||
self.slug = self.get_slug()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_slug(self):
|
||||
"""Compute slug value from name."""
|
||||
return slugify(self.name)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the team.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend resource server module."""
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Resource Server Authentication"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from mozilla_django_oidc.contrib.drf import OIDCAuthentication
|
||||
|
||||
from .backend import ResourceServerBackend, ResourceServerImproperlyConfiguredBackend
|
||||
from .clients import AuthorizationServerClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceServerAuthentication(OIDCAuthentication):
|
||||
"""Authenticate clients using the token received from the authorization server."""
|
||||
|
||||
def __init__(self):
|
||||
"""Require authentication to be configured in order to instantiate."""
|
||||
super().__init__()
|
||||
|
||||
try:
|
||||
authorization_server_client = AuthorizationServerClient(
|
||||
url=settings.OIDC_OP_URL,
|
||||
verify_ssl=settings.OIDC_VERIFY_SSL,
|
||||
timeout=settings.OIDC_TIMEOUT,
|
||||
proxy=settings.OIDC_PROXY,
|
||||
url_jwks=settings.OIDC_OP_JWKS_ENDPOINT,
|
||||
url_introspection=settings.OIDC_OP_INTROSPECTION_ENDPOINT,
|
||||
)
|
||||
self.backend = ResourceServerBackend(authorization_server_client)
|
||||
|
||||
except ImproperlyConfigured as err:
|
||||
message = "Resource Server authentication is disabled"
|
||||
logger.debug("%s. Exception: %s", message, err)
|
||||
self.backend = ResourceServerImproperlyConfiguredBackend()
|
||||
|
||||
def get_access_token(self, request):
|
||||
"""Retrieve and decode the access token from the request.
|
||||
|
||||
This method overrides the 'get_access_token' method from the parent class,
|
||||
to support service providers that would base64 encode the bearer token.
|
||||
"""
|
||||
|
||||
access_token = super().get_access_token(request)
|
||||
|
||||
try:
|
||||
access_token = base64.b64decode(access_token).decode("utf-8")
|
||||
except (binascii.Error, TypeError):
|
||||
pass
|
||||
|
||||
return access_token
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the request and return a tuple of (user, token) or None.
|
||||
|
||||
We override the 'authenticate' method from the parent class to store
|
||||
the introspected token audience inside the request.
|
||||
"""
|
||||
result = super().authenticate(request) # Might raise AuthenticationFailed
|
||||
|
||||
if result is None: # Case when there is no access token
|
||||
return None
|
||||
|
||||
# Note: at this stage, the request is a "drf_request" object
|
||||
request.resource_server_token_audience = self.backend.token_origin_audience
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Resource Server Backend"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.errors import InvalidClaimError, InvalidTokenError
|
||||
from requests.exceptions import HTTPError
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from . import utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceServerBackend:
|
||||
"""Backend of an OAuth 2.0 resource server.
|
||||
|
||||
This backend is designed to authenticate resource owners to our API using the access token
|
||||
they received from the authorization server.
|
||||
|
||||
In the context of OAuth 2.0, a resource server is a server that hosts protected resources and
|
||||
is capable of accepting and responding to protected resource requests using access tokens.
|
||||
The resource server verifies the validity of the access tokens issued by the authorization
|
||||
server to ensure secure access to the resources.
|
||||
|
||||
For more information, visit: https://www.oauth.com/oauth2-servers/the-resource-server/
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
def __init__(self, authorization_server_client):
|
||||
"""Require client_id, client_secret set and authorization_server_client provided."""
|
||||
# pylint: disable=invalid-name
|
||||
self.UserModel = auth.get_user_model()
|
||||
|
||||
self._client_id = settings.OIDC_RS_CLIENT_ID
|
||||
self._client_secret = settings.OIDC_RS_CLIENT_SECRET
|
||||
self._encryption_encoding = settings.OIDC_RS_ENCRYPTION_ENCODING
|
||||
self._encryption_algorithm = settings.OIDC_RS_ENCRYPTION_ALGO
|
||||
self._signing_algorithm = settings.OIDC_RS_SIGNING_ALGO
|
||||
self._scopes = settings.OIDC_RS_SCOPES
|
||||
|
||||
if (
|
||||
not self._client_id
|
||||
or not self._client_secret
|
||||
or not authorization_server_client
|
||||
):
|
||||
raise ImproperlyConfigured(
|
||||
"Could not instantiate ResourceServerBackend, some parameters are missing."
|
||||
)
|
||||
|
||||
self._authorization_server_client = authorization_server_client
|
||||
|
||||
self._claims_registry = jose_jwt.JWTClaimsRegistry(
|
||||
iss={"essential": True, "value": self._authorization_server_client.url},
|
||||
aud={"essential": True, "value": self._client_id},
|
||||
token_introspection={"essential": True},
|
||||
)
|
||||
|
||||
# Declare the token origin audience: to know where the token comes from
|
||||
# and store it for further use in the application
|
||||
self.token_origin_audience = None
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Maintain API compatibility with OIDCAuthentication class from mozilla-django-oidc
|
||||
|
||||
Params 'id_token', 'payload' won't be used, and our implementation will only
|
||||
support 'get_user', not 'get_or_create_user'.
|
||||
"""
|
||||
|
||||
return self.get_user(access_token)
|
||||
|
||||
def get_user(self, access_token):
|
||||
"""Get user from an access token emitted by the authorization server.
|
||||
|
||||
This method will submit the access token to the authorization server for
|
||||
introspection, to ensure its validity and obtain the associated metadata.
|
||||
|
||||
It follows the specifications outlined in RFC7662 https://www.rfc-editor.org/info/rfc7662,
|
||||
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
|
||||
|
||||
In our eGovernment applications, the standard RFC 7662 doesn't provide sufficient security.
|
||||
Its introspection response is a plain JSON object. Therefore, we use the draft RFC
|
||||
that extends RFC 7662 by returning a signed and encrypted JWT for stronger assurance that
|
||||
the authorization server issued the token introspection response.
|
||||
"""
|
||||
self.token_origin_audience = None # Reset the token origin audience
|
||||
|
||||
jwt = self._introspect(access_token)
|
||||
claims = self._verify_claims(jwt)
|
||||
user_info = self._verify_user_info(claims["token_introspection"])
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if sub is None:
|
||||
message = "User info contained no recognizable user identification"
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
try:
|
||||
user = self.UserModel.objects.get(sub=sub)
|
||||
except self.UserModel.DoesNotExist:
|
||||
logger.debug("Login failed: No user with %s found", sub)
|
||||
return None
|
||||
|
||||
self.token_origin_audience = str(user_info["aud"])
|
||||
|
||||
return user
|
||||
|
||||
def _verify_user_info(self, introspection_response):
|
||||
"""Verify the 'introspection_response' to get valid and relevant user info.
|
||||
|
||||
The 'introspection_response' should be still active, and while authenticating
|
||||
the resource owner should have requested relevant scope to access her data in
|
||||
our resource server.
|
||||
|
||||
Scope should be configured to match between the AS and the RS. The AS will filter
|
||||
all the scopes the resource owner requested to expose only the relevant ones to
|
||||
our resource server.
|
||||
"""
|
||||
|
||||
active = introspection_response.get("active", None)
|
||||
|
||||
if not active:
|
||||
message = "Introspection response is not active."
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
requested_scopes = introspection_response.get("scope", None).split(" ")
|
||||
if set(self._scopes).isdisjoint(set(requested_scopes)):
|
||||
message = "Introspection response contains any required scopes."
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
audience = introspection_response.get("aud", None)
|
||||
if not audience:
|
||||
raise SuspiciousOperation(
|
||||
"Introspection response does not provide source audience."
|
||||
)
|
||||
|
||||
return introspection_response
|
||||
|
||||
def _introspect(self, token):
|
||||
"""Introspect an access token to the authorization server."""
|
||||
try:
|
||||
jwe = self._authorization_server_client.get_introspection(
|
||||
self._client_id,
|
||||
self._client_secret,
|
||||
token,
|
||||
)
|
||||
except HTTPError as err:
|
||||
message = "Could not fetch introspection"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
private_key = utils.import_private_key_from_settings()
|
||||
jws = self._decrypt(jwe, private_key=private_key)
|
||||
|
||||
try:
|
||||
public_key_set = self._authorization_server_client.import_public_keys()
|
||||
except (TypeError, ValueError, AttributeError, HTTPError) as err:
|
||||
message = "Could get authorization server JWKS"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
jwt = self._decode(jws, public_key_set)
|
||||
|
||||
return jwt
|
||||
|
||||
def _decrypt(self, encrypted_token, private_key):
|
||||
"""Decrypt the token encrypted by the Authorization Server (AS).
|
||||
|
||||
Resource Server (RS)'s public key is used for encryption, and its private
|
||||
key is used for decryption. The RS's public key is exposed to the AS via a JWKS endpoint.
|
||||
Encryption Algorithm and Encoding should be configured to match between the AS
|
||||
and the RS.
|
||||
"""
|
||||
|
||||
try:
|
||||
decrypted_token = jose_jwe.decrypt_compact(
|
||||
encrypted_token,
|
||||
private_key,
|
||||
algorithms=[self._encryption_algorithm, self._encryption_encoding],
|
||||
)
|
||||
except Exception as err:
|
||||
message = "Token decryption failed"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return decrypted_token
|
||||
|
||||
def _decode(self, encoded_token, public_key_set):
|
||||
"""Decode the token signed by the Authorization Server (AS).
|
||||
|
||||
AS's private key is used for signing, and its public key is used for decoding.
|
||||
The AS public key is exposed via a JWK endpoint.
|
||||
Signing Algorithm should be configured to match between the AS and the RS.
|
||||
"""
|
||||
try:
|
||||
token = jose_jwt.decode(
|
||||
encoded_token.plaintext,
|
||||
public_key_set,
|
||||
algorithms=[self._signing_algorithm],
|
||||
)
|
||||
except ValueError as err:
|
||||
message = "Token decoding failed"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return token
|
||||
|
||||
def _verify_claims(self, token):
|
||||
"""Verify the claims of the token to ensure authentication security.
|
||||
|
||||
By verifying these claims, we ensure that the token was issued by a
|
||||
trusted authorization server and is intended for this specific
|
||||
resource server. This prevents various types of attacks, such as
|
||||
token substitution or misuse of tokens issued for different clients.
|
||||
"""
|
||||
try:
|
||||
self._claims_registry.validate(token.claims)
|
||||
except (InvalidClaimError, InvalidTokenError) as err:
|
||||
message = "Failed to validate token's claims"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return token.claims
|
||||
|
||||
|
||||
class ResourceServerImproperlyConfiguredBackend:
|
||||
"""Fallback backend for improperly configured Resource Servers."""
|
||||
|
||||
token_origin_audience = None
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Indicate that the Resource Server is improperly configured."""
|
||||
raise AuthenticationFailed("Resource Server is improperly configured")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Resource Server Clients classes"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import requests
|
||||
from joserfc.jwk import KeySet
|
||||
|
||||
|
||||
class AuthorizationServerClient:
|
||||
"""Client for interacting with an OAuth 2.0 authorization server.
|
||||
|
||||
An authorization server issues access tokens to client applications after authenticating
|
||||
and obtaining authorization from the resource owner. It also provides endpoints for token
|
||||
introspection and JSON Web Key Sets (JWKS) to validate and decode tokens.
|
||||
|
||||
This client facilitates communication with the authorization server, including:
|
||||
- Fetching token introspection responses.
|
||||
- Fetching JSON Web Key Sets (JWKS) for token validation.
|
||||
- Setting appropriate headers for secure communication as recommended by RFC drafts.
|
||||
"""
|
||||
|
||||
# ruff: noqa: PLR0913 PLR0917
|
||||
# pylint: disable=too-many-positional-arguments
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
url,
|
||||
url_jwks,
|
||||
url_introspection,
|
||||
verify_ssl,
|
||||
timeout,
|
||||
proxy,
|
||||
):
|
||||
"""Require at a minimum url, url_jwks and url_introspection."""
|
||||
|
||||
if not url or not url_jwks or not url_introspection:
|
||||
raise ImproperlyConfigured(
|
||||
"Could not instantiate AuthorizationServerClient, some parameters are missing."
|
||||
)
|
||||
|
||||
self.url = url
|
||||
self._url_introspection = url_introspection
|
||||
self._url_jwks = url_jwks
|
||||
self._verify_ssl = verify_ssl
|
||||
self._timeout = timeout
|
||||
self._proxy = proxy
|
||||
|
||||
@property
|
||||
def _introspection_headers(self):
|
||||
"""Get HTTP header for the introspection request.
|
||||
|
||||
Notify the authorization server that we expect a signed and encrypted response
|
||||
by setting the appropriate 'Accept' header.
|
||||
|
||||
This follows the recommendation from the draft RFC:
|
||||
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
|
||||
"""
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/token-introspection+jwt",
|
||||
}
|
||||
|
||||
def get_introspection(self, client_id, client_secret, token):
|
||||
"""Retrieve introspection response about a token."""
|
||||
response = requests.post(
|
||||
self._url_introspection,
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"token": token,
|
||||
},
|
||||
headers=self._introspection_headers,
|
||||
verify=self._verify_ssl,
|
||||
timeout=self._timeout,
|
||||
proxies=self._proxy,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
def get_jwks(self):
|
||||
"""Retrieve Authorization Server JWKS."""
|
||||
response = requests.get(
|
||||
self._url_jwks,
|
||||
verify=self._verify_ssl,
|
||||
timeout=self._timeout,
|
||||
proxies=self._proxy,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def import_public_keys(self):
|
||||
"""Retrieve and import Authorization Server JWKS."""
|
||||
|
||||
jwks = self.get_jwks()
|
||||
public_keys = KeySet.import_key_set(jwks)
|
||||
|
||||
return public_keys
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Mixins for resource server views.
|
||||
"""
|
||||
|
||||
from rest_framework import exceptions as drf_exceptions
|
||||
|
||||
from .authentication import ResourceServerAuthentication
|
||||
|
||||
|
||||
class ResourceServerMixin:
|
||||
"""
|
||||
Mixin for resource server views:
|
||||
- Restrict the authentication class to ResourceServerAuthentication.
|
||||
- Adds the Service Provider ID to the serializer context.
|
||||
- Fetch the Service Provider ID from the OIDC introspected token stored
|
||||
in the request.
|
||||
|
||||
This Mixin *must* be used in every view that should act as a resource server.
|
||||
"""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# When used as a resource server, we need to know the audience to automatically:
|
||||
# - add the Service Provider to the team "scope" on creation
|
||||
context["from_service_provider_audience"] = (
|
||||
self._get_service_provider_audience()
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def _get_service_provider_audience(self):
|
||||
"""Return the audience of the Service Provider from the OIDC introspected token."""
|
||||
if not isinstance(
|
||||
self.request.successful_authenticator, ResourceServerAuthentication
|
||||
):
|
||||
# We could check request.resource_server_token_audience here, but it's
|
||||
# more explicit to check the authenticator type and assert the attribute
|
||||
# existence.
|
||||
return None
|
||||
|
||||
# When used as a resource server, the request has a token audience
|
||||
service_provider_audience = self.request.resource_server_token_audience
|
||||
|
||||
if not service_provider_audience: # should not happen
|
||||
raise drf_exceptions.AuthenticationFailed(
|
||||
"Resource server token audience not found in request"
|
||||
)
|
||||
|
||||
return service_provider_audience
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Resource Server URL Configuration"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from .views import JWKSView
|
||||
|
||||
urlpatterns = [
|
||||
path("jwks", JWKSView.as_view(), name="resource_server_jwks"),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Resource Server utils functions"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from joserfc.jwk import JWKRegistry
|
||||
|
||||
|
||||
def import_private_key_from_settings():
|
||||
"""Import the private key used by the resource server when interacting with the OIDC provider.
|
||||
|
||||
This private key is crucial; its public components are exposed in the JWK endpoints,
|
||||
while its private component is used for decrypting the introspection token retrieved
|
||||
from the OIDC provider.
|
||||
|
||||
By default, we recommend using RSAKey for asymmetric encryption,
|
||||
known for its strong security features.
|
||||
|
||||
Note:
|
||||
- The function requires the 'OIDC_RS_PRIVATE_KEY_STR' setting to be configured.
|
||||
- The 'OIDC_RS_ENCRYPTION_KEY_TYPE' and 'OIDC_RS_ENCRYPTION_ALGO' settings can be customized
|
||||
based on the chosen key type.
|
||||
|
||||
Raises:
|
||||
ImproperlyConfigured: If the private key setting is missing, empty, or incorrect.
|
||||
|
||||
Returns:
|
||||
joserfc.jwk.JWK: The imported private key as a JWK object.
|
||||
"""
|
||||
|
||||
private_key_str = getattr(settings, "OIDC_RS_PRIVATE_KEY_STR", None)
|
||||
if not private_key_str:
|
||||
raise ImproperlyConfigured(
|
||||
"OIDC_RS_PRIVATE_KEY_STR setting is missing or empty."
|
||||
)
|
||||
|
||||
private_key_pem = private_key_str.encode()
|
||||
|
||||
try:
|
||||
private_key = JWKRegistry.import_key(
|
||||
private_key_pem,
|
||||
key_type=settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
parameters={"alg": settings.OIDC_RS_ENCRYPTION_ALGO, "use": "enc"},
|
||||
)
|
||||
except ValueError as err:
|
||||
raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is wrong.") from err
|
||||
|
||||
return private_key
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Resource Server views"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from joserfc.jwk import KeySet
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from . import utils
|
||||
|
||||
|
||||
class JWKSView(APIView):
|
||||
"""
|
||||
API endpoint for retrieving a JSON Web Keys Set (JWKS).
|
||||
|
||||
Returns:
|
||||
Response: JSON response containing the JWKS data.
|
||||
"""
|
||||
|
||||
authentication_classes = [] # disable authentication
|
||||
permission_classes = [] # disable permission
|
||||
|
||||
def get(self, request):
|
||||
"""Handle GET requests to retrieve JSON Web Keys Set (JWKS).
|
||||
|
||||
Returns:
|
||||
Response: JSON response containing the JWKS data.
|
||||
"""
|
||||
|
||||
try:
|
||||
private_key = utils.import_private_key_from_settings()
|
||||
except (ImproperlyConfigured, ValueError) as err:
|
||||
return Response({"error": str(err)}, status=500)
|
||||
|
||||
try:
|
||||
jwk = KeySet([private_key]).as_dict(private=False)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return Response({"error": "Could not load key"}, status=500)
|
||||
|
||||
return Response(jwk)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
"""Core template tags module."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core tests."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core authentication tests."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
@@ -8,6 +9,7 @@ from core import factories, models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_email(
|
||||
@@ -40,7 +42,8 @@ def test_authentication_getter_existing_user_with_email(
|
||||
When the user's info contains an email and targets an existing user,
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = factories.UserFactory(name="John Doe")
|
||||
|
||||
user = factories.UserFactory(name="John Doe", with_organization=True)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
@@ -77,7 +80,9 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
It should update the email or name fields on the user when they change.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = factories.UserFactory(name="John Doe", email="john.doe@example.com")
|
||||
user = factories.UserFactory(
|
||||
name="John Doe", email="john.doe@example.com", with_organization=True
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
@@ -101,15 +106,79 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
assert user.name == f"{first_name:s} {last_name:s}"
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
def test_authentication_getter_existing_user_keep_fields(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's info doesn't contain an email/name, created user's email/name should be empty.
|
||||
Falsy values in claim should not update the user's fields.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = factories.UserFactory(
|
||||
name="John Doe", email="john.doe@example.com", with_organization=True
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123"}
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": None,
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# No field changed no more query
|
||||
with django_assert_num_queries(1):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == "john.doe@example.com"
|
||||
assert user.name == "John Doe"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub but matches the email,
|
||||
the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory(with_organization=True)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should not match users by email, even if the email matches.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory()
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
@@ -117,11 +186,31 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
# Since the sub doesn't match, it should create a new user
|
||||
assert models.User.objects.count() == 2
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.name is None
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should not be created without email
|
||||
nor organization registration ID.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123"} # No email, no organization registration ID
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Claims contained no recognizable organization identification",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
@@ -169,3 +258,198 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_sub(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user matches the sub but is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory(name="John Doe", is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": db_user.sub,
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(1),
|
||||
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user does not matches the sub but match the email and is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory(name="John Doe", is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "random",
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(2),
|
||||
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_with_email_new_organization(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
If the corresponding organization doesn't exist, it should be created.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "people@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.organization is not None
|
||||
assert user.organization.domain_list == ["example.com"]
|
||||
assert user.organization.registration_id_list == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"registration_id_setting,expected_registration_id_list,expected_domain_list",
|
||||
[
|
||||
(None, [], ["example.com"]),
|
||||
("missing-claim", [], ["example.com"]),
|
||||
("registration_number", ["12345678901234"], []),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_new_user_with_registration_id_new_organization(
|
||||
monkeypatch,
|
||||
settings,
|
||||
registration_id_setting,
|
||||
expected_registration_id_list,
|
||||
expected_domain_list,
|
||||
):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
If the corresponding organization doesn't exist, it should be created.
|
||||
"""
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = registration_id_setting
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "people@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"email": email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"registration_number": "12345678901234",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.organization is not None
|
||||
assert user.organization.domain_list == expected_domain_list
|
||||
assert user.organization.registration_id_list == expected_registration_id_list
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email_update_organization(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user already exists without organization, the organization must be updated.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory(name="John Doe", email="toto@my-domain.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": db_user.sub,
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(9):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
assert user.organization is not None
|
||||
assert user.organization.domain_list == ["my-domain.com"]
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_registration_id(
|
||||
monkeypatch,
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
Authenticate a user who already exists with an organization registration ID.
|
||||
|
||||
This asserts the "update_user_if_needed" does not fail when the claim
|
||||
contains the organization registration ID (or any value not present in the
|
||||
User model).
|
||||
"""
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "registration_number"
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
_existing_user = factories.UserFactory(
|
||||
sub="123",
|
||||
name="John Doe",
|
||||
email="people@example.com",
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"email": "people@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"registration_number": "12345678901234",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.organization is not None
|
||||
assert user.organization.registration_id_list == ["12345678901234"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Contacts tests package."""
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
CONTACT_DATA = {
|
||||
"emails": [
|
||||
{"type": "Work", "value": "john.doe@work.com"},
|
||||
{"type": "Home", "value": "john.doe@home.com"},
|
||||
],
|
||||
"phones": [
|
||||
{"type": "Work", "value": "(123) 456-7890"},
|
||||
{"type": "Other", "value": "(987) 654-3210"},
|
||||
],
|
||||
"addresses": [
|
||||
{
|
||||
"type": "Home",
|
||||
"street": "123 Main St",
|
||||
"city": "Cityville",
|
||||
"state": "CA",
|
||||
"zip": "12345",
|
||||
"country": "USA",
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{"type": "Blog", "value": "http://personalwebsite.com"},
|
||||
{"type": "Website", "value": "http://workwebsite.com"},
|
||||
],
|
||||
"customFields": {"custom_field_1": "value1", "custom_field_2": "value2"},
|
||||
"organizations": [
|
||||
{
|
||||
"name": "ACME Corporation",
|
||||
"department": "IT",
|
||||
"jobTitle": "Software Engineer",
|
||||
},
|
||||
{
|
||||
"name": "XYZ Ltd",
|
||||
"department": "Marketing",
|
||||
"jobTitle": "Marketing Specialist",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_anonymous_forbidden():
|
||||
"""Anonymous users should not be able to create contacts via the API."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David",
|
||||
"short_name": "Bowman",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert not models.Contact.objects.exists()
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_missing_base():
|
||||
"""Authenticated user should be able to create contact without override."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": {},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
new_contact = models.Contact.objects.get(owner=user)
|
||||
|
||||
assert response.json() == {
|
||||
"override": None,
|
||||
"data": {},
|
||||
"full_name": "David Bowman",
|
||||
"id": str(new_contact.pk),
|
||||
"notes": "",
|
||||
"owner": str(user.pk),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_successful():
|
||||
"""Authenticated users should be able to create contacts."""
|
||||
user = factories.UserFactory()
|
||||
base_contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Existing override for another user should not interfere
|
||||
factories.ContactFactory(override=base_contact)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"override": str(base_contact.id),
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Contact.objects.count() == 3
|
||||
|
||||
contact = models.Contact.objects.get(owner=user)
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"override": str(base_contact.id),
|
||||
"data": CONTACT_DATA,
|
||||
"full_name": "David Bowman",
|
||||
"notes": "",
|
||||
"owner": str(user.id),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
assert contact.full_name == "David Bowman"
|
||||
assert contact.short_name == "Dave"
|
||||
assert contact.data == CONTACT_DATA
|
||||
assert contact.override == base_contact
|
||||
assert contact.owner == user
|
||||
|
||||
|
||||
@override_settings(ALLOW_API_USER_CREATE=True)
|
||||
def test_api_contacts_create_authenticated_existing_override():
|
||||
"""
|
||||
Trying to create a contact overriding a contact that is already overridden by the user
|
||||
should receive a 400 error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
base_contact = factories.BaseContactFactory()
|
||||
factories.ContactFactory(override=base_contact, owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"override": str(base_contact.id),
|
||||
"full_name": "David Bowman",
|
||||
"notes": "",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
assert response.json() == {
|
||||
"__all__": ["Contact with this Owner and Override already exists."]
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_successful_with_notes():
|
||||
"""Authenticated users should be able to create contacts with notes."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
"notes": "This is a note",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
contact = models.Contact.objects.get(owner=user)
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"override": None,
|
||||
"data": CONTACT_DATA,
|
||||
"full_name": "David Bowman",
|
||||
"notes": "This is a note",
|
||||
"owner": str(user.id),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
assert contact.full_name == "David Bowman"
|
||||
assert contact.short_name == "Dave"
|
||||
assert contact.data == CONTACT_DATA
|
||||
assert contact.owner == user
|
||||
assert contact.notes == "This is a note"
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_delete_list_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a list of contacts."""
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_contacts_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of contacts."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = client.delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 405
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_contacts_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a contact."""
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
response = client.delete(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a public contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_owner():
|
||||
"""
|
||||
Authenticated users should be allowed to delete a contact they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Contact.objects.count() == 0
|
||||
assert models.Contact.objects.filter(id=contact.id).exists() is False
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to delete their profile contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Contact.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a contact they don't own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.count() == 1
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list contacts."""
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_no_query():
|
||||
"""
|
||||
Authenticated users should be able to list contacts without applying a query.
|
||||
Profile and overridden contacts should be excluded.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
factories.ContactFactory(owner=user, user=user)
|
||||
|
||||
# Let's have 5 contacts in database:
|
||||
assert user.profile_contact is not None # Excluded because profile contact
|
||||
base_contact = factories.BaseContactFactory() # Excluded because overridden
|
||||
factories.ContactFactory(
|
||||
override=base_contact
|
||||
) # Excluded because belongs to other user
|
||||
contact2 = factories.ContactFactory(
|
||||
override=base_contact, owner=user, full_name="Bernard"
|
||||
) # Included
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{
|
||||
"id": str(contact2.id),
|
||||
"override": str(base_contact.id),
|
||||
"owner": str(contact2.owner.id),
|
||||
"data": contact2.data,
|
||||
"full_name": contact2.full_name,
|
||||
"notes": "",
|
||||
"short_name": contact2.short_name,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_by_full_name():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case insensitive and
|
||||
partial query on the full name.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="David Bowman")
|
||||
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
|
||||
frank = factories.BaseContactFactory(full_name="Frank Poole")
|
||||
factories.BaseContactFactory(full_name="Heywood Floyd")
|
||||
|
||||
# Full query should work
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=David%20Bowman")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/contacts/?q=ank")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=ole")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=ool")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
|
||||
|
||||
# Unaccented full name
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=EEE")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=AAA")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_content():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented query should find unaccented content."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=ééé")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=ààà")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a user."""
|
||||
client = APIClient()
|
||||
contact = factories.ContactFactory()
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_owned():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a contact they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"override": None,
|
||||
"owner": str(contact.owner.id),
|
||||
"data": contact.data,
|
||||
"full_name": contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": contact.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_public():
|
||||
"""
|
||||
Authenticated users should be able to retrieve public contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"override": None,
|
||||
"owner": None,
|
||||
"data": contact.data,
|
||||
"full_name": contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": contact.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve another user's contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.client import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a contact."""
|
||||
contact = factories.ContactFactory()
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
assert contact_values == old_contact_values
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_owned():
|
||||
"""
|
||||
Authenticated users should be allowed to update their own contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user) # Owned by the logged-in user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
for key, value in contact_values.items():
|
||||
if key in ["override", "owner", "id"]:
|
||||
assert value == old_contact_values[key]
|
||||
else:
|
||||
assert value == new_contact_values[key]
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to update their profile contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user, user=user)
|
||||
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
for key, value in contact_values.items():
|
||||
if key in ["override", "owner", "id"]:
|
||||
assert value == old_contact_values[key]
|
||||
else:
|
||||
assert value == new_contact_values[key]
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to update contacts owned by other users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory() # owned by another user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
assert contact_values == old_contact_values
|
||||
@@ -0,0 +1 @@
|
||||
"""Test organization API endpoints."""
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Tests for Organizations API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_anonymous(client):
|
||||
"""Anonymous users should not be allowed to retrieve an organization."""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
response = client.get(f"/api/v1.0/organizations/{organization.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_authenticated_unrelated(client):
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve an organization to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/organizations/{organization.pk!s}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Organization matches the given query."}
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_authenticated_belong_to_organization(client):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve an organization to which they
|
||||
belong to.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(
|
||||
registration_id_list=["56618615316840", "31561861231231", "98781236231482"],
|
||||
domain_list=["example.com", "example.org"],
|
||||
)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/organizations/{organization.pk!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(organization.pk),
|
||||
"name": organization.name,
|
||||
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
|
||||
"domain_list": ["example.com", "example.org"],
|
||||
"registration_id_list": ["56618615316840", "31561861231231", "98781236231482"],
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_authenticated_administrator(client):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve an organization
|
||||
which they administrate.
|
||||
"""
|
||||
organization_access = (
|
||||
factories.OrganizationAccessFactory()
|
||||
) # only role is administrator for now
|
||||
user = organization_access.user
|
||||
organization = organization_access.organization
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/organizations/{organization.pk!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"patch": True,
|
||||
"put": True,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Tests for Organizations API endpoint in People's core app: update
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_organizations_update_anonymous(client):
|
||||
"""Anonymous users should not be allowed to update an organization."""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_update_authenticated_unrelated(client):
|
||||
"""
|
||||
Authenticated users should not be allowed to update an organization to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Organization matches the given query."}
|
||||
|
||||
|
||||
def test_api_organizations_update_authenticated_belong_to_organization(client):
|
||||
"""
|
||||
Authenticated users should NOT be allowed to update an organization to which they
|
||||
belong to.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_update_authenticated_administrator(client):
|
||||
"""
|
||||
Authenticated users should be allowed to update an organization
|
||||
which they administrate.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(
|
||||
registration_id_list=["56618615316840", "31561861231231", "98781236231482"],
|
||||
domain_list=["example.com", "example.org"],
|
||||
)
|
||||
organization_access = factories.OrganizationAccessFactory(organization=organization)
|
||||
user = organization_access.user
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(organization.pk),
|
||||
"name": "New Name",
|
||||
"abilities": {"delete": False, "get": True, "patch": True, "put": True},
|
||||
"domain_list": ["example.com", "example.org"],
|
||||
"registration_id_list": ["56618615316840", "31561861231231", "98781236231482"],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Core resource server tests."""
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Tests for the authentication process of the resource server."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.rfc7518.rsa_key import RSAKey
|
||||
from jwt.utils import to_base64url_uint
|
||||
from rest_framework.request import Request as DRFRequest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.models import ServiceProvider
|
||||
from core.resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def build_authorization_bearer(token):
|
||||
"""
|
||||
Build an Authorization Bearer header value from a token.
|
||||
|
||||
This can be used like this:
|
||||
client.post(
|
||||
...
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
"""
|
||||
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_authentication_class(client, settings):
|
||||
"""
|
||||
Defines the settings for the resource server
|
||||
for a full authentication with introspection process.
|
||||
|
||||
This is an integration test that checks the authentication process
|
||||
when using the ResourceServerAuthentication class.
|
||||
|
||||
This test asserts the DRF request object contains the
|
||||
`resource_server_token_audience` attribute which is used in
|
||||
the resource server views.
|
||||
|
||||
This test uses the `/resource-server/v1.0/teams/` URL as an example
|
||||
because we don't want to create a new URL just for this test.
|
||||
"""
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
unencrypted_pem_private_key = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
pem_public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = unencrypted_pem_private_key.decode("utf-8")
|
||||
settings.OIDC_RS_ENCRYPTION_KEY_TYPE = "RSA"
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
|
||||
settings.OIDC_RS_SIGNING_ALGO = "RS256"
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
# Mock the JWKS endpoint
|
||||
public_numbers = private_key.public_key().public_numbers()
|
||||
responses.add(
|
||||
responses.GET,
|
||||
settings.OIDC_OP_JWKS_ENDPOINT,
|
||||
body=json.dumps(
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
"use": "sig",
|
||||
"kid": "1234567890",
|
||||
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
|
||||
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def encrypt_jwt(json_data):
|
||||
"""Encrypt the JWT token for the backend to decrypt."""
|
||||
token = jose_jwt.encode(
|
||||
{
|
||||
"kid": "1234567890",
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
},
|
||||
json_data,
|
||||
RSAKey.import_key(unencrypted_pem_private_key),
|
||||
algorithms=[settings.OIDC_RS_SIGNING_ALGO],
|
||||
)
|
||||
|
||||
return jose_jwe.encrypt_compact(
|
||||
protected={
|
||||
"alg": settings.OIDC_RS_ENCRYPTION_ALGO,
|
||||
"enc": settings.OIDC_RS_ENCRYPTION_ENCODING,
|
||||
},
|
||||
plaintext=token,
|
||||
public_key=RSAKey.import_key(pem_public_key),
|
||||
algorithms=[
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO,
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING,
|
||||
],
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
body=encrypt_jwt(
|
||||
{
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"token_introspection": {
|
||||
"sub": "very-specific-sub",
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_service_provider",
|
||||
"scope": "openid groups",
|
||||
"active": True,
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Try to authenticate while the user does not exist => 401
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/", # use an exising URL here
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
|
||||
# Create a user with the specific sub, the access is authorized
|
||||
UserFactory(sub="very-specific-sub")
|
||||
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/", # use an exising URL here
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
response_request = response.renderer_context.get("request")
|
||||
assert isinstance(response_request, DRFRequest)
|
||||
assert isinstance(
|
||||
response_request.successful_authenticator, ResourceServerAuthentication
|
||||
)
|
||||
|
||||
# Check that the user is authenticated
|
||||
assert response_request.user.is_authenticated
|
||||
|
||||
# Check the request contains the resource server token audience
|
||||
assert response_request.resource_server_token_audience == "some_service_provider"
|
||||
|
||||
# Check that no service provider is created here
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
@@ -0,0 +1,447 @@
|
||||
"""
|
||||
Test for the Resource Server (RS) Backend.
|
||||
"""
|
||||
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from logging import Logger
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from joserfc.errors import InvalidClaimError, InvalidTokenError
|
||||
from joserfc.jwt import JWTClaimsRegistry
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from core.resource_server.backend import ResourceServerBackend
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_authorization_server")
|
||||
def fixture_mock_authorization_server():
|
||||
"""Mock an Authorization Server client."""
|
||||
mock_server = Mock()
|
||||
mock_server.url = "https://auth.server.com"
|
||||
return mock_server
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_token")
|
||||
def fixture_mock_token():
|
||||
"""Mock a token"""
|
||||
mock_token = Mock()
|
||||
mock_token.claims = {"sub": "user123", "iss": "https://auth.server.com"}
|
||||
return mock_token
|
||||
|
||||
|
||||
@pytest.fixture(name="resource_server_backend")
|
||||
def fixture_resource_server_backend(settings, mock_authorization_server):
|
||||
"""Generate a Resource Server backend."""
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "client_secret"
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
|
||||
settings.OIDC_RS_SIGNING_ALGO = "ES256"
|
||||
settings.OIDC_RS_SCOPES = ["groups"]
|
||||
|
||||
return ResourceServerBackend(mock_authorization_server)
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_CLIENT_ID="client_id")
|
||||
@override_settings(OIDC_RS_CLIENT_SECRET="client_secret")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ENCODING="A256GCM")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RSA-OAEP")
|
||||
@override_settings(OIDC_RS_SIGNING_ALGO="RS256")
|
||||
@override_settings(OIDC_RS_SCOPES=["scopes"])
|
||||
@patch.object(auth, "get_user_model", return_value="foo")
|
||||
def test_backend_initialization(mock_get_user_model, mock_authorization_server):
|
||||
"""Test the ResourceServerBackend initialization."""
|
||||
|
||||
backend = ResourceServerBackend(mock_authorization_server)
|
||||
|
||||
mock_get_user_model.assert_called_once()
|
||||
assert backend.UserModel == "foo"
|
||||
|
||||
assert backend._client_id == "client_id"
|
||||
assert backend._client_secret == "client_secret"
|
||||
assert backend._encryption_encoding == "A256GCM"
|
||||
assert backend._encryption_algorithm == "RSA-OAEP"
|
||||
assert backend._signing_algorithm == "RS256"
|
||||
assert backend._scopes == ["scopes"]
|
||||
|
||||
assert backend._authorization_server_client == mock_authorization_server
|
||||
assert isinstance(backend._claims_registry, JWTClaimsRegistry)
|
||||
|
||||
assert backend._claims_registry.options == {
|
||||
"iss": {"essential": True, "value": "https://auth.server.com"},
|
||||
"aud": {"essential": True, "value": "client_id"},
|
||||
"token_introspection": {"essential": True},
|
||||
}
|
||||
|
||||
|
||||
@patch.object(ResourceServerBackend, "get_user", return_value="user")
|
||||
def test_get_or_create_user(mock_get_user, resource_server_backend):
|
||||
"""Test 'get_or_create_user' method."""
|
||||
|
||||
access_token = "access_token"
|
||||
res = resource_server_backend.get_or_create_user(access_token, None, None)
|
||||
|
||||
mock_get_user.assert_called_once_with(access_token)
|
||||
assert res == "user"
|
||||
|
||||
|
||||
def test_verify_claims_success(resource_server_backend, mock_token):
|
||||
"""Test '_verify_claims' method with a successful response."""
|
||||
|
||||
with patch.object(
|
||||
resource_server_backend._claims_registry, "validate"
|
||||
) as mock_validate:
|
||||
resource_server_backend._verify_claims(mock_token)
|
||||
mock_validate.assert_called_once_with(mock_token.claims)
|
||||
|
||||
|
||||
def test_verify_claims_invalid_claim_error(resource_server_backend, mock_token):
|
||||
"""Test '_verify_claims' method with an invalid claim error."""
|
||||
|
||||
with patch.object(
|
||||
resource_server_backend._claims_registry, "validate"
|
||||
) as mock_validate:
|
||||
mock_validate.side_effect = InvalidClaimError("claim_name")
|
||||
|
||||
expected_message = "Failed to validate token's claims"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_claims(mock_token)
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_verify_claims_invalid_token_error(resource_server_backend, mock_token):
|
||||
"""Test '_verify_claims' method with an invalid token error."""
|
||||
|
||||
with patch.object(
|
||||
resource_server_backend._claims_registry, "validate"
|
||||
) as mock_validate:
|
||||
mock_validate.side_effect = InvalidTokenError
|
||||
|
||||
expected_message = "Failed to validate token's claims"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_claims(mock_token)
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_decode_success(resource_server_backend):
|
||||
"""Test '_decode' method with a successful response."""
|
||||
|
||||
encoded_token = Mock()
|
||||
encoded_token.plaintext = "valid_encoded_token"
|
||||
public_key_set = Mock()
|
||||
|
||||
expected_decoded_token = {"sub": "user123"}
|
||||
|
||||
with patch(
|
||||
"joserfc.jwt.decode", return_value=expected_decoded_token
|
||||
) as mock_decode:
|
||||
decoded_token = resource_server_backend._decode(encoded_token, public_key_set)
|
||||
|
||||
mock_decode.assert_called_once_with(
|
||||
"valid_encoded_token", public_key_set, algorithms=["ES256"]
|
||||
)
|
||||
|
||||
assert decoded_token == expected_decoded_token
|
||||
|
||||
|
||||
def test_decode_failure(resource_server_backend):
|
||||
"""Test '_decode' method with a ValueError"""
|
||||
encoded_token = Mock()
|
||||
encoded_token.plaintext = "invalid_encoded_token"
|
||||
public_key_set = Mock()
|
||||
|
||||
with patch("joserfc.jwt.decode", side_effect=ValueError):
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match="Token decoding failed"):
|
||||
resource_server_backend._decode(encoded_token, public_key_set)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", "Token decoding failed", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_decrypt_success(resource_server_backend):
|
||||
"""Test '_decrypt' method with a successful response."""
|
||||
encrypted_token = "valid_encrypted_token"
|
||||
private_key = "private_key"
|
||||
|
||||
expected_decrypted_token = {"sub": "user123"}
|
||||
|
||||
with patch(
|
||||
"joserfc.jwe.decrypt_compact", return_value=expected_decrypted_token
|
||||
) as mock_decrypt:
|
||||
decrypted_token = resource_server_backend._decrypt(encrypted_token, private_key)
|
||||
mock_decrypt.assert_called_once_with(
|
||||
encrypted_token, private_key, algorithms=["RSA-OAEP", "A256GCM"]
|
||||
)
|
||||
|
||||
assert decrypted_token == expected_decrypted_token
|
||||
|
||||
|
||||
def test_decrypt_failure(resource_server_backend):
|
||||
"""Test '_decrypt' method with an Exception."""
|
||||
encrypted_token = "invalid_encrypted_token"
|
||||
private_key = "private_key"
|
||||
|
||||
with patch(
|
||||
"joserfc.jwe.decrypt_compact", side_effect=Exception("Decryption error")
|
||||
):
|
||||
expected_message = "Token decryption failed"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._decrypt(encrypted_token, private_key)
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"core.resource_server.utils.import_private_key_from_settings",
|
||||
return_value="private_key",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def test_introspect_success(
|
||||
mock_import_private_key_from_settings, resource_server_backend
|
||||
):
|
||||
"""Test '_introspect' method with a successful response."""
|
||||
token = "valid_token"
|
||||
jwe = "valid_jwe"
|
||||
jws = "valid_jws"
|
||||
jwt = {"sub": "user123"}
|
||||
|
||||
resource_server_backend._authorization_server_client.get_introspection = Mock(
|
||||
return_value=jwe
|
||||
)
|
||||
resource_server_backend._decrypt = Mock(return_value=jws)
|
||||
resource_server_backend._authorization_server_client.import_public_keys = Mock(
|
||||
return_value="public_key_set"
|
||||
)
|
||||
resource_server_backend._decode = Mock(return_value=jwt)
|
||||
|
||||
result = resource_server_backend._introspect(token)
|
||||
|
||||
assert result == jwt
|
||||
resource_server_backend._authorization_server_client.get_introspection.assert_called_once_with(
|
||||
"client_id", "client_secret", token
|
||||
)
|
||||
resource_server_backend._decrypt.assert_called_once_with(
|
||||
jwe, private_key="private_key"
|
||||
)
|
||||
resource_server_backend._authorization_server_client.import_public_keys.assert_called_once()
|
||||
resource_server_backend._decode.assert_called_once_with(jws, "public_key_set")
|
||||
|
||||
|
||||
def test_introspect_introspection_failure(resource_server_backend):
|
||||
"""Test '_introspect' method when introspection to the AS fails."""
|
||||
token = "invalid_token"
|
||||
resource_server_backend._authorization_server_client.get_introspection.side_effect = HTTPError(
|
||||
"Introspection error"
|
||||
)
|
||||
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
expected_message = "Could not fetch introspection"
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._introspect(token)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"core.resource_server.utils.import_private_key_from_settings",
|
||||
return_value="private_key",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def test_introspect_public_key_import_failure(
|
||||
mock_import_private_key_from_settings, resource_server_backend
|
||||
):
|
||||
"""Test '_introspect' method when fetching AS's jwks fails."""
|
||||
token = "valid_token"
|
||||
jwe = "valid_jwe"
|
||||
jws = "valid_jws"
|
||||
|
||||
resource_server_backend._authorization_server_client.get_introspection = Mock(
|
||||
return_value=jwe
|
||||
)
|
||||
resource_server_backend._decrypt = Mock(return_value=jws)
|
||||
|
||||
resource_server_backend._authorization_server_client.import_public_keys.side_effect = HTTPError(
|
||||
"Public key error"
|
||||
)
|
||||
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
expected_message = "Could get authorization server JWKS"
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._introspect(token)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_verify_user_info_success(resource_server_backend):
|
||||
"""Test '_verify_user_info' with a successful response."""
|
||||
introspection_response = {"active": True, "scope": "groups", "aud": "123"}
|
||||
|
||||
result = resource_server_backend._verify_user_info(introspection_response)
|
||||
assert result == introspection_response
|
||||
|
||||
|
||||
def test_verify_user_info_inactive(resource_server_backend):
|
||||
"""Test '_verify_user_info' with an inactive introspection response."""
|
||||
|
||||
introspection_response = {"active": False, "scope": "groups"}
|
||||
|
||||
expected_message = "Introspection response is not active."
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_user_info(introspection_response)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(expected_message)
|
||||
|
||||
|
||||
def test_verify_user_info_wrong_scopes(resource_server_backend):
|
||||
"""Test '_verify_user_info' with wrong requested scopes."""
|
||||
|
||||
introspection_response = {"active": True, "scope": "wrong-scopes"}
|
||||
|
||||
expected_message = "Introspection response contains any required scopes."
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_user_info(introspection_response)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(expected_message)
|
||||
|
||||
|
||||
def test_get_user_success(resource_server_backend):
|
||||
"""Test '_get_user' with a successful response."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
mock_claims = {"token_introspection": {"sub": "user123", "aud": "123"}}
|
||||
mock_user = Mock()
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
|
||||
resource_server_backend._verify_user_info = Mock(
|
||||
return_value=mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get = Mock(return_value=mock_user)
|
||||
|
||||
user = resource_server_backend.get_user(access_token)
|
||||
|
||||
assert user == mock_user
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
|
||||
resource_server_backend._verify_user_info.assert_called_once_with(
|
||||
mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get.assert_called_once_with(sub="user123")
|
||||
|
||||
|
||||
def test_get_user_could_not_introspect(resource_server_backend):
|
||||
"""Test '_get_user' with introspection failing."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
|
||||
resource_server_backend._introspect = Mock(
|
||||
side_effect=SuspiciousOperation("Invalid jwt")
|
||||
)
|
||||
resource_server_backend._verify_claims = Mock()
|
||||
resource_server_backend._verify_user_info = Mock()
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="Invalid jwt"):
|
||||
resource_server_backend.get_user(access_token)
|
||||
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_not_called()
|
||||
resource_server_backend._verify_user_info.assert_not_called()
|
||||
|
||||
|
||||
def test_get_user_invalid_introspection_response(resource_server_backend):
|
||||
"""Test '_get_user' with an invalid introspection response."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(
|
||||
side_effect=SuspiciousOperation("Invalid claims")
|
||||
)
|
||||
resource_server_backend._verify_user_info = Mock()
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="Invalid claims"):
|
||||
resource_server_backend.get_user(access_token)
|
||||
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
|
||||
resource_server_backend._verify_user_info.assert_not_called()
|
||||
|
||||
|
||||
def test_get_user_user_not_found(resource_server_backend):
|
||||
"""Test '_get_user' if the user is not found."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
mock_claims = {"token_introspection": {"sub": "user123"}}
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
|
||||
resource_server_backend._verify_user_info = Mock(
|
||||
return_value=mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get = Mock(
|
||||
side_effect=resource_server_backend.UserModel.DoesNotExist
|
||||
)
|
||||
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
user = resource_server_backend.get_user(access_token)
|
||||
assert user is None
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
|
||||
resource_server_backend._verify_user_info.assert_called_once_with(
|
||||
mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get.assert_called_once_with(
|
||||
sub="user123"
|
||||
)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"Login failed: No user with %s found", "user123"
|
||||
)
|
||||
|
||||
|
||||
def test_get_user_no_user_identification(resource_server_backend):
|
||||
"""Test '_get_user' if the response miss a user identification."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
mock_claims = {"token_introspection": {}}
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
|
||||
resource_server_backend._verify_user_info = Mock(
|
||||
return_value=mock_claims["token_introspection"]
|
||||
)
|
||||
|
||||
expected_message = "User info contained no recognizable user identification"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend.get_user(access_token)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(expected_message)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Test for the Resource Server (RS) clients classes.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from joserfc.jwk import KeySet, RSAKey
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from core.resource_server.clients import AuthorizationServerClient
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def fixture_client():
|
||||
"""Generate an Authorization Server client."""
|
||||
return AuthorizationServerClient(
|
||||
url="https://auth.example.com/api/v2",
|
||||
url_jwks="https://auth.example.com/api/v2/jwks",
|
||||
url_introspection="https://auth.example.com/api/v2/introspect",
|
||||
verify_ssl=True,
|
||||
timeout=5,
|
||||
proxy=None,
|
||||
)
|
||||
|
||||
|
||||
def test_authorization_server_client_initialization():
|
||||
"""Test the AuthorizationServerClient initialization."""
|
||||
|
||||
new_client = AuthorizationServerClient(
|
||||
url="https://auth.example.com/api/v2",
|
||||
url_jwks="https://auth.example.com/api/v2/jwks",
|
||||
url_introspection="https://auth.example.com/api/v2/checktoken/foo",
|
||||
verify_ssl=True,
|
||||
timeout=5,
|
||||
proxy=None,
|
||||
)
|
||||
|
||||
assert new_client.url == "https://auth.example.com/api/v2"
|
||||
assert (
|
||||
new_client._url_introspection
|
||||
== "https://auth.example.com/api/v2/checktoken/foo"
|
||||
)
|
||||
assert new_client._url_jwks == "https://auth.example.com/api/v2/jwks"
|
||||
assert new_client._verify_ssl is True
|
||||
assert new_client._timeout == 5
|
||||
assert new_client._proxy is None
|
||||
|
||||
|
||||
def test_introspection_headers(client):
|
||||
"""Test the introspection headers to ensure they match the expected values."""
|
||||
assert client._introspection_headers == {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/token-introspection+jwt",
|
||||
}
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_get_introspection_success(mock_post, client):
|
||||
"""Test 'get_introspection' method with a successful response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.text = "introspection response"
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = client.get_introspection("client_id", "client_secret", "token")
|
||||
assert result == "introspection response"
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
"https://auth.example.com/api/v2/introspect",
|
||||
data={
|
||||
"client_id": "client_id",
|
||||
"client_secret": "client_secret",
|
||||
"token": "token",
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/token-introspection+jwt",
|
||||
},
|
||||
verify=True,
|
||||
timeout=5,
|
||||
proxies=None,
|
||||
)
|
||||
|
||||
|
||||
@patch("requests.post", side_effect=HTTPError())
|
||||
# pylint: disable=(unused-argument
|
||||
def test_get_introspection_error(mock_post, client):
|
||||
"""Test 'get_introspection' method with an HTTPError."""
|
||||
with pytest.raises(HTTPError):
|
||||
client.get_introspection("client_id", "client_secret", "token")
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_get_jwks_success(mock_get, client):
|
||||
"""Test 'get_jwks' method with a successful response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"jwks": "foo"}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
result = client.get_jwks()
|
||||
assert result == {"jwks": "foo"}
|
||||
|
||||
mock_get.assert_called_once_with(
|
||||
"https://auth.example.com/api/v2/jwks",
|
||||
verify=client._verify_ssl,
|
||||
timeout=client._timeout,
|
||||
proxies=client._proxy,
|
||||
)
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_get_jwks_error(mock_get, client):
|
||||
"""Test 'get_jwks' method with an HTTPError."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = HTTPError(
|
||||
response=MagicMock(status=500)
|
||||
)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
client.get_jwks()
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_valid(mock_get, client):
|
||||
"""Test 'import_public_keys' method with a successful response."""
|
||||
|
||||
mocked_key = RSAKey.generate_key(2048)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"keys": [mocked_key.as_dict()]}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.import_public_keys()
|
||||
|
||||
assert isinstance(response, KeySet)
|
||||
assert response.as_dict() == KeySet([mocked_key]).as_dict()
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_http_error(mock_get, client):
|
||||
"""Test 'import_public_keys' method with an HTTPError."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = HTTPError(
|
||||
response=MagicMock(status=500)
|
||||
)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
client.import_public_keys()
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_empty_jwks(mock_get, client):
|
||||
"""Test 'import_public_keys' method with empty keys response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"keys": []}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.import_public_keys()
|
||||
|
||||
assert isinstance(response, KeySet)
|
||||
assert response.as_dict() == {"keys": []}
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_invalid_jwks(mock_get, client):
|
||||
"""Test 'import_public_keys' method with invalid keys response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"keys": [{"foo": "foo"}]}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
client.import_public_keys()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Test for the Resource Server (RS) utils functions.
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from joserfc.jwk import ECKey, RSAKey
|
||||
|
||||
from core.resource_server.utils import import_private_key_from_settings
|
||||
|
||||
PRIVATE_KEY_STR_MOCKED = """-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
|
||||
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
|
||||
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
|
||||
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
|
||||
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
|
||||
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
|
||||
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
|
||||
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
|
||||
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
|
||||
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
|
||||
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
|
||||
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
|
||||
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
|
||||
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
|
||||
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
|
||||
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
|
||||
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
|
||||
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
|
||||
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
|
||||
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
|
||||
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
|
||||
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
|
||||
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
|
||||
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
|
||||
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
|
||||
MbyqKyC6DAzv4jEEhHaN7oY=
|
||||
-----END PRIVATE KEY-----
|
||||
"""
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@pytest.mark.parametrize("mocked_private_key", [None, ""])
|
||||
def test_import_private_key_from_settings_missing_or_empty_key(
|
||||
settings, mocked_private_key
|
||||
):
|
||||
"""Should raise an exception if the settings 'OIDC_RS_PRIVATE_KEY_STR' is missing or empty."""
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured,
|
||||
match="OIDC_RS_PRIVATE_KEY_STR setting is missing or empty.",
|
||||
):
|
||||
import_private_key_from_settings()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_private_key", ["123", "foo", "invalid_key"])
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
|
||||
def test_import_private_key_from_settings_incorrect_key(settings, mocked_private_key):
|
||||
"""Should raise an exception if the setting 'OIDC_RS_PRIVATE_KEY_STR' has an incorrect value."""
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured, match="OIDC_RS_PRIVATE_KEY_STR setting is wrong."
|
||||
):
|
||||
import_private_key_from_settings()
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
|
||||
def test_import_private_key_from_settings_success_rsa_key():
|
||||
"""Should import private key string as an RSA key."""
|
||||
private_key = import_private_key_from_settings()
|
||||
assert isinstance(private_key, RSAKey)
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="EC")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="ES256")
|
||||
def test_import_private_key_from_settings_success_ec_key():
|
||||
"""Should import private key string as an EC key."""
|
||||
private_key = import_private_key_from_settings()
|
||||
assert isinstance(private_key, ECKey)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Tests for the Resource Server (RS) Views.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from joserfc.jwk import RSAKey
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
|
||||
def test_view_jwks_valid_public_key(mock_import_private_key_from_settings):
|
||||
"""JWKs endpoint should return a set of valid Json Web Key"""
|
||||
|
||||
mocked_key = RSAKey.generate_key(2048)
|
||||
mock_import_private_key_from_settings.return_value = mocked_key
|
||||
|
||||
url = reverse("resource_server_jwks")
|
||||
response = APIClient().get(url)
|
||||
|
||||
mock_import_private_key_from_settings.assert_called_once()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/json"
|
||||
|
||||
jwks = response.json()
|
||||
assert jwks == {"keys": [mocked_key.as_dict(private=False)]}
|
||||
|
||||
# Security checks to make sure no details from the private key are exposed
|
||||
private_details = ["d", "p", "q", "dp", "dq", "qi", "oth", "r", "t"]
|
||||
assert all(
|
||||
private_detail not in jwks["keys"][0].keys()
|
||||
for private_detail in private_details
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
|
||||
def test_view_jwks_invalid_private_key(mock_import_private_key_from_settings):
|
||||
"""JWKS endpoint should return a proper exception when loading keys fails."""
|
||||
|
||||
mock_import_private_key_from_settings.return_value = "wrong_key"
|
||||
|
||||
url = reverse("resource_server_jwks")
|
||||
response = APIClient().get(url)
|
||||
|
||||
mock_import_private_key_from_settings.assert_called_once()
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"error": "Could not load key"}
|
||||
|
||||
|
||||
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
|
||||
def test_view_jwks_missing_private_key(mock_import_private_key_from_settings):
|
||||
"""JWKS endpoint should return a proper exception when private key is missing."""
|
||||
|
||||
mock_import_private_key_from_settings.side_effect = ImproperlyConfigured("foo.")
|
||||
|
||||
url = reverse("resource_server_jwks")
|
||||
response = APIClient().get(url)
|
||||
|
||||
mock_import_private_key_from_settings.assert_called_once()
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"error": "foo."}
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the resource server API endpoints."""
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Defines fixtures for the resource server API tests."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
|
||||
from core.resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
User = get_user_model()
|
||||
fake = Faker()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _force_login_via_resource_server(
|
||||
client_fixture,
|
||||
user: User,
|
||||
service_provider_audience: Optional[str],
|
||||
):
|
||||
"""
|
||||
Context manager to authenticate a user with a service provider via
|
||||
a resource server call.
|
||||
|
||||
This allows to authenticate a user with a service provider without doing
|
||||
all the introspection process.
|
||||
|
||||
This is a private function, use the `force_login_via_resource_server`
|
||||
fixture instead.
|
||||
|
||||
The `service_provider_audience` parameter might not match any existing
|
||||
service provider audience, doing so allow to check the behavior when
|
||||
the service provider is not yet known.
|
||||
"""
|
||||
|
||||
def mock_authenticate(self, request): # pylint: disable=unused-argument
|
||||
request.resource_server_token_audience = (
|
||||
service_provider_audience or fake.pystr(min_chars=10, max_chars=10)
|
||||
)
|
||||
return user, "unused-token"
|
||||
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication, "authenticate", mock_authenticate
|
||||
):
|
||||
client_fixture.force_login(
|
||||
user,
|
||||
backend="core.resource_server.authentication.ResourceServerAuthentication",
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="force_login_via_resource_server")
|
||||
@responses.activate
|
||||
def force_login_via_resource_server_fixture():
|
||||
"""
|
||||
Fixture to authenticate a user with a service provider via a resource server call.
|
||||
|
||||
Usage:
|
||||
```
|
||||
def test_login_with_resource_server(
|
||||
client, force_login_via_resource_server,
|
||||
):
|
||||
user = UserFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/<whatever>/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
# response is authenticated
|
||||
```
|
||||
"""
|
||||
return _force_login_via_resource_server
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the resource server Team API endpoints."""
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: create
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_201_CREATED,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import OrganizationFactory, ServiceProviderFactory, UserFactory
|
||||
from core.models import ServiceProvider, Team
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create teams."""
|
||||
response = APIClient().post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert not Team.objects.exists()
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_new_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and should automatically be declared
|
||||
as the owner of the newly created team and a new service provider should be created and
|
||||
associated to the team.
|
||||
"""
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
|
||||
with force_login_via_resource_server(client, user, "some_service_provider"):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
team = Team.objects.get()
|
||||
team_access = team.accesses.get()
|
||||
service_provider = ServiceProvider.objects.get() # service provider created
|
||||
|
||||
assert response.json() == {
|
||||
"created_at": team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"id": str(team.pk),
|
||||
"name": "my team",
|
||||
"updated_at": team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
}
|
||||
|
||||
# check team data
|
||||
assert team.name == "my team"
|
||||
assert team.organization == organization
|
||||
|
||||
# check team access data
|
||||
assert team_access.role == "owner"
|
||||
assert team_access.user == user
|
||||
|
||||
# check service provider data
|
||||
assert service_provider.audience_id == "some_service_provider"
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_existing_service_provider(
|
||||
client,
|
||||
force_login_via_resource_server,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and should automatically be declared
|
||||
as the owner of the newly created team and an existing service provider should be associated
|
||||
to the team.
|
||||
"""
|
||||
user = UserFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
assert ServiceProvider.objects.count() == 1 # no object created
|
||||
team = Team.objects.get() # team created
|
||||
assert team.service_providers.get().audience_id == service_provider.audience_id
|
||||
assert team.name == "my team"
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_teams_create_cannot_override_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and not
|
||||
be able to set the organization manually (for now).
|
||||
"""
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
service_provider = ServiceProviderFactory()
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
"organization": OrganizationFactory(
|
||||
with_registration_id=True
|
||||
).pk, # ignored
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.organization == organization
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_teams_create_cannot_override_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and not
|
||||
be able to set the team service provider manually.
|
||||
"""
|
||||
user = UserFactory(with_organization=True)
|
||||
service_provider = ServiceProviderFactory()
|
||||
|
||||
other_service_provider = ServiceProviderFactory()
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
"service_providers": [str(other_service_provider.pk)], # ignored
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.service_providers.get().audience_id == service_provider.audience_id
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: delete
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a team."""
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert models.Team.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_teams_delete_not_allowed(client, force_login_via_resource_server):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team from a resource
|
||||
server, even if they have the right permissions.
|
||||
|
||||
This may be implemented in the future, but for now, it is not allowed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, "owner")], service_providers=[service_provider]
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.delete(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "DELETE" not allowed.'}
|
||||
assert models.Team.objects.count() == 1
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list teams."""
|
||||
factories.TeamFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get("/resource-server/v1.0/teams/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
|
||||
client, django_assert_num_queries, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list teams
|
||||
they are an owner/administrator/member of, and only list from the
|
||||
requesting service provider should appear.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
hidden_service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team_access_1 = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[service_provider], role="member"
|
||||
)
|
||||
team_1 = team_access_1.team
|
||||
|
||||
team_access_2 = factories.TeamAccessFactory(
|
||||
user=user,
|
||||
team__service_providers=[hidden_service_provider, service_provider],
|
||||
role="member",
|
||||
)
|
||||
team_2 = team_access_2.team
|
||||
|
||||
team_access_3 = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[service_provider], role="administrator"
|
||||
)
|
||||
team_3 = team_access_3.team
|
||||
|
||||
team_access_4 = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[service_provider], role="owner"
|
||||
)
|
||||
team_4 = team_access_4.team
|
||||
|
||||
# Team filtered out because of the service provider
|
||||
_not_included_team_access = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[hidden_service_provider]
|
||||
)
|
||||
|
||||
# Authenticate using the resource server, ie via the Authorization header
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
with django_assert_num_queries(4): # Count, Team, ServiceProvider, TeamAccess
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/?ordering=created_at",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"count": 4,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"created_at": team_1.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"id": str(team_1.pk),
|
||||
"name": team_1.name,
|
||||
"updated_at": team_1.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
"created_at": team_2.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"id": str(team_2.pk),
|
||||
"name": team_2.name,
|
||||
"updated_at": team_2.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
"created_at": team_3.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"id": str(team_3.pk),
|
||||
"name": team_3.name,
|
||||
"updated_at": team_3.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
"created_at": team_4.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"id": str(team_4.pk),
|
||||
"name": team_4.name,
|
||||
"updated_at": team_4.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated_new_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Team list from not yet known service provider should be empty (not fail).
|
||||
|
||||
Teams without service providers should not be listed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
_team = factories.TeamFactory(users=[user])
|
||||
|
||||
with force_login_via_resource_server(client, user, "some_service_provider"):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated_distinct(client, force_login_via_resource_server):
|
||||
"""A team with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[user, other_user], service_providers=[service_provider]
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert content["count"] == 1
|
||||
|
||||
results = content["results"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == str(team.id)
|
||||
|
||||
|
||||
def test_api_teams_order_param(client, force_login_via_resource_server):
|
||||
"""
|
||||
Test that the 'created_at' field is sorted in ascending order
|
||||
when the 'ordering' query parameter is set.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team_ids = [
|
||||
str(team.id)
|
||||
for team in factories.TeamFactory.create_batch(
|
||||
5, users=[user], service_providers=[service_provider]
|
||||
)
|
||||
]
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/?ordering=created_at",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
), "created_at values are not sorted from oldest to newest"
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.status import HTTP_404_NOT_FOUND
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a team."""
|
||||
team = factories.TeamFactory()
|
||||
response = APIClient().get(f"/resource-server/v1.0/teams/{team.id}/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_unrelated(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a team to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team = factories.TeamFactory(service_providers=[service_provider])
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a team to which they
|
||||
are related whatever the role even if the request is authenticated via
|
||||
a resource server.
|
||||
"""
|
||||
service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[user], service_providers=[service_provider])
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_other_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve a team
|
||||
if the request is authenticated via a different resource server.
|
||||
"""
|
||||
user = UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
team = factories.TeamFactory(
|
||||
users=[user], service_providers=[other_service_provider]
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: update
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.resource_server import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a team."""
|
||||
team = factories.TeamFactory()
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
response = APIClient().put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_unrelated(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be allowed to update a team to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
team = factories.TeamFactory(service_providers=[service_provider])
|
||||
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated(
|
||||
client,
|
||||
force_login_via_resource_server,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to update a team to which they
|
||||
are related whatever the role even if the request is authenticated via
|
||||
a resource server.
|
||||
"""
|
||||
service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(
|
||||
name="Old name",
|
||||
users=[(user, "owner")],
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
data=serializers.TeamSerializer(instance=team).data
|
||||
| {
|
||||
"name": "New name",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.name == "New name"
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_other_resource_server(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to update a team for which they are directly
|
||||
owner, if the request is authenticated via a different service provider.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
team = factories.TeamFactory(
|
||||
name="Old name",
|
||||
users=[(user, "owner")],
|
||||
service_providers=[other_service_provider],
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
data=serializers.TeamSerializer(instance=team).data
|
||||
| {
|
||||
"name": "New name",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.name == "Old name"
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_members(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Users who are members of a team but not administrators should
|
||||
not be allowed to update it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, "member")], service_providers=[service_provider]
|
||||
)
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_teams_update_authenticated_administrators(
|
||||
client, force_login_via_resource_server, role
|
||||
):
|
||||
"""Administrators or owners of a team should be allowed to update it."""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, role)],
|
||||
service_providers=[service_provider],
|
||||
name="old name",
|
||||
)
|
||||
initial_created_at = team.created_at
|
||||
initial_updated_at = team.updated_at
|
||||
initial_pk = team.pk
|
||||
|
||||
# generate new random values
|
||||
new_values = serializers.TeamSerializer(instance=factories.TeamFactory.build()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.pk == initial_pk
|
||||
assert team.name == new_values["name"]
|
||||
assert team.created_at == initial_created_at
|
||||
assert team.updated_at > initial_updated_at
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_teams_update_administrator_or_owner_of_another(
|
||||
client, force_login_via_resource_server, role
|
||||
):
|
||||
"""
|
||||
Being administrator or owner of a team should not grant authorization to update
|
||||
another team.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
factories.TeamFactory(users=[(user, role)], service_providers=[service_provider])
|
||||
|
||||
team = factories.TeamFactory(name="Old name", service_providers=[service_provider])
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Test for the service providers viewset.
|
||||
"""
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Tests for Service Provider API endpoint in People's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_service_providers_list_anonymous(client):
|
||||
"""Anonymous users should not be allowed to list service providers."""
|
||||
factories.ServiceProviderFactory.create_batch(2)
|
||||
|
||||
response = client.get("/api/v1.0/service-providers/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_service_providers_list_authenticated(client):
|
||||
"""
|
||||
Authenticated users should be able to list service providers
|
||||
of their organization.
|
||||
"""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
client.force_login(user)
|
||||
|
||||
service_provider_1 = factories.ServiceProviderFactory(
|
||||
name="A", organizations=[user.organization]
|
||||
)
|
||||
service_provider_2 = factories.ServiceProviderFactory(
|
||||
name="B", organizations=[user.organization]
|
||||
)
|
||||
|
||||
# Generate some not fetched data
|
||||
factories.ServiceProviderFactory.create_batch(
|
||||
2, organizations=[factories.OrganizationFactory(with_registration_id=True)]
|
||||
) # Other service providers
|
||||
factories.TeamFactory(
|
||||
users=[user], service_providers=[factories.ServiceProviderFactory()]
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/service-providers/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"count": 2,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"audience_id": str(service_provider_1.audience_id),
|
||||
"id": str(service_provider_1.pk),
|
||||
"name": "A",
|
||||
},
|
||||
{
|
||||
"audience_id": str(service_provider_2.audience_id),
|
||||
"id": str(service_provider_2.pk),
|
||||
"name": "B",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_service_providers_order(client):
|
||||
"""Test that the service providers are sorted as requested."""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
factories.ServiceProviderFactory(name="A", organizations=[user.organization])
|
||||
factories.ServiceProviderFactory(name="B", organizations=[user.organization])
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
# Test ordering by name descending
|
||||
response = client.get("/api/v1.0/service-providers/?ordering=-name")
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()["results"]
|
||||
assert response_data[0]["name"] == "B"
|
||||
assert response_data[1]["name"] == "A"
|
||||
|
||||
# Test ordering by creation date ascending
|
||||
response = client.get("/api/v1.0/service-providers/?ordering=created_at")
|
||||
response_data = response.json()["results"]
|
||||
assert response_data[0]["name"] == "A"
|
||||
assert response_data[1]["name"] == "B"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Tests for Service Provider API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_service_providers_retrieve_anonymous(client):
|
||||
"""Anonymous users should not be allowed to retrieve service providers."""
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_service_providers_retrieve_authenticated_allowed(client):
|
||||
"""
|
||||
Authenticated users should be able to retrieve service providers
|
||||
of their organization.
|
||||
"""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
client.force_login(user)
|
||||
|
||||
service_provider = factories.ServiceProviderFactory(
|
||||
organizations=[user.organization]
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"audience_id": str(service_provider.audience_id),
|
||||
"id": str(service_provider.pk),
|
||||
"name": service_provider.name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_service_providers_retrieve_authenticated_other_organization(client):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve service providers
|
||||
of other organization.
|
||||
"""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
client.force_login(user)
|
||||
|
||||
service_provider = factories.ServiceProviderFactory(
|
||||
organizations=[factories.OrganizationFactory(with_registration_id=True)]
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No ServiceProvider matches the given query."}
|
||||
|
||||
|
||||
def test_api_service_providers_retrieve_authenticated_on_teams(client):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve service providers
|
||||
just because it is related to one of their teams if it is not related
|
||||
to their organization (might change later if needed).
|
||||
"""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
client.force_login(user)
|
||||
|
||||
other_organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
factories.TeamFactory(
|
||||
users=[user],
|
||||
organization=other_organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No ServiceProvider matches the given query."}
|
||||
@@ -0,0 +1 @@
|
||||
"""Core Swagger tests."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Team accesses tests package."""
|
||||
@@ -223,7 +223,7 @@ def test_api_team_accesses__list_find_members_by_email():
|
||||
Authenticated users should be able to search users access with a case-insensitive and
|
||||
partial query on the email.
|
||||
"""
|
||||
user = factories.UserFactory(name=None)
|
||||
user = factories.UserFactory(name=None, email="alicia@example.com")
|
||||
|
||||
# set all names to None to match only on emails
|
||||
colleague1 = factories.UserFactory(name=None, email="prudence_crandall@edu.us")
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.api.client import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Teams tests package."""
|
||||
@@ -5,12 +5,11 @@ 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,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import TeamFactory, UserFactory
|
||||
from core.factories import OrganizationFactory, UserFactory
|
||||
from core.models import Team
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -34,7 +33,8 @@ def test_api_teams_create_authenticated():
|
||||
Authenticated users should be able to create teams and should automatically be declared
|
||||
as the owner of the newly created team.
|
||||
"""
|
||||
user = UserFactory()
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -50,76 +50,34 @@ def test_api_teams_create_authenticated():
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.organization == organization
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_slugify_name():
|
||||
def test_api_teams_create_cannot_override_organization():
|
||||
"""
|
||||
Creating teams should automatically generate a slug.
|
||||
Authenticated users should be able to create teams and not
|
||||
be able to set the organization manually (for now).
|
||||
"""
|
||||
user = UserFactory()
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{"name": "my team"},
|
||||
{
|
||||
"name": "my team",
|
||||
"organization": OrganizationFactory(
|
||||
with_registration_id=True
|
||||
).pk, # ignored
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.slug == "my-team"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"param",
|
||||
[
|
||||
("my team", "my-team"),
|
||||
("my team", "my-team"),
|
||||
("MY TEAM TOO", "my-team-too"),
|
||||
("mon équipe", "mon-equipe"),
|
||||
("front devs & UX", "front-devs-ux"),
|
||||
],
|
||||
)
|
||||
def test_api_teams_create_authenticated_expected_slug(param):
|
||||
"""
|
||||
Creating teams should automatically create unaccented, no unicode, lower-case slug.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": param[0],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == param[0]
|
||||
assert team.slug == param[1]
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_unique_slugs():
|
||||
"""
|
||||
Creating teams should raise an error if already existing slug.
|
||||
"""
|
||||
TeamFactory(name="existing team")
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": "èxisting team",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["slug"] == ["Team with this Slug already exists."]
|
||||
assert team.organization == organization
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
Tests for Teams API endpoint in People's core app: list
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -47,60 +44,13 @@ def test_api_teams_list_authenticated():
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
results = response.json()["results"]
|
||||
results = response.json()
|
||||
|
||||
assert len(results) == 5
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
def test_api_teams_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team_ids = [
|
||||
str(access.team.id)
|
||||
for access in factories.TeamAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/teams/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
team_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/teams/"
|
||||
|
||||
assert len(content["results"]) == 1
|
||||
team_ids.remove(content["results"][0]["id"])
|
||||
assert team_ids == []
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated_distinct():
|
||||
"""A team with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
@@ -118,8 +68,8 @@ def test_api_teams_list_authenticated_distinct():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(team.id)
|
||||
assert len(content) == 1
|
||||
assert content[0]["id"] == str(team.id)
|
||||
|
||||
|
||||
def test_api_teams_order():
|
||||
@@ -142,7 +92,7 @@ def test_api_teams_order():
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
response_team_ids = [team["id"] for team in response_data]
|
||||
|
||||
team_ids.reverse()
|
||||
assert (
|
||||
@@ -170,8 +120,7 @@ def test_api_teams_order_param():
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
response_team_ids = [team["id"] for team in response_data]
|
||||
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
|
||||
@@ -69,8 +69,8 @@ def test_api_teams_retrieve_authenticated_related():
|
||||
assert response.json() == {
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"slug": team.slug,
|
||||
"abilities": team.get_abilities(user),
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"service_providers": [],
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import random
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
@@ -15,7 +14,7 @@ from rest_framework.status import (
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
from core.api.client import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -125,7 +124,7 @@ def test_api_teams_update_authenticated_administrators():
|
||||
elif key == "updated_at":
|
||||
assert value > initial_values[key]
|
||||
else:
|
||||
# name, slug and abilities successfully modified
|
||||
# name and abilities successfully modified
|
||||
assert value == new_values[key]
|
||||
|
||||
|
||||
@@ -158,7 +157,7 @@ def test_api_teams_update_authenticated_owners():
|
||||
elif key == "updated_at":
|
||||
assert value > old_team_values[key]
|
||||
else:
|
||||
# name, slug and abilities successfully modified
|
||||
# name and abilities successfully modified
|
||||
assert value == new_team_values[key]
|
||||
|
||||
|
||||
@@ -191,29 +190,32 @@ def test_api_teams_update_administrator_or_owner_of_another():
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_existing_slug_should_return_error():
|
||||
def test_api_teams_update_authenticated_owners_add_service_providers():
|
||||
"""
|
||||
Updating a team's name to an existing slug should return a bad request,
|
||||
instead of creating a duplicate.
|
||||
Owners of a team should be allowed to update its service providers.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.TeamFactory(name="Existing team", users=[(user, "administrator")])
|
||||
my_team = factories.TeamFactory(name="New team", users=[(user, "administrator")])
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
new_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
service_provider_1 = factories.ServiceProviderFactory()
|
||||
service_provider_2 = factories.ServiceProviderFactory()
|
||||
new_team_values["service_providers"] = [
|
||||
service_provider_1.pk,
|
||||
service_provider_2.pk,
|
||||
]
|
||||
|
||||
updated_values = serializers.TeamSerializer(instance=my_team).data
|
||||
# Update my team's name for existing team. Creates a duplicate slug
|
||||
updated_values["name"] = "existing team"
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{my_team.id!s}/",
|
||||
updated_values,
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["slug"] == ["Team with this Slug already exists."]
|
||||
# Both teams names and slugs should be unchanged
|
||||
assert my_team.name == "New team"
|
||||
assert my_team.slug == "new-team"
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.service_providers.count() == 2
|
||||
assert set(team.service_providers.all()) == {service_provider_1, service_provider_2}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user