Compare commits
191 Commits
fix/groups
...
v1.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
@@ -196,15 +201,35 @@ jobs:
|
||||
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 +251,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 +331,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
|
||||
+179
@@ -7,3 +7,182 @@ and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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.6.1...main
|
||||
[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
|
||||
|
||||
+16
-24
@@ -1,15 +1,14 @@
|
||||
# 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
|
||||
@@ -40,7 +39,7 @@ 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
|
||||
@@ -88,11 +87,9 @@ 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
|
||||
@@ -116,16 +113,13 @@ 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
|
||||
@@ -155,9 +149,7 @@ FROM core as backend-development
|
||||
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
|
||||
|
||||
@@ -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.12
|
||||
pull_policy: always
|
||||
environment:
|
||||
DIMAIL_MODE: FAKE
|
||||
DIMAIL_JWT_SECRET: fake_jwt_secret
|
||||
ports:
|
||||
- "8001:8000"
|
||||
|
||||
@@ -99,6 +99,216 @@
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member",
|
||||
"email": "jean.team-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator",
|
||||
"email": "jean.team-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner",
|
||||
"email": "jean.team-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-member",
|
||||
"email": "jean.mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-administrator",
|
||||
"email": "jean.mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-owner",
|
||||
"email": "jean.mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-member",
|
||||
"email": "jean.team-member-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-administrator",
|
||||
"email": "jean.team-member-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-owner",
|
||||
"email": "jean.team-member-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-member",
|
||||
"email": "jean.team-administrator-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-administrator",
|
||||
"email": "jean.team-administrator-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-owner",
|
||||
"email": "jean.team-administrator-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-member",
|
||||
"email": "jean.team-owner-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-administrator",
|
||||
"email": "jean.team-owner-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-owner",
|
||||
"email": "jean.team-owner-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.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
|
||||
@@ -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,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:
|
||||
- `jean.team-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only belong to a team with the specified role.
|
||||
- `jean.mail-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only have a mailbox with the specified role access.
|
||||
- `jean.team-<team_role>-mail-<domain_role>@example.com` with a combination of roles as for the
|
||||
previous accounts.
|
||||
|
||||
For each account, the password is `password-e2e-<username>`, for instance `password-e2e-jean.team-member`.
|
||||
|
||||
In the E2E tests you can use these accounts to benefit from there accesses,
|
||||
using the `keyCloakSignIn(page, browserName, <account_name>)`. The account name is the
|
||||
username without the prefix `jean.`.
|
||||
|
||||
``` 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 -a 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 @@
|
||||
"""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,
|
||||
@@ -40,7 +52,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("email", "language", "timezone")}),
|
||||
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -65,10 +77,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 +91,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,6 +99,14 @@ 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")
|
||||
|
||||
|
||||
@admin.register(models.Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
@@ -95,7 +115,6 @@ class TeamAdmin(admin.ModelAdmin):
|
||||
inlines = (TeamAccessInline, TeamWebhookInline)
|
||||
list_display = (
|
||||
"name",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
@@ -139,8 +158,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 +172,44 @@ 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",
|
||||
"base",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for organizations."""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
inlines = (OrganizationAccessInline,)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -34,6 +34,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)
|
||||
|
||||
@@ -69,6 +71,39 @@ 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()
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"email",
|
||||
"id",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
"language",
|
||||
"name",
|
||||
"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 +205,6 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
slug = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
@@ -179,7 +213,6 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"name",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -187,11 +220,18 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new team with organization enforcement."""
|
||||
# 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 +239,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."""
|
||||
|
||||
@@ -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,8 +11,10 @@ from rest_framework import (
|
||||
pagination,
|
||||
response,
|
||||
throttling,
|
||||
views,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from core import models
|
||||
|
||||
@@ -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):
|
||||
@@ -147,19 +155,11 @@ class ContactViewSet(
|
||||
overriding_contacts__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)
|
||||
@@ -173,20 +173,22 @@ class ContactViewSet(
|
||||
|
||||
|
||||
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")
|
||||
serializer_class = serializers.UserSerializer
|
||||
get_me_serializer_class = serializers.UserMeSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
|
||||
@@ -204,22 +206,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 +226,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,6 +245,7 @@ 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."""
|
||||
@@ -337,6 +327,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 +339,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 +471,21 @@ 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)
|
||||
|
||||
@@ -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,20 @@ from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import 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 +67,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 +83,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 +156,75 @@ 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,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -119,6 +119,25 @@ class ContactFactory(BaseContactFactory):
|
||||
owner = factory.SubFactory("core.factories.UserFactory", profile_contact=None)
|
||||
|
||||
|
||||
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 UserFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create random users for testing purposes."""
|
||||
|
||||
@@ -126,6 +145,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")
|
||||
|
||||
@@ -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, validators=[core.models.validate_unique_registration_id], 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, validators=[core.models.validate_unique_domain], 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',
|
||||
),
|
||||
]
|
||||
+261
-13
@@ -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__)
|
||||
|
||||
@@ -44,6 +48,17 @@ class RoleChoices(models.TextChoices):
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
|
||||
class OrganizationRoleChoices(models.TextChoices):
|
||||
"""
|
||||
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
|
||||
@@ -124,12 +139,12 @@ class Contact(BaseModel):
|
||||
unique_together = ("owner", "base")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=~models.Q(base__isnull=False, owner__isnull=True),
|
||||
condition=~models.Q(base__isnull=False, owner__isnull=True),
|
||||
name="base_owner_constraint",
|
||||
violation_error_message="A contact overriding a base contact must be owned.",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=~models.Q(base=models.F("id")),
|
||||
condition=~models.Q(base=models.F("id")),
|
||||
name="base_not_self",
|
||||
violation_error_message="A contact cannot be based on itself.",
|
||||
),
|
||||
@@ -158,6 +173,140 @@ class Contact(BaseModel):
|
||||
raise exceptions.ValidationError({"data": [error_message]}) from e
|
||||
|
||||
|
||||
class OrganizationManager(models.Manager):
|
||||
"""
|
||||
Custom manager for the Organization model, to manage complexity/automation.
|
||||
"""
|
||||
|
||||
def get_or_create_from_user_claims(
|
||||
self, registration_id: str = None, domain: str = None, **kwargs
|
||||
) -> Tuple["Organization", bool]:
|
||||
"""
|
||||
Get or create an organization using the most fitting information from the user's claims.
|
||||
|
||||
We expect to have only one organization per registration_id, but
|
||||
the registration_id might not be provided.
|
||||
When the registration_id is not provided, we use the domain to identify the organization.
|
||||
|
||||
If both are provided, we use the registration_id first to create missing organization.
|
||||
|
||||
Dev note: When a registration_id is provided by the Identity Provider, we don't want
|
||||
to use the domain to create the organization, because it is less reliable: for example,
|
||||
a professional user, may have a personal email address, and the domain would be gmail.com
|
||||
which is not a good identifier for an organization. The domain email is just a fallback
|
||||
when the registration_id is not provided by the Identity Provider. We can use the domain
|
||||
to create the organization manually when we are sure about the "safety" of it.
|
||||
"""
|
||||
if not any([registration_id, domain]):
|
||||
raise ValueError("You must provide either a registration_id or a domain.")
|
||||
|
||||
filters = models.Q()
|
||||
if registration_id:
|
||||
filters |= models.Q(registration_id_list__icontains=registration_id)
|
||||
if domain:
|
||||
filters |= models.Q(domain_list__icontains=domain)
|
||||
|
||||
with suppress(self.model.DoesNotExist):
|
||||
# If there are several organizations, we must raise an error and fix the data
|
||||
# If there is an organization, we return it
|
||||
return self.get(filters, **kwargs), False
|
||||
|
||||
# Manage the case where the organization does not exist: we create one
|
||||
if registration_id:
|
||||
return self.create(
|
||||
name=registration_id, registration_id_list=[registration_id], **kwargs
|
||||
), True
|
||||
|
||||
if domain:
|
||||
return self.create(name=domain, domain_list=[domain], **kwargs), True
|
||||
|
||||
raise ValueError("Should never reach this point.")
|
||||
|
||||
|
||||
def validate_unique_registration_id(value):
|
||||
"""
|
||||
Validate that the registration ID values in an array field are unique across all instances.
|
||||
"""
|
||||
if Organization.objects.filter(registration_id_list__overlap=value).exists():
|
||||
raise ValidationError(
|
||||
"registration_id_list value must be unique across all instances."
|
||||
)
|
||||
|
||||
|
||||
def validate_unique_domain(value):
|
||||
"""
|
||||
Validate that the domain values in an array field are unique across all instances.
|
||||
"""
|
||||
if Organization.objects.filter(domain_list__overlap=value).exists():
|
||||
raise ValidationError("domain_list value must be unique across all instances.")
|
||||
|
||||
|
||||
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,
|
||||
validators=[
|
||||
validate_unique_registration_id,
|
||||
],
|
||||
)
|
||||
domain_list = ArrayField(
|
||||
models.CharField(max_length=256),
|
||||
verbose_name=_("domain list"),
|
||||
default=list,
|
||||
blank=True,
|
||||
validators=[validate_unique_domain],
|
||||
)
|
||||
|
||||
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})"
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
@@ -218,6 +367,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()
|
||||
|
||||
@@ -284,6 +440,101 @@ 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):
|
||||
"""
|
||||
@@ -291,7 +542,6 @@ class Team(BaseModel):
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(max_length=100, unique=True, null=False, editable=False)
|
||||
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
@@ -299,6 +549,13 @@ 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
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_team"
|
||||
@@ -309,15 +566,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,55 @@
|
||||
"""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
|
||||
@@ -0,0 +1,224 @@
|
||||
"""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},
|
||||
)
|
||||
|
||||
# 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.
|
||||
"""
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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."""
|
||||
|
||||
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,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 @@
|
||||
"""Core resource server tests."""
|
||||
@@ -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"}
|
||||
|
||||
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"}}
|
||||
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 @@
|
||||
"""Core Swagger tests."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -47,60 +47,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 +71,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 +95,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 (
|
||||
@@ -171,7 +124,7 @@ def test_api_teams_order_param():
|
||||
|
||||
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,7 +69,6 @@ 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"),
|
||||
|
||||
@@ -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,
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -189,31 +188,3 @@ def test_api_teams_update_administrator_or_owner_of_another():
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_existing_slug_should_return_error():
|
||||
"""
|
||||
Updating a team's name to an existing slug should return a bad request,
|
||||
instead of creating a duplicate.
|
||||
"""
|
||||
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")])
|
||||
|
||||
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,
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Test users API endpoints in the People core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_config_anonymous():
|
||||
"""Anonymous users should be allowed to get the configuration."""
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
|
||||
"COMMIT": "NA",
|
||||
"FEATURES": {
|
||||
"CONTACTS_DISPLAY": True,
|
||||
"CONTACTS_CREATE": True,
|
||||
"MAILBOXES_CREATE": True,
|
||||
"TEAMS_DISPLAY": True,
|
||||
"TEAMS_CREATE": True,
|
||||
},
|
||||
"RELEASE": "NA",
|
||||
}
|
||||
|
||||
|
||||
def test_api_config_authenticated():
|
||||
"""Authenticated users should be allowed to get the configuration."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
|
||||
"COMMIT": "NA",
|
||||
"FEATURES": {
|
||||
"CONTACTS_DISPLAY": True,
|
||||
"CONTACTS_CREATE": True,
|
||||
"MAILBOXES_CREATE": True,
|
||||
"TEAMS_DISPLAY": True,
|
||||
"TEAMS_CREATE": True,
|
||||
},
|
||||
"RELEASE": "NA",
|
||||
}
|
||||
@@ -76,7 +76,7 @@ def test_api_contacts_list_authenticated_no_query():
|
||||
|
||||
# Let's have 5 contacts in database:
|
||||
assert user.profile_contact is not None # Excluded because profile contact
|
||||
base_contact = factories.BaseContactFactory() # Excluded because overriden
|
||||
base_contact = factories.BaseContactFactory() # Excluded because overridden
|
||||
factories.ContactFactory(
|
||||
base=base_contact
|
||||
) # Excluded because belongs to other user
|
||||
@@ -131,19 +131,17 @@ def test_api_contacts_list_authenticated_by_full_name():
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id)]
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/contacts/?q=ole")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
# "Nicole Foole" matches twice on "ole"
|
||||
assert contact_ids == [str(nicole.id), str(frank.id)]
|
||||
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(nicole.id), str(frank.id)]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_uppercase_content():
|
||||
@@ -395,7 +393,7 @@ def test_api_contacts_create_authenticated_successful():
|
||||
@override_settings(ALLOW_API_USER_CREATE=True)
|
||||
def test_api_contacts_create_authenticated_existing_override():
|
||||
"""
|
||||
Trying to create a contact for base contact that is already overriden by the user
|
||||
Trying to create a contact for base contact that is already overridden by the user
|
||||
should receive a 400 error.
|
||||
"""
|
||||
user = factories.UserFactory(profile_contact=None)
|
||||
|
||||
@@ -16,6 +16,9 @@ from rest_framework.test import APIClient
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.api.viewsets import Pagination
|
||||
from core.factories import TeamAccessFactory
|
||||
|
||||
from mailbox_manager.factories import MailDomainAccessFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -78,28 +81,16 @@ def test_api_users_authenticated_list_by_email():
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.id)
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
# "Nicole Foole" matches twice on "ole"
|
||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
||||
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
# Even with a low similarity threshold, query should match expected emails
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.is_device,
|
||||
"is_staff": nicole.is_staff,
|
||||
"language": nicole.language,
|
||||
"timezone": str(nicole.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.id),
|
||||
"email": frank.email,
|
||||
@@ -109,6 +100,15 @@ def test_api_users_authenticated_list_by_email():
|
||||
"language": frank.language,
|
||||
"timezone": str(frank.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(nicole.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.is_device,
|
||||
"is_staff": nicole.is_staff,
|
||||
"language": nicole.language,
|
||||
"timezone": str(nicole.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -118,9 +118,9 @@ def test_api_users_authenticated_list_by_name():
|
||||
partial query on the name.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(name="dave bowman", email=None)
|
||||
dave = factories.UserFactory(name="Dave bowman", email=None)
|
||||
nicole = factories.UserFactory(name="nicole foole", email=None)
|
||||
frank = factories.UserFactory(name="frank poole", email=None)
|
||||
frank = factories.UserFactory(name="frank poolé", email=None)
|
||||
factories.UserFactory(name="heywood floyd", email=None)
|
||||
|
||||
client = APIClient()
|
||||
@@ -128,7 +128,7 @@ def test_api_users_authenticated_list_by_name():
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
"/api/v1.0/users/?q=dave",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -142,28 +142,16 @@ def test_api_users_authenticated_list_by_name():
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.id)
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
# "Nicole Foole" matches twice on "ole"
|
||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
||||
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
# Even with a low similarity threshold, query should match expected user
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
response = client.get("/api/v1.0/users/?q=oole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.is_device,
|
||||
"is_staff": nicole.is_staff,
|
||||
"language": nicole.language,
|
||||
"timezone": str(nicole.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.id),
|
||||
"email": frank.email,
|
||||
@@ -173,6 +161,15 @@ def test_api_users_authenticated_list_by_name():
|
||||
"language": frank.language,
|
||||
"timezone": str(frank.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(nicole.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.is_device,
|
||||
"is_staff": nicole.is_staff,
|
||||
"language": nicole.language,
|
||||
"timezone": str(nicole.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -184,22 +181,18 @@ def test_api_users_authenticated_list_by_name_and_email():
|
||||
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
nicole = factories.UserFactory(email="nicole_foole@work.com", name="nicole foole")
|
||||
frank = factories.UserFactory(email="oleg_poole@work.com", name=None)
|
||||
oleg = factories.UserFactory(email="oleg_poole@work.com", name=None)
|
||||
david = factories.UserFactory(email=None, name="david role")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Result that matches a trigram in name and email ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
|
||||
# "Nicole Foole" matches twice on "ole" in her name and twice on "ole" in her email
|
||||
# "Oleg poole" matches twice on "ole" in her email
|
||||
# "David role" matches once on "ole" in his name
|
||||
assert user_ids == [str(nicole.id), str(frank.id), str(david.id)]
|
||||
assert user_ids == [str(david.id), str(oleg.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_exclude_users_already_in_team(
|
||||
@@ -468,6 +461,77 @@ def test_api_users_retrieve_me_authenticated():
|
||||
"timezone": str(user.timezone),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
"abilities": {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": False, "can_view": False},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_authenticated_abilities():
|
||||
"""
|
||||
Authenticated users should be able to retrieve their own user via the "/users/me" path
|
||||
with the proper abilities.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Define profile contact
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
|
||||
# Test the mailboxes abilities
|
||||
mail_domain_access = MailDomainAccessFactory(user=user)
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
}
|
||||
|
||||
# Test the teams abilities - user is not an admin/owner
|
||||
team_access = TeamAccessFactory(user=user, role=models.RoleChoices.MEMBER)
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
}
|
||||
|
||||
# Test the teams abilities - user is an admin/owner
|
||||
team_access.role = models.RoleChoices.ADMIN
|
||||
team_access.save()
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": True, "can_view": True},
|
||||
}
|
||||
|
||||
# Test the mailboxes abilities - user has no mail domain access anymore
|
||||
mail_domain_access.delete()
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": False, "can_view": False},
|
||||
"teams": {"can_create": True, "can_view": True},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Unit tests for the Organization model
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_organization_str():
|
||||
"""The str representation should be the organization's name."""
|
||||
organization = factories.OrganizationFactory(
|
||||
name="HAL 9000", registration_id_list=["12345678901234"]
|
||||
)
|
||||
assert str(organization) == f"HAL 9000 (# {organization.pk})"
|
||||
|
||||
|
||||
def test_models_organization_constraints():
|
||||
"""It should not be possible to create an organization."""
|
||||
organization = factories.OrganizationFactory(
|
||||
registration_id_list=["12345678901234"], domain_list=["hal9000.com"]
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
models.Organization.objects.create(name="HAL 9000")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
models.Organization.objects.create(
|
||||
name="HAL 9000",
|
||||
registration_id_list=[
|
||||
organization.registration_id_list[0],
|
||||
"12345678901235",
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
models.Organization.objects.create(
|
||||
name="HAL 9000", domain_list=[organization.domain_list[0], "hal9001.com"]
|
||||
)
|
||||
|
||||
|
||||
def test_models_organization_get_or_create_from_user_claims_no_kwargs():
|
||||
"""It should fail."""
|
||||
with pytest.raises(ValueError):
|
||||
models.Organization.objects.get_or_create_from_user_claims()
|
||||
|
||||
|
||||
def test_models_organization_get_or_create_from_user_claims_with_registration_id():
|
||||
"""It should create an organization with a registration ID number."""
|
||||
organization, created = models.Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id="12345678901234"
|
||||
)
|
||||
assert created is True
|
||||
assert organization.registration_id_list == ["12345678901234"]
|
||||
assert organization.domain_list == []
|
||||
|
||||
same_organization, created = (
|
||||
models.Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id="12345678901234"
|
||||
)
|
||||
)
|
||||
assert created is False
|
||||
assert organization == same_organization
|
||||
assert same_organization.registration_id_list == ["12345678901234"]
|
||||
assert same_organization.domain_list == []
|
||||
|
||||
|
||||
def test_models_organization_get_or_create_from_user_claims_with_domain():
|
||||
"""It should create an organization with a domain."""
|
||||
organization, created = models.Organization.objects.get_or_create_from_user_claims(
|
||||
domain="hal9000.com"
|
||||
)
|
||||
assert created is True
|
||||
assert organization.registration_id_list == []
|
||||
assert organization.domain_list == ["hal9000.com"]
|
||||
|
||||
same_organization, created = (
|
||||
models.Organization.objects.get_or_create_from_user_claims(domain="hal9000.com")
|
||||
)
|
||||
assert created is False
|
||||
assert organization == same_organization
|
||||
assert same_organization.registration_id_list == []
|
||||
assert same_organization.domain_list == ["hal9000.com"]
|
||||
|
||||
|
||||
def test_models_organization_get_or_create_from_user_claims_with_registration_id_and_domain():
|
||||
"""It should create an organization with a registration ID number."""
|
||||
organization, created = models.Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id="12345678901234", domain="hal9000.com"
|
||||
)
|
||||
assert created is True
|
||||
assert organization.registration_id_list == ["12345678901234"]
|
||||
assert organization.domain_list == []
|
||||
|
||||
same_organization, created = (
|
||||
models.Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id="12345678901234", domain="hal9000.com"
|
||||
)
|
||||
)
|
||||
assert created is False
|
||||
assert organization == same_organization
|
||||
assert same_organization.registration_id_list == ["12345678901234"]
|
||||
assert same_organization.domain_list == []
|
||||
|
||||
|
||||
def test_models_organization_registration_id_validators():
|
||||
"""
|
||||
Test the registration ID validators.
|
||||
|
||||
This cannot be tested dynamically because the validators are set at model loading
|
||||
and this is not possible to reload the models on the fly. We therefore enforce the
|
||||
setting in Test environment.
|
||||
"""
|
||||
models.Organization.objects.create(
|
||||
name="hu",
|
||||
registration_id_list=["12345678901234"],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
models.Organization.objects.create(
|
||||
name="hi",
|
||||
registration_id_list=["a12345678912345"],
|
||||
)
|
||||
@@ -47,12 +47,6 @@ def test_models_teams_name_max_length():
|
||||
factories.TeamFactory(name="a " * 51)
|
||||
|
||||
|
||||
def test_models_teams_slug_empty():
|
||||
"""Slug field should not be empty."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank."):
|
||||
models.Team.objects.create(slug="")
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Test cases for core.validators module.
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.validators import EmailValidator, RegexValidator
|
||||
|
||||
import pytest
|
||||
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
|
||||
def test_get_field_validators_from_setting_without_option(settings):
|
||||
"""Test get_field_validators_from_setting without options."""
|
||||
settings.VALIDATOR_NO_OPTION = [
|
||||
{
|
||||
"NAME": "django.core.validators.EmailValidator",
|
||||
},
|
||||
]
|
||||
|
||||
validators = get_field_validators_from_setting("VALIDATOR_NO_OPTION")
|
||||
assert len(validators) == 1
|
||||
assert isinstance(validators[0], EmailValidator)
|
||||
|
||||
|
||||
def test_get_field_validators_from_setting_with_option(settings):
|
||||
"""Test get_field_validators_from_setting with options."""
|
||||
settings.REGEX_WITH_OPTIONS = [
|
||||
{
|
||||
"NAME": "django.core.validators.RegexValidator",
|
||||
"OPTIONS": {
|
||||
"regex": "[a-z][0-9]{14}",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
validators = get_field_validators_from_setting("REGEX_WITH_OPTIONS")
|
||||
assert len(validators) == 1
|
||||
assert isinstance(validators[0], RegexValidator)
|
||||
assert validators[0].regex.pattern == "[a-z][0-9]{14}"
|
||||
|
||||
|
||||
def test_get_field_validators_from_setting_invalid_class_name(settings):
|
||||
"""Test get_field_validators_from_setting with an invalid class name."""
|
||||
settings.INVALID_VALIDATORS = [
|
||||
{
|
||||
"NAME": "non.existent.Validator",
|
||||
},
|
||||
]
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
get_field_validators_from_setting("INVALID_VALIDATORS")
|
||||
|
||||
|
||||
def test_get_field_validators_from_setting_empty_setting(settings):
|
||||
"""Test get_field_validators_from_setting with an empty setting."""
|
||||
settings.EMPTY_VALIDATORS = []
|
||||
validators = get_field_validators_from_setting("EMPTY_VALIDATORS")
|
||||
assert not validators
|
||||
@@ -0,0 +1 @@
|
||||
"""Core utils module."""
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Declare validators that can be used in our Django models.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
|
||||
def get_field_validators_from_setting(setting_name: str) -> list:
|
||||
"""
|
||||
Get field validators from a setting.
|
||||
|
||||
Highly inspired by Django's `get_password_validators` function.
|
||||
|
||||
The setting should be a list of dictionaries, where each dictionary
|
||||
should have a NAME key that points to the validator class and an
|
||||
optional OPTIONS key that points to the validator options.
|
||||
|
||||
Example:
|
||||
```
|
||||
ORGANIZATION_REGISTRATION_ID_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.core.validators.RegexValidator",
|
||||
"OPTIONS": {
|
||||
"regex": "[a-z][0-9]{14}",
|
||||
},
|
||||
},
|
||||
]
|
||||
```
|
||||
"""
|
||||
validators = []
|
||||
for validator in getattr(settings, setting_name):
|
||||
try:
|
||||
klass = import_string(validator["NAME"])
|
||||
except ImportError as exc:
|
||||
msg = "The module in NAME could not be imported: %s. Check your %s setting."
|
||||
raise ImproperlyConfigured(msg % (validator["NAME"], setting_name)) from exc
|
||||
validators.append(klass(**validator.get("OPTIONS", {})))
|
||||
|
||||
return validators
|
||||
@@ -4,6 +4,7 @@ from django.urls import path
|
||||
|
||||
from .views import (
|
||||
DebugViewHtml,
|
||||
DebugViewNewMailboxHtml,
|
||||
DebugViewTxt,
|
||||
)
|
||||
|
||||
@@ -18,4 +19,9 @@ urlpatterns = [
|
||||
DebugViewTxt.as_view(),
|
||||
name="debug.mail.invitation_txt",
|
||||
),
|
||||
path(
|
||||
"__debug__/mail/new_mailbox_html",
|
||||
DebugViewNewMailboxHtml.as_view(),
|
||||
name="debug.mail.new_mailbox_html",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -25,3 +25,18 @@ class DebugViewTxt(DebugBaseView):
|
||||
"""Debug View for Text Email Layout"""
|
||||
|
||||
template_name = "mail/text/invitation.txt"
|
||||
|
||||
|
||||
class DebugViewNewMailboxHtml(DebugBaseView):
|
||||
"""Debug view for new mailbox email layout"""
|
||||
|
||||
template_name = "mail/html/new_mailbox.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Hardcode user credentials for debug setting."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["mailbox_data"] = {
|
||||
"email": "john.doe@example.com",
|
||||
"password": "6HGVAsjoog_v",
|
||||
}
|
||||
return context
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Demo module."""
|
||||
|
||||
@@ -4,4 +4,5 @@ NB_OBJECTS = {
|
||||
"users": 1000,
|
||||
"teams": 100,
|
||||
"max_users_per_team": 100,
|
||||
"domains": 20,
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Demo management module."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"Demo management commands module."
|
||||
|
||||
@@ -10,12 +10,15 @@ from uuid import uuid4
|
||||
from django import db
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.text import slugify
|
||||
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
|
||||
from demo import defaults
|
||||
from mailbox_manager import models as mailbox_models
|
||||
from mailbox_manager.enums import MailDomainStatusChoices
|
||||
|
||||
fake = Faker()
|
||||
|
||||
@@ -103,7 +106,7 @@ class Timeit:
|
||||
return elapsed_time
|
||||
|
||||
|
||||
def create_demo(stdout):
|
||||
def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
"""
|
||||
Create a database with demo data for developers to work in a realistic environment.
|
||||
The code is engineered to create a huge number of objects fast.
|
||||
@@ -125,6 +128,45 @@ def create_demo(stdout):
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
)
|
||||
# this is a quick fix to fix e2e tests
|
||||
# tests needs some no random data
|
||||
queue.push(
|
||||
models.User(
|
||||
sub=uuid4(),
|
||||
email="monique.test@example.com",
|
||||
name="Monique Test",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
)
|
||||
queue.push(
|
||||
models.User(
|
||||
sub=uuid4(),
|
||||
email="jeanne.test@example.com",
|
||||
name="Jean Test",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
)
|
||||
queue.push(
|
||||
models.User(
|
||||
sub=uuid4(),
|
||||
email="jean.somethingelse@example.com",
|
||||
name="Jean Something",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
)
|
||||
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating teams"):
|
||||
@@ -132,8 +174,6 @@ def create_demo(stdout):
|
||||
queue.push(
|
||||
models.Team(
|
||||
name=f"Team {i:d}",
|
||||
# slug should be automatic but bulk_create doesn't use save
|
||||
slug=f"team-{i:d}",
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
@@ -150,6 +190,108 @@ def create_demo(stdout):
|
||||
queue.push(
|
||||
models.TeamAccess(team_id=team_id, user_id=user_id, role=role[0])
|
||||
)
|
||||
|
||||
with Timeit(stdout, "Creating domains"):
|
||||
created = set()
|
||||
for _i in range(defaults.NB_OBJECTS["domains"]):
|
||||
name = fake.domain_name()
|
||||
if name in created:
|
||||
continue
|
||||
created.add(name)
|
||||
|
||||
slug = slugify(name)
|
||||
|
||||
queue.push(
|
||||
mailbox_models.MailDomain(
|
||||
name=name,
|
||||
# slug should be automatic but bulk_create doesn't use save
|
||||
slug=slug,
|
||||
status=random.choice(MailDomainStatusChoices.choices)[0],
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating accesses to domains"):
|
||||
domains_ids = list(
|
||||
mailbox_models.MailDomain.objects.values_list("id", flat=True)
|
||||
)
|
||||
for domain_id in domains_ids:
|
||||
queue.push(
|
||||
mailbox_models.MailDomainAccess(
|
||||
domain_id=domain_id,
|
||||
user_id=random.choice(users_ids),
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
)
|
||||
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating specific users"):
|
||||
# ⚠️ Warning: this users also need to be created in the keycloak
|
||||
# realm.json AND the OIDC setting to fallback on user email
|
||||
# should be set to True, because we don't pilot the sub.
|
||||
for role in models.RoleChoices.values:
|
||||
team_user = models.User(
|
||||
sub=uuid4(),
|
||||
email=f"jean.team-{role}@example.com",
|
||||
name=f"Jean Group {role.capitalize()}",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
queue.push(team_user)
|
||||
queue.push(
|
||||
models.TeamAccess(team_id=teams_ids[0], user_id=team_user.pk, role=role)
|
||||
)
|
||||
|
||||
for role in models.RoleChoices.values:
|
||||
user_with_mail = models.User(
|
||||
sub=uuid4(),
|
||||
email=f"jean.mail-{role}@example.com",
|
||||
name=f"Jean Mail {role.capitalize()}",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
queue.push(user_with_mail)
|
||||
queue.push(
|
||||
mailbox_models.MailDomainAccess(
|
||||
domain_id=domains_ids[0],
|
||||
user_id=user_with_mail.pk,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
|
||||
for team_role in models.RoleChoices.values:
|
||||
for domain_role in models.RoleChoices.values:
|
||||
team_mail_user = models.User(
|
||||
sub=uuid4(),
|
||||
email=f"jean.team-{team_role}-mail-{domain_role}@example.com",
|
||||
name=f"Jean Group {team_role.capitalize()} Mail {domain_role.capitalize()}",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
queue.push(team_mail_user)
|
||||
queue.push(
|
||||
models.TeamAccess(
|
||||
team_id=teams_ids[0], user_id=team_mail_user.pk, role=team_role
|
||||
)
|
||||
)
|
||||
queue.push(
|
||||
mailbox_models.MailDomainAccess(
|
||||
domain_id=domains_ids[0],
|
||||
user_id=team_mail_user.pk,
|
||||
role=domain_role,
|
||||
)
|
||||
)
|
||||
|
||||
queue.flush()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Demo tests."""
|
||||
|
||||
@@ -10,12 +10,13 @@ import pytest
|
||||
from core import models
|
||||
|
||||
from demo import defaults
|
||||
from mailbox_manager import models as mailbox_models
|
||||
|
||||
TEST_NB_OBJECTS = {
|
||||
"users": 5,
|
||||
"teams": 3,
|
||||
"max_identities_per_user": 3,
|
||||
"max_users_per_team": 5,
|
||||
"domains": 2,
|
||||
}
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -27,9 +28,22 @@ def test_commands_create_demo():
|
||||
"""The create_demo management command should create objects as expected."""
|
||||
call_command("create_demo")
|
||||
|
||||
assert models.User.objects.count() == 5
|
||||
assert models.Team.objects.count() == 3
|
||||
assert models.TeamAccess.objects.count() >= 3
|
||||
# Monique Test, Jeanne Test and Jean Something (quick fix for e2e)
|
||||
# 3 user with team rights
|
||||
# 3 user with domain rights
|
||||
# 3 x 3 user with both rights
|
||||
assert models.User.objects.count() == TEST_NB_OBJECTS["users"] + 3 + 3 + 3 + 9
|
||||
|
||||
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
|
||||
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
|
||||
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"]
|
||||
|
||||
# 3 domain access for each user with domain rights
|
||||
# 3 x 3 domain access for each user with both rights
|
||||
assert (
|
||||
mailbox_models.MailDomainAccess.objects.count()
|
||||
== TEST_NB_OBJECTS["domains"] + 3 + 9
|
||||
)
|
||||
|
||||
|
||||
def test_commands_createsuperuser():
|
||||
|
||||
Binary file not shown.
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-03-21 19:26+0000\n"
|
||||
"POT-Creation-Date: 2024-11-18 23:02+0000\n"
|
||||
"PO-Revision-Date: 2024-01-03 23:15\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
@@ -17,212 +17,287 @@ msgstr ""
|
||||
"X-Crowdin-File: backend.pot\n"
|
||||
"X-Crowdin-File-ID: 2\n"
|
||||
|
||||
#: core/admin.py:70
|
||||
#: core/admin.py:55
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:72
|
||||
#: core/admin.py:57
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:84
|
||||
#: core/admin.py:69
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:81
|
||||
#: core/admin.py:108
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:89
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:114
|
||||
#: core/authentication/backends.py:111
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:157
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:38
|
||||
#: core/authentication/backends.py:176
|
||||
msgid "Claims contained no recognizable organization identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/enums.py:23
|
||||
msgid "Failure"
|
||||
msgstr ""
|
||||
|
||||
#: core/enums.py:24 mailbox_manager/enums.py:21 mailbox_manager/enums.py:30
|
||||
msgid "Pending"
|
||||
msgstr "En attente"
|
||||
|
||||
#: core/enums.py:25
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:46
|
||||
msgid "Member"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:47 core/models.py:59 mailbox_manager/enums.py:14
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:40
|
||||
#: core/models.py:48 mailbox_manager/enums.py:15
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:71
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:72
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:78
|
||||
msgid "created at"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:79
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:65
|
||||
#: core/models.py:84
|
||||
msgid "updated at"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:66
|
||||
#: core/models.py:85
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:116
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:117
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:122
|
||||
msgid "contact information"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:123
|
||||
msgid "A JSON object containing the contact information"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:137
|
||||
msgid "contact"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:119
|
||||
#: core/models.py:138
|
||||
msgid "contacts"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160 core/models.py:263 core/models.py:483
|
||||
msgid "email address"
|
||||
#: core/models.py:262 core/models.py:331 mailbox_manager/models.py:24
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "language"
|
||||
#: core/models.py:270
|
||||
msgid "registration ID list"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:173
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
#: core/models.py:279
|
||||
msgid "domain list"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
#: core/models.py:289
|
||||
msgid "organization"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182
|
||||
msgid "device"
|
||||
#: core/models.py:290
|
||||
msgid "organizations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
msgid "Whether the user is a device or a real user."
|
||||
#: core/models.py:297
|
||||
msgid "An organization must have at least a registration ID or a domain."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:189
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:192
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:195
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:207
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:208
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:248
|
||||
#: core/models.py:316
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:255
|
||||
#: core/models.py:322
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:257
|
||||
#: core/models.py:324
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:264
|
||||
msgid "name"
|
||||
#: core/models.py:330 core/models.py:737
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:266
|
||||
msgid "main"
|
||||
#: core/models.py:343
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:268
|
||||
msgid "Designates whether the email is the main one."
|
||||
#: core/models.py:344
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:274
|
||||
msgid "identity"
|
||||
#: core/models.py:350
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:275
|
||||
msgid "identities"
|
||||
#: core/models.py:353
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:282
|
||||
msgid "This email address is already declared for this user."
|
||||
#: core/models.py:355
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:356
|
||||
msgid "Team"
|
||||
#: core/models.py:358
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:357
|
||||
msgid "Teams"
|
||||
#: core/models.py:360
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:417
|
||||
msgid "Team/user relation"
|
||||
#: core/models.py:363
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:418
|
||||
msgid "Team/user relations"
|
||||
#: core/models.py:366
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:423
|
||||
msgid "This user is already in this team."
|
||||
#: core/models.py:385
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:500
|
||||
msgid "Team invitation"
|
||||
#: core/models.py:386
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:501
|
||||
msgid "Team invitations"
|
||||
#: core/models.py:525
|
||||
msgid "Organization/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:526
|
||||
msgid "Organization/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:531
|
||||
msgid "This user is already in this organization."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:563
|
||||
msgid "Team"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:564
|
||||
msgid "Teams"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:615
|
||||
msgid "Team/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:616
|
||||
msgid "Team/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:621
|
||||
msgid "This user is already in this team."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:710
|
||||
msgid "url"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:711
|
||||
msgid "secret"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:720
|
||||
msgid "Team webhook"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:721
|
||||
msgid "Team webhooks"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:754
|
||||
msgid "Team invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:755
|
||||
msgid "Team invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:780
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:568 core/models.py:573
|
||||
#: core/models.py:822 core/models.py:828
|
||||
msgid "Invitation to join Desk!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
@@ -246,7 +321,7 @@ msgstr ""
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
"We are delighted to welcome you to our community on Equipes, your new "
|
||||
"We are delighted to welcome you to our community on Régie, your new "
|
||||
"companion to simplify the management of your groups efficiently, "
|
||||
"intuitively, and securely."
|
||||
msgstr ""
|
||||
@@ -260,7 +335,7 @@ msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:236
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "With Equipes, you will be able to:"
|
||||
msgid "With Régie, you will be able to:"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
@@ -294,13 +369,13 @@ msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:252
|
||||
#: core/templates/mail/text/invitation.txt:23
|
||||
msgid "Visit Equipes"
|
||||
msgid "Visit Régie"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:261
|
||||
#: core/templates/mail/text/invitation.txt:25
|
||||
msgid ""
|
||||
"We are confident that Equipes will help you increase efficiency and "
|
||||
"We are confident that Régie will help you increase efficiency and "
|
||||
"productivity while strengthening the bond among members."
|
||||
msgstr ""
|
||||
|
||||
@@ -320,19 +395,141 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:278
|
||||
#: core/templates/mail/html/new_mailbox.html:272
|
||||
#: core/templates/mail/text/invitation.txt:29
|
||||
#: core/templates/mail/text/new_mailbox.txt:15
|
||||
msgid "Sincerely,"
|
||||
msgstr ""
|
||||
msgstr "Cordialement,"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:279
|
||||
#: core/templates/mail/text/invitation.txt:31
|
||||
msgid "The La Suite Numérique Team"
|
||||
msgstr "L'équipe de La Suite Numérique"
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:159
|
||||
#: core/templates/mail/text/new_mailbox.txt:3
|
||||
msgid "La Messagerie"
|
||||
msgstr "La Messagerie"
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:188
|
||||
#: core/templates/mail/text/new_mailbox.txt:5
|
||||
msgid "Welcome to La Messagerie"
|
||||
msgstr "Bienvenue dans La Messagerie"
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:193
|
||||
#: core/templates/mail/text/new_mailbox.txt:6
|
||||
msgid "La Messagerie is the email solution of La Suite."
|
||||
msgstr "La Messagerie est la solution de mail de La Suite."
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:199
|
||||
#: core/templates/mail/text/new_mailbox.txt:7
|
||||
msgid "Your mailbox has been created."
|
||||
msgstr "Votre boîte mail a été créée."
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:204
|
||||
#: core/templates/mail/text/new_mailbox.txt:8
|
||||
msgid "Please find below your login info: "
|
||||
msgstr "Voici vos identifiants de connexion :"
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:228
|
||||
#: core/templates/mail/text/new_mailbox.txt:10
|
||||
msgid "Email address: "
|
||||
msgstr "Adresse email : "
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:233
|
||||
#: core/templates/mail/text/new_mailbox.txt:11
|
||||
msgid "Temporary password (to be modify on first login): "
|
||||
msgstr "Mot de passe temporaire (à modifier à la première connexion) : "
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:261
|
||||
#: core/templates/mail/text/new_mailbox.txt:13
|
||||
msgid "Go to La Messagerie"
|
||||
msgstr "Accéder à La Messagerie"
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:273
|
||||
#: core/templates/mail/text/new_mailbox.txt:17
|
||||
msgid "La Suite Team"
|
||||
msgstr "L'équipe de La Suite"
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: people/settings.py:133
|
||||
#: mailbox_manager/admin.py:12
|
||||
msgid "Synchronise from dimail"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/admin.py:23
|
||||
#, python-brace-format
|
||||
msgid "Synchronisation failed for {domain.name} with message: [{err}]"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/admin.py:29
|
||||
#, python-brace-format
|
||||
msgid "Synchronisation succeed for {domain.name}. "
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/enums.py:13
|
||||
msgid "Viewer"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:31
|
||||
msgid "Enabled"
|
||||
msgstr "Actif"
|
||||
|
||||
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:32
|
||||
msgid "Failed"
|
||||
msgstr "En échec"
|
||||
|
||||
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:33
|
||||
msgid "Disabled"
|
||||
msgstr "Désactivé"
|
||||
|
||||
#: mailbox_manager/models.py:35
|
||||
msgid "Mail domain"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:36
|
||||
msgid "Mail domains"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:102
|
||||
msgid "User/mail domain relation"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:103
|
||||
msgid "User/mail domain relations"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:175
|
||||
msgid "local_part"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:189
|
||||
msgid "secondary email address"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:199
|
||||
msgid "Mailbox"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:200
|
||||
msgid "Mailboxes"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:224
|
||||
msgid "You can't create a mailbox for a disabled domain."
|
||||
msgstr "Vous ne pouvez pas créer de boîte mail pour un domain désactivé."
|
||||
|
||||
#: mailbox_manager/utils/dimail.py:183
|
||||
msgid "Your new mailbox information"
|
||||
msgstr "Informations concernant votre nouvelle boîte mail"
|
||||
|
||||
#: people/settings.py:135
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: people/settings.py:134
|
||||
#: people/settings.py:136
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox manager module."""
|
||||
|
||||
@@ -1,9 +1,43 @@
|
||||
"""Admin classes and registrations for People's mailbox manager app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib import admin, messages
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from requests import exceptions
|
||||
|
||||
from mailbox_manager import models
|
||||
from mailbox_manager.utils.dimail import DimailAPIClient
|
||||
|
||||
|
||||
@admin.action(description=_("Synchronise from dimail"))
|
||||
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
"""Admin action to synchronize existing mailboxes from dimail to our database."""
|
||||
client = DimailAPIClient()
|
||||
|
||||
for domain in queryset:
|
||||
try:
|
||||
imported_mailboxes = client.synchronize_mailboxes_from_dimail(domain)
|
||||
except exceptions.HTTPError as err:
|
||||
messages.error(
|
||||
request,
|
||||
_(f"Synchronisation failed for {domain.name} with message: [{err}]"),
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
_(
|
||||
f"Synchronisation succeed for {domain.name}. "
|
||||
f"Imported mailboxes: {', '.join(imported_mailboxes)}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UserMailDomainAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for mail domain accesses."""
|
||||
|
||||
extra = 0
|
||||
model = models.MailDomainAccess
|
||||
readonly_fields = ("created_at", "updated_at", "domain", "user")
|
||||
|
||||
|
||||
@admin.register(models.MailDomain)
|
||||
@@ -19,6 +53,15 @@ class MailDomainAdmin(admin.ModelAdmin):
|
||||
)
|
||||
search_fields = ("name",)
|
||||
readonly_fields = ["created_at", "slug"]
|
||||
inlines = (UserMailDomainAccessInline,)
|
||||
actions = (sync_mailboxes_from_dimail,)
|
||||
|
||||
|
||||
@admin.register(models.Mailbox)
|
||||
class MailboxAdmin(admin.ModelAdmin):
|
||||
"""Admin for mailbox model."""
|
||||
|
||||
list_display = ("__str__", "first_name", "last_name", "status")
|
||||
|
||||
|
||||
@admin.register(models.MailDomainAccess)
|
||||
@@ -34,8 +77,10 @@ class MailDomainAccessAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Mailbox)
|
||||
class MailboxAdmin(admin.ModelAdmin):
|
||||
"""Admin for mailbox model."""
|
||||
class MailDomainAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for mail domain accesses."""
|
||||
|
||||
list_display = ("__str__", "first_name", "last_name")
|
||||
extra = 0
|
||||
autocomplete_fields = ["user", "domain"]
|
||||
model = models.MailDomainAccess
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox manager API module."""
|
||||
|
||||
@@ -18,6 +18,16 @@ class MailBoxPermission(core_permissions.IsAuthenticated):
|
||||
"""Permission class to manage mailboxes for a mail domain"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check permission based on domain."""
|
||||
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
|
||||
abilities = domain.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
|
||||
class MailDomainAccessRolePermission(core_permissions.IsAuthenticated):
|
||||
"""Permission class to manage mailboxes for a mail domain"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""Client serializers for People's mailbox manager app."""
|
||||
|
||||
from rest_framework import serializers
|
||||
import json
|
||||
|
||||
from mailbox_manager import models
|
||||
from rest_framework import exceptions, serializers
|
||||
|
||||
from core.api.serializers import UserSerializer
|
||||
from core.models import User
|
||||
|
||||
from mailbox_manager import enums, models
|
||||
from mailbox_manager.utils.dimail import DimailAPIClient
|
||||
|
||||
|
||||
class MailboxSerializer(serializers.ModelSerializer):
|
||||
@@ -10,7 +16,45 @@ class MailboxSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Mailbox
|
||||
fields = ["id", "first_name", "last_name", "local_part", "secondary_email"]
|
||||
fields = [
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"local_part",
|
||||
"secondary_email",
|
||||
"status",
|
||||
]
|
||||
# everything is actually read-only as we do not allow update for now
|
||||
read_only_fields = ["id", "status"]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Override create function to fire a request on mailbox creation.
|
||||
"""
|
||||
mailbox_status = enums.MailDomainStatusChoices.PENDING
|
||||
|
||||
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
|
||||
client = DimailAPIClient()
|
||||
# send new mailbox request to dimail
|
||||
response = client.send_mailbox_request(
|
||||
validated_data, self.context["request"].user.sub
|
||||
)
|
||||
|
||||
# fix format to have actual json, and remove uuid
|
||||
mailbox_data = json.loads(
|
||||
response.content.decode("utf-8").replace("'", '"')
|
||||
)
|
||||
del mailbox_data["uuid"]
|
||||
|
||||
mailbox_status = enums.MailDomainStatusChoices.ENABLED
|
||||
|
||||
# send confirmation email
|
||||
client.send_new_mailbox_notification(
|
||||
recipient=validated_data["secondary_email"], mailbox_data=mailbox_data
|
||||
)
|
||||
|
||||
# actually save mailbox on our database
|
||||
return models.Mailbox.objects.create(**validated_data, status=mailbox_status)
|
||||
|
||||
|
||||
class MailDomainSerializer(serializers.ModelSerializer):
|
||||
@@ -25,6 +69,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"status",
|
||||
"abilities",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
@@ -32,6 +77,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"slug",
|
||||
"status",
|
||||
"abilities",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
@@ -44,9 +90,104 @@ class MailDomainSerializer(serializers.ModelSerializer):
|
||||
return domain.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Override create function to fire a request to dimail upon domain creation.
|
||||
"""
|
||||
# send new domain request to dimail
|
||||
client = DimailAPIClient()
|
||||
client.send_domain_creation_request(
|
||||
validated_data["name"], self.context["request"].user.sub
|
||||
)
|
||||
|
||||
# no exception raised ? Then actually save domain on our database
|
||||
return models.MailDomain.objects.create(**validated_data)
|
||||
|
||||
|
||||
class MailDomainAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize mail domain accesses."""
|
||||
"""Serialize mail domain access."""
|
||||
|
||||
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
|
||||
can_set_role_to = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.MailDomainAccess
|
||||
fields = ["id", "user", "role", "can_set_role_to"]
|
||||
read_only_fields = ["id", "can_set_role_to"]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "user" field is readonly but only on update."""
|
||||
validated_data.pop("user", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def get_can_set_role_to(self, access):
|
||||
"""Return roles available to set for the authenticated user"""
|
||||
return access.get_can_set_role_to(self.context.get("request").user)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Check access rights specific to writing (update/create)
|
||||
"""
|
||||
|
||||
request = self.context.get("request")
|
||||
authenticated_user = getattr(request, "user", None)
|
||||
role = attrs.get("role")
|
||||
|
||||
# Update
|
||||
if self.instance:
|
||||
can_set_role_to = self.instance.get_can_set_role_to(authenticated_user)
|
||||
|
||||
if role and role not in can_set_role_to:
|
||||
message = (
|
||||
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
|
||||
if can_set_role_to
|
||||
else "You are not allowed to modify role for this user."
|
||||
)
|
||||
raise exceptions.PermissionDenied(message)
|
||||
# Create
|
||||
else:
|
||||
# A domain slug has to be set to create a new access
|
||||
try:
|
||||
domain_slug = self.context["domain_slug"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a domain slug in kwargs to create a new domain access."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
access = authenticated_user.mail_domain_accesses.get(
|
||||
domain__slug=domain_slug
|
||||
)
|
||||
except models.MailDomainAccess.DoesNotExist as exc:
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage accesses for this domain."
|
||||
) from exc
|
||||
|
||||
# Authenticated user must be owner or admin of current domain to set new roles
|
||||
if access.role not in [
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
]:
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage accesses for this domain."
|
||||
)
|
||||
# only an owner can set an owner role to another user
|
||||
if (
|
||||
role == enums.MailDomainRoleChoices.OWNER
|
||||
and access.role != enums.MailDomainRoleChoices.OWNER
|
||||
):
|
||||
raise exceptions.PermissionDenied(
|
||||
"Only owners of a domain can assign other users as owners."
|
||||
)
|
||||
attrs["user"] = User.objects.get(pk=self.initial_data["user"])
|
||||
attrs["domain"] = models.MailDomain.objects.get(
|
||||
slug=self.context["domain_slug"]
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer):
|
||||
"""Serialize mail domain access for list and retrieve actions."""
|
||||
|
||||
class Meta:
|
||||
model = models.MailDomainAccess
|
||||
@@ -54,7 +195,11 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"user",
|
||||
"role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"can_set_role_to",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"user",
|
||||
"role",
|
||||
"can_set_role_to",
|
||||
]
|
||||
read_only_fields = ["id"]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""API endpoints"""
|
||||
|
||||
from rest_framework import filters, mixins, viewsets
|
||||
from rest_framework import permissions as drf_permissions
|
||||
from django.db.models import Subquery
|
||||
|
||||
from rest_framework import exceptions, filters, mixins, viewsets
|
||||
|
||||
from core import models as core_models
|
||||
|
||||
from mailbox_manager import models
|
||||
from mailbox_manager import enums, models
|
||||
from mailbox_manager.api import permissions, serializers
|
||||
|
||||
|
||||
@@ -39,6 +40,7 @@ class MailDomainViewSet(
|
||||
queryset = models.MailDomain.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Restrict results to the current user's team."""
|
||||
return self.queryset.filter(accesses__user=self.request.user)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -54,19 +56,126 @@ class MailDomainViewSet(
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
class MailDomainAccessViewSet(
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
"""
|
||||
MailDomainAccess viewset.
|
||||
API ViewSet for all interactions with mail domain accesses.
|
||||
|
||||
GET /api/v1.0/mail-domains/<domain_slug>/accesses/:<domain_access_id>
|
||||
Return list of all domain accesses related to the logged-in user and one
|
||||
domain access if an id is provided.
|
||||
|
||||
POST /api/v1.0/mail-domains/<domain_slug>/accesses/ with expected data:
|
||||
- user: str
|
||||
- role: str [owner|admin|viewer]
|
||||
Return newly created mail domain access
|
||||
|
||||
PUT /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
|
||||
- role: str [owner|admin|viewer]
|
||||
Return updated domain access
|
||||
|
||||
PATCH /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
|
||||
- role: str [owner|admin|viewer]
|
||||
Return partially updated domain access
|
||||
|
||||
DELETE /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/
|
||||
Delete targeted domain access
|
||||
"""
|
||||
|
||||
permission_classes = [drf_permissions.IsAuthenticated]
|
||||
permission_classes = [permissions.MailDomainAccessRolePermission]
|
||||
serializer_class = serializers.MailDomainAccessSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at", "user", "domain", "role"]
|
||||
ordering_fields = ["role", "user__email", "user__name"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.MailDomainAccess.objects.all()
|
||||
queryset = (
|
||||
models.MailDomainAccess.objects.all()
|
||||
.select_related("user")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
list_serializer_class = serializers.MailDomainAccessReadOnlySerializer
|
||||
detail_serializer_class = serializers.MailDomainAccessSerializer
|
||||
|
||||
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
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["domain_slug"] = self.kwargs["domain_slug"]
|
||||
context["authenticated_user"] = self.request.user
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"])
|
||||
|
||||
if self.action in {"list", "retrieve"}:
|
||||
# Determine which role the logged-in user has in the domain
|
||||
user_role_query = models.MailDomainAccess.objects.filter(
|
||||
user=self.request.user, domain__slug=self.kwargs["domain_slug"]
|
||||
).values("role")[:1]
|
||||
|
||||
queryset = (
|
||||
# The logged-in user should be part of a domain to see its accesses
|
||||
queryset.filter(
|
||||
domain__accesses__user=self.request.user,
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role and
|
||||
# the user role on each domain access
|
||||
.annotate(
|
||||
user_role=Subquery(user_role_query),
|
||||
)
|
||||
.select_related("user")
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Check that we don't change the role if it leads to losing the last owner."""
|
||||
instance = serializer.instance
|
||||
|
||||
# Check if the role is being updated and the new role is not "owner"
|
||||
if (
|
||||
"role" in self.request.data
|
||||
and self.request.data["role"] != enums.MailDomainRoleChoices.OWNER
|
||||
):
|
||||
domain = instance.domain
|
||||
# Check if the access being updated is the last owner access for the domain
|
||||
if (
|
||||
instance.role == enums.MailDomainRoleChoices.OWNER
|
||||
and domain.accesses.filter(
|
||||
role=enums.MailDomainRoleChoices.OWNER
|
||||
).count()
|
||||
== 1
|
||||
):
|
||||
message = "Cannot change the role to a non-owner role for the last owner access."
|
||||
raise exceptions.PermissionDenied({"role": message})
|
||||
serializer.save()
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Forbid deleting the last owner access"""
|
||||
instance = self.get_object()
|
||||
domain = instance.domain
|
||||
|
||||
# Check if the access being deleted is the last owner access for the domain
|
||||
if (
|
||||
instance.role == enums.MailDomainRoleChoices.OWNER
|
||||
and domain.accesses.filter(role=enums.MailDomainRoleChoices.OWNER).count()
|
||||
== 1
|
||||
):
|
||||
message = "Cannot delete the last owner access for the domain."
|
||||
raise exceptions.PermissionDenied({"detail": message})
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class MailBoxViewSet(
|
||||
@@ -74,7 +183,18 @@ class MailBoxViewSet(
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""MailBox ViewSet"""
|
||||
"""MailBox ViewSet
|
||||
|
||||
GET /api/<version>/mail-domains/<domain-slug>/mailboxes/
|
||||
Return a list of mailboxes on the domain
|
||||
|
||||
POST /api/<version>/mail-domains/<domain-slug>/mailboxes/ with expected data:
|
||||
- first_name: str
|
||||
- last_name: str
|
||||
- local_part: str
|
||||
- secondary_email: str
|
||||
Sends request to email provisioning API and returns newly created mailbox
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.MailBoxPermission]
|
||||
serializer_class = serializers.MailboxSerializer
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-ancestors
|
||||
"""
|
||||
Application enums declaration
|
||||
"""
|
||||
@@ -21,3 +22,12 @@ class MailDomainStatusChoices(models.TextChoices):
|
||||
ENABLED = "enabled", _("Enabled")
|
||||
FAILED = "failed", _("Failed")
|
||||
DISABLED = "disabled", _("Disabled")
|
||||
|
||||
|
||||
class MailboxStatusChoices(models.TextChoices):
|
||||
"""Lists the possible statuses in which a mailbox can be."""
|
||||
|
||||
PENDING = "pending", _("Pending")
|
||||
ENABLED = "enabled", _("Enabled")
|
||||
FAILED = "failed", _("Failed")
|
||||
DISABLED = "disabled", _("Disabled")
|
||||
|
||||
@@ -75,3 +75,9 @@ class MailboxFactory(factory.django.DjangoModelFactory):
|
||||
)
|
||||
domain = factory.SubFactory(MailDomainEnabledFactory)
|
||||
secondary_email = factory.Faker("email")
|
||||
|
||||
|
||||
class MailboxEnabledFactory(MailboxFactory):
|
||||
"""A factory to create mailbox enabled."""
|
||||
|
||||
status = enums.MailboxStatusChoices.ENABLED
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"Mailbox manager management module."
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox manager management commands module."""
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Management command creating a dimail-api container, for test purposes."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
import requests
|
||||
from rest_framework import status
|
||||
|
||||
from mailbox_manager.enums import MailDomainStatusChoices
|
||||
from mailbox_manager.models import MailDomain
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
DIMAIL_URL = "http://dimail:8000"
|
||||
admin = {"username": "admin", "password": "admin"}
|
||||
regie = {"username": "la_regie", "password": "password"}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command populate local dimail database, to ease dev
|
||||
"""
|
||||
|
||||
help = "Populate local dimail database, for dev purposes."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Handling of the management command."""
|
||||
if not settings.DEBUG:
|
||||
raise CommandError(
|
||||
("This command is meant to run in local dev environment.")
|
||||
)
|
||||
|
||||
# Create a first superuser for dimail-api container. User creation is usually
|
||||
# protected behind admin rights but dimail allows to create a first user
|
||||
# when database is empty
|
||||
self.create_user(
|
||||
auth=(None, None),
|
||||
name=admin["username"],
|
||||
password=admin["password"],
|
||||
perms=[],
|
||||
)
|
||||
|
||||
# Create Regie user, auth for all remaining requests
|
||||
# and your own dev
|
||||
self.create_user(
|
||||
auth=(admin["username"], admin["password"]),
|
||||
name=regie["username"],
|
||||
password=regie["password"],
|
||||
perms=["new_domain", "create_users", "manage_users"],
|
||||
)
|
||||
|
||||
# we create a domain and add John Doe to it
|
||||
domain_name = "test.domain.com"
|
||||
if not MailDomain.objects.filter(name=domain_name).exists():
|
||||
MailDomain.objects.create(
|
||||
name=domain_name, status=MailDomainStatusChoices.ENABLED
|
||||
)
|
||||
self.create_domain(domain_name)
|
||||
|
||||
# we create a dimail user for keycloak+regie user John Doe
|
||||
# This way, la Régie will be able to make request in the name of
|
||||
# this user
|
||||
people_base_user = User.objects.filter(name="John Doe")
|
||||
if people_base_user.exists():
|
||||
self.create_user(name=people_base_user.sub, password="whatever") # noqa S106
|
||||
self.create_allows(people_base_user.sub, domain_name)
|
||||
|
||||
self.stdout.write("DONE", ending="\n")
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
name,
|
||||
password,
|
||||
perms=None,
|
||||
auth=(regie["username"], regie["password"]),
|
||||
):
|
||||
"""
|
||||
Send a request to create a new user.
|
||||
"""
|
||||
|
||||
response = requests.post(
|
||||
url=f"{DIMAIL_URL}/users/",
|
||||
json={
|
||||
"name": name,
|
||||
"password": password,
|
||||
"is_admin": name == admin["username"],
|
||||
"perms": perms or [],
|
||||
},
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
self.stdout.write(self.style.SUCCESS(f"Creating user {name}......... OK"))
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Creating user {name} ......... failed: {response.json()['detail']}"
|
||||
)
|
||||
)
|
||||
|
||||
def create_domain(self, name, auth=(regie["username"], regie["password"])):
|
||||
"""
|
||||
Send a request to create a new domain.
|
||||
"""
|
||||
response = requests.post(
|
||||
url=f"{DIMAIL_URL}/domains/",
|
||||
json={
|
||||
"name": name,
|
||||
"context_name": "context",
|
||||
"features": ["webmail", "mailbox", "alias"],
|
||||
},
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Creating domain '{name}' ........ OK")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Creating domain '{name}' ........ failed: {response.json()['detail']}"
|
||||
)
|
||||
)
|
||||
|
||||
def create_allows(self, user, domain, auth=(regie["username"], regie["password"])):
|
||||
"""
|
||||
Send a request to create a new allows between user and domain.
|
||||
"""
|
||||
response = requests.post(
|
||||
url=f"{DIMAIL_URL}/allows/",
|
||||
json={
|
||||
"domain": domain,
|
||||
"user": user,
|
||||
},
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Creating permissions for {user} on {domain} ........ OK"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Creating permissions for {user} on {domain}\
|
||||
........ failed: {response.json()['detail']}"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.6 on 2024-07-01 16:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0010_alter_mailbox_first_name_alter_mailbox_last_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='maildomain',
|
||||
name='secret',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='secret'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1 on 2024-08-30 10:09
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0011_maildomain_secret'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='mailbox',
|
||||
name='local_part',
|
||||
field=models.CharField(max_length=150, validators=[django.core.validators.RegexValidator(regex='^[a-zA-Z0-9_.-]+$')], verbose_name='local_part'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1 on 2024-08-30 12:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0012_alter_mailbox_local_part'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='maildomain',
|
||||
name='secret',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-18 14:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0013_remove_maildomain_secret'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mailbox',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('pending', 'Pending'), ('enabled', 'Enabled'), ('failed', 'Failed'), ('disabled', 'Disabled')], default='pending', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from mailbox_manager import enums
|
||||
|
||||
def change_mailboxes_status_to_enabled(apps, schema_editor):
|
||||
Mailbox = apps.get_model('mailbox_manager', 'Mailbox')
|
||||
Mailbox.objects.filter(status=enums.MailboxStatusChoices.PENDING).update(status=enums.MailboxStatusChoices.ENABLED)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0014_mailbox_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(change_mailboxes_status_to_enabled, reverse_code=migrations.RunPython.noop),
|
||||
|
||||
]
|
||||
@@ -3,15 +3,18 @@ Declare and configure the models for the People additional application : mailbox
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core import exceptions, validators
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import BaseModel
|
||||
|
||||
from mailbox_manager.enums import MailDomainRoleChoices, MailDomainStatusChoices
|
||||
from mailbox_manager.enums import (
|
||||
MailboxStatusChoices,
|
||||
MailDomainRoleChoices,
|
||||
MailDomainStatusChoices,
|
||||
)
|
||||
|
||||
|
||||
class MailDomain(BaseModel):
|
||||
@@ -48,7 +51,6 @@ class MailDomain(BaseModel):
|
||||
"""
|
||||
Compute and return abilities for a given user on the domain.
|
||||
"""
|
||||
is_owner_or_admin = False
|
||||
role = None
|
||||
|
||||
if user.is_authenticated:
|
||||
@@ -104,6 +106,65 @@ class MailDomainAccess(BaseModel):
|
||||
def __str__(self):
|
||||
return f"Access of user {self.user} on domain {self.domain}."
|
||||
|
||||
def get_can_set_role_to(self, user):
|
||||
"""Return roles available to set"""
|
||||
if not user.is_authenticated:
|
||||
return []
|
||||
roles = list(MailDomainRoleChoices)
|
||||
authenticated_user_role = None
|
||||
|
||||
# get role of authenticated user
|
||||
if hasattr(self, "user_role"):
|
||||
authenticated_user_role = self.user_role
|
||||
else:
|
||||
try:
|
||||
authenticated_user_role = user.mail_domain_accesses.get(
|
||||
domain=self.domain
|
||||
).role
|
||||
except (MailDomainAccess.DoesNotExist, IndexError):
|
||||
return []
|
||||
|
||||
# only an owner can set an owner role
|
||||
if authenticated_user_role != MailDomainRoleChoices.OWNER:
|
||||
roles.remove(MailDomainRoleChoices.OWNER)
|
||||
|
||||
# if the user authenticated is a viewer, they can't modify role
|
||||
# and only an owner can change role of an owner
|
||||
if authenticated_user_role == MailDomainRoleChoices.VIEWER or (
|
||||
authenticated_user_role != MailDomainRoleChoices.OWNER
|
||||
and self.role == MailDomainRoleChoices.OWNER
|
||||
):
|
||||
return []
|
||||
# we only want to return other roles available to change,
|
||||
# so we remove the current role of current access.
|
||||
roles.remove(self.role)
|
||||
return sorted(roles)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the domain access.
|
||||
"""
|
||||
role = None
|
||||
|
||||
if user.is_authenticated:
|
||||
try:
|
||||
role = user.mail_domain_accesses.filter(domain=self.domain).get().role
|
||||
except (MailDomainAccess.DoesNotExist, IndexError):
|
||||
role = None
|
||||
|
||||
is_owner_or_admin = role in [
|
||||
MailDomainRoleChoices.OWNER,
|
||||
MailDomainRoleChoices.ADMIN,
|
||||
]
|
||||
|
||||
return {
|
||||
"get": bool(role),
|
||||
"patch": is_owner_or_admin,
|
||||
"put": is_owner_or_admin,
|
||||
"post": is_owner_or_admin,
|
||||
"delete": is_owner_or_admin,
|
||||
}
|
||||
|
||||
|
||||
class Mailbox(BaseModel):
|
||||
"""Mailboxes for users from mail domain."""
|
||||
@@ -115,7 +176,7 @@ class Mailbox(BaseModel):
|
||||
max_length=150,
|
||||
null=False,
|
||||
blank=False,
|
||||
validators=[validators.RegexValidator(regex="^[a-zA-Z0-9_.+-]+$")],
|
||||
validators=[validators.RegexValidator(regex="^[a-zA-Z0-9_.-]+$")],
|
||||
)
|
||||
domain = models.ForeignKey(
|
||||
MailDomain,
|
||||
@@ -127,6 +188,11 @@ class Mailbox(BaseModel):
|
||||
secondary_email = models.EmailField(
|
||||
_("secondary email address"), null=False, blank=False
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=MailboxStatusChoices.choices,
|
||||
default=MailboxStatusChoices.PENDING,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_mail_box"
|
||||
@@ -137,8 +203,29 @@ class Mailbox(BaseModel):
|
||||
def __str__(self):
|
||||
return f"{self.local_part!s}@{self.domain.name:s}"
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Mail-provisioning API credentials must be set for dimail to allow auth.
|
||||
"""
|
||||
# Won't be able to query user token if MAIL_PROVISIONING_API_CREDENTIALS are not set
|
||||
if not settings.MAIL_PROVISIONING_API_CREDENTIALS:
|
||||
raise exceptions.ValidationError(
|
||||
"Please configure MAIL_PROVISIONING_API_CREDENTIALS before creating any mailbox."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Modification is forbidden for now.
|
||||
"""
|
||||
self.full_clean()
|
||||
if self.domain.status != MailDomainStatusChoices.ENABLED:
|
||||
raise ValidationError("You can create mailbox only for a domain enabled")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self.domain.status == MailDomainStatusChoices.DISABLED:
|
||||
raise exceptions.ValidationError(
|
||||
_("You can't create a mailbox for a disabled domain.")
|
||||
)
|
||||
|
||||
if self._state.adding:
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
# Update is not implemented for now
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<h1>403 Forbidden</h1>
|
||||
{{ exception }}
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox manager tests."""
|
||||
|
||||
+129
-13
@@ -2,7 +2,13 @@
|
||||
Tests for MailDomains API endpoint in People's app mailbox_manager. Focus on "create" action.
|
||||
"""
|
||||
|
||||
import re
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from requests.exceptions import HTTPError
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -52,23 +58,133 @@ def test_api_mail_domains__create_authenticated():
|
||||
Authenticated users should be able to create mail domains
|
||||
and should automatically be added as owner of the newly created domain.
|
||||
"""
|
||||
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{
|
||||
"name": "mydomain.com",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
domain_name = "test.domain.fr"
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/domains/"),
|
||||
body=str(
|
||||
{
|
||||
"name": domain_name,
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{"name": domain_name, "context": "null", "features": ["webmail"]},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
domain = models.MailDomain.objects.get()
|
||||
|
||||
# response is as expected
|
||||
assert response.json() == {
|
||||
"id": str(domain.id),
|
||||
"name": domain.name,
|
||||
"slug": domain.slug,
|
||||
"status": enums.MailDomainStatusChoices.PENDING,
|
||||
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"abilities": domain.get_abilities(user),
|
||||
}
|
||||
|
||||
# a new domain with status "pending" is created and authenticated user is the owner
|
||||
assert domain.status == enums.MailDomainStatusChoices.PENDING
|
||||
assert domain.name == domain_name
|
||||
assert domain.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
## SYNC TO DIMAIL
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_api_mail_domains__create_dimail_domain(mock_info):
|
||||
"""
|
||||
Creating a domain should trigger a call to create a domain on dimail too.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
domain_name = "test.fr"
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsp = rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/domains/"),
|
||||
body=str(
|
||||
{
|
||||
"name": domain_name,
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{
|
||||
"name": domain_name,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
# a new domain pending is created and the authenticated user is the owner
|
||||
domain = models.MailDomain.objects.get()
|
||||
assert domain.status == enums.MailDomainStatusChoices.PENDING
|
||||
assert domain.name == "mydomain.com"
|
||||
assert domain.accesses.filter(role="owner", user=user).exists()
|
||||
assert rsp.call_count == 1 # endpoint was called
|
||||
|
||||
# Logger
|
||||
assert (
|
||||
mock_info.call_count == 2
|
||||
) # should be 1. A new empty info has been added. To be investigated
|
||||
assert mock_info.call_args_list[0][0] == (
|
||||
"Domain %s successfully created on dimail by user %s",
|
||||
domain_name,
|
||||
user.sub,
|
||||
)
|
||||
|
||||
|
||||
def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
|
||||
"""No domain should be created when dimail returns a 409 conflict."""
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
domain_name = "test.fr"
|
||||
dimail_error = {
|
||||
"status_code": status.HTTP_409_CONFLICT,
|
||||
"detail": "Domain already exists",
|
||||
}
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsp = rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/domains/"),
|
||||
body=str({"detail": dimail_error["detail"]}),
|
||||
status=dimail_error["status_code"],
|
||||
content_type="application/json",
|
||||
)
|
||||
with pytest.raises(HTTPError):
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{
|
||||
"name": domain_name,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert rsp.call_count == 1 # endpoint was called
|
||||
assert response.status_code == dimail_error["status_code"]
|
||||
assert response.json() == {"detail": dimail_error["detail"]}
|
||||
|
||||
# check logs
|
||||
record = caplog.records[0]
|
||||
assert record.levelname == "ERROR"
|
||||
assert (
|
||||
record.message
|
||||
== "[DIMAIL] unexpected error : 409 {'detail': 'Domain already exists'}"
|
||||
)
|
||||
|
||||
@@ -81,6 +81,7 @@ def test_api_mail_domains__retrieve_authenticated_related():
|
||||
"id": str(domain.id),
|
||||
"name": domain.name,
|
||||
"slug": domain.slug,
|
||||
"status": domain.status,
|
||||
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"abilities": domain.get_abilities(user),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user