Compare commits
398 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7522569ce0 | |||
| 351c696ef8 | |||
| 579dbdee10 | |||
| b4a877381a | |||
| 92753082c7 | |||
| 4df4172151 | |||
| e7af1fd591 | |||
| 8a2b0d0a76 | |||
| d48a3ff677 | |||
| 4fb5d87df9 | |||
| 6274764f90 | |||
| 7f0e231474 | |||
| 2c15609c1e | |||
| cd94dc5091 | |||
| 9d9216cf39 | |||
| 7dd9eae5d9 | |||
| 914319c366 | |||
| c34ad00fae | |||
| 289879962b | |||
| cd88799943 | |||
| 4011c8e8ed | |||
| b98281fa72 | |||
| 04bd154bad | |||
| 227ecd0700 | |||
| 6d618b4aff | |||
| 3a2b2b8a53 | |||
| f969b118cb | |||
| c48957612b | |||
| e09e1d1b80 | |||
| 615d5894c9 | |||
| 914554e45c | |||
| 3f990e93b7 | |||
| 9de20a496e | |||
| 870ef424f5 | |||
| 0e92c1cafa | |||
| defc18ea40 | |||
| 4ccea4655b | |||
| 45fd10fd2d | |||
| 201864db3a | |||
| 182f9c1d17 | |||
| cff3d5c123 | |||
| 32a576bbe9 | |||
| 010d3674de | |||
| 80976e3761 | |||
| b848f9eca6 | |||
| cd7135da00 | |||
| 0a795f6e6f | |||
| 6c8329405d | |||
| ea3a45ea87 | |||
| 86451df8b4 | |||
| 76fc789eb6 | |||
| 632a78c339 | |||
| 20cc173e93 | |||
| d495ef3e19 | |||
| 4def80214c | |||
| 2428229dbb | |||
| 9b9aa2aa37 | |||
| a28bb5e2a2 | |||
| 6962953625 | |||
| b0b718e657 | |||
| 0abfd49fee | |||
| 5d2e63fc18 | |||
| 94a443525e | |||
| 42d7d00772 | |||
| 5ec65b1202 | |||
| 9ba30843c5 | |||
| 4de589a7e8 | |||
| 151f030582 | |||
| a58152b9a9 | |||
| 8fd55a61c5 | |||
| 2435a59078 | |||
| fd2c90f50d | |||
| 469014ac41 | |||
| e60bae4321 | |||
| 2e7f224e30 | |||
| 8865e250a0 | |||
| fa114b1064 | |||
| 38369a8312 | |||
| d3940f6d09 | |||
| 6061a65e44 | |||
| 4a763396e2 | |||
| fcc63f9b82 | |||
| 0f4db82b02 | |||
| bcdc57905b | |||
| 30832a20e1 | |||
| a021e9eec6 | |||
| fa80edfaa8 | |||
| 38a5f158b5 | |||
| 6e14c2e61f | |||
| dd9a905dc0 | |||
| 2f380b49d0 | |||
| 9953dd2111 | |||
| 5d84e226b7 | |||
| 6fde76fb46 | |||
| 7154a491f4 | |||
| 019ce99a86 | |||
| 579aac264e | |||
| c54f010989 | |||
| 841a6c27ba | |||
| b060bbea51 | |||
| ac24dd91a7 | |||
| 867c89a2e6 | |||
| 54120eb179 | |||
| cf155dc033 | |||
| e623c32c9a | |||
| a1892d2693 | |||
| 632a96ccf4 | |||
| d7c654e1aa | |||
| 99b6181944 | |||
| 514414de16 | |||
| f1956f4e86 | |||
| f13e2c18b9 | |||
| 4ba1901355 | |||
| e821163269 | |||
| fc9499e211 | |||
| 9dde2fe7e2 | |||
| 5dad700c1c | |||
| f759d318b9 | |||
| 60ab61d125 | |||
| 625f122ad5 | |||
| 6d5d0f7ebb | |||
| b7908801d4 | |||
| 0785c3a5bc | |||
| 51eb3474e4 | |||
| 10be01f6af | |||
| 8e20f86d2c | |||
| 03c79b0412 | |||
| a33822b38d | |||
| edbd1f0061 | |||
| 5692c50f21 | |||
| 6fe4818743 | |||
| 02c6048d2c | |||
| a6f409f6ed | |||
| ccb06b3abf | |||
| 3469764697 | |||
| 892a5f10d6 | |||
| 0357caa75a | |||
| 9785ce295d | |||
| a26e10909d | |||
| d6f1cae9e9 | |||
| 478a3ffbd1 | |||
| 8e6b6318c9 | |||
| b524369add | |||
| a991737a59 | |||
| a041296f8a | |||
| 512d9fe82c | |||
| b205ad1d16 | |||
| 0227231370 | |||
| a57070bfb8 | |||
| 17a1c39dbf | |||
| dfecb83c0a | |||
| 8414a7af4d | |||
| 33b364d386 | |||
| b7f61e73c2 | |||
| a8e3d8d20e | |||
| 43c18cb4e6 | |||
| bbe8f32b96 | |||
| 1e025f034f | |||
| 2f7449f727 | |||
| 863c85e3f0 | |||
| 28a972e19e | |||
| 90a3e26c7f | |||
| 59f3499799 | |||
| 6123e11dd4 | |||
| 6be1b63277 | |||
| ac853299d3 | |||
| 4d3097e322 | |||
| a10f65a51f | |||
| 33e05f7a2d | |||
| 1e45f1ffd1 | |||
| 9c894bdbe9 | |||
| 20a8edd3aa | |||
| 0b0b77cead | |||
| 21bf431940 | |||
| 8f30264445 | |||
| bde91d55da | |||
| a328e16e53 | |||
| edde9c8d15 | |||
| a18f06ed27 | |||
| 72abe04c72 | |||
| 79e92214ab | |||
| e5f1151f58 | |||
| ca886c19b0 | |||
| b602478406 | |||
| 8b0f942f9b | |||
| bd6fe584c6 | |||
| 821db276bc | |||
| 639490d41e | |||
| 748e24cab6 | |||
| 55c0815c31 | |||
| 989239082e | |||
| 988a091e53 | |||
| 20a19d36b5 | |||
| 7d695ab81c | |||
| a42e7a10db | |||
| ad4065e682 | |||
| ababcde0d6 | |||
| faf8dcc7e5 | |||
| 2bbed8de0c | |||
| a736a9143f | |||
| c4ea62dc1f | |||
| 1d1f5cfbb6 | |||
| 3934a0bc28 | |||
| fd3ac00ea7 | |||
| 2d46b7d504 | |||
| 54e5be81e2 | |||
| 31944e8083 | |||
| d1421dbe8c | |||
| c8800259ae | |||
| bfc2462103 | |||
| 230ed75d8d | |||
| 5ac3454c5a | |||
| bbac34b62a | |||
| 38508c272e | |||
| 10a81f14e6 | |||
| a08689a64d | |||
| 30229e11f9 | |||
| d11d1f3bd0 | |||
| 6a22169dae | |||
| 6e7a6e9d51 | |||
| 95fb476041 | |||
| 6b4ea1a2e7 | |||
| be55d6ea09 | |||
| 9c9216bb51 | |||
| 017f52a0dc | |||
| d635c484ae | |||
| 0e48bc0f90 | |||
| f243a2423f | |||
| 763eb254a8 | |||
| 9b613e63a9 | |||
| 6b7f00651f | |||
| fe249c5b6f | |||
| 579657afa4 | |||
| ce21a7552b | |||
| 4a63a26135 | |||
| 8e4472befc | |||
| 315a6ab931 | |||
| 2894b9a999 | |||
| aea15292ee | |||
| de46a50e8d | |||
| e6ed3c3be2 | |||
| f04c8bc6aa | |||
| ae05b430db | |||
| c98d173c0a | |||
| dddc281778 | |||
| bb2279b6d5 | |||
| 5420b20f43 | |||
| fbb2accefb | |||
| 75008d3e9a | |||
| c4c3e9de96 | |||
| 00816e097c | |||
| 976c955126 | |||
| 2967f1bff2 | |||
| a15d548ac4 | |||
| 7c6b3307fa | |||
| 5ded297df6 | |||
| b69ce001c8 | |||
| c0cd136618 | |||
| ee5a785d43 | |||
| 3d7dfa019c | |||
| cc9303bc9c | |||
| 34341e6f74 | |||
| 01abc66e59 | |||
| 55d7e846d8 | |||
| 232ea97d87 | |||
| a7afa68afa | |||
| 932ffb3bcb | |||
| d0254ce963 | |||
| cd1dcf11d5 | |||
| 59468aaa12 | |||
| dd8bd2a89b | |||
| cc86a3bd61 | |||
| 7f31a2b820 | |||
| aaca8819b3 | |||
| 77cc64a6c7 | |||
| aaad48480a | |||
| 00dafd4b15 | |||
| 864702d0ee | |||
| 29904ef7b6 | |||
| ba30b1d3ee | |||
| 9503b073b6 | |||
| e4aed82ff2 | |||
| 25898bbb64 | |||
| b5d8e92d1e | |||
| 237d64b4c5 | |||
| 0d157d3f2b | |||
| d291e55a9e | |||
| e7aebfe59e | |||
| bd6cd59df6 | |||
| 874ce18134 | |||
| 4fe74733a5 | |||
| 4b47f80cab | |||
| ba631fafb9 | |||
| ce15e8a3ed | |||
| 55dc342a8b | |||
| 05c8f636dd | |||
| 675ba4b557 | |||
| f5c7abcd20 | |||
| 8c54e701c1 | |||
| ff2cbe3aed | |||
| 7f3f25c61d | |||
| bd36983e3c | |||
| e8cc6a6f23 | |||
| 9adb2d4cf4 | |||
| bb5058c478 | |||
| 78818ba541 | |||
| f1a2b7c603 | |||
| 5634a7f390 | |||
| 9c05167d80 | |||
| 21371dbd1b | |||
| b40aefc505 | |||
| 591b3eedff | |||
| 09cb7ff6f1 | |||
| 87966fa062 | |||
| e4dd3395bb | |||
| 93681b0030 | |||
| e04a994d37 | |||
| ba46d7de54 | |||
| b79b4b1853 | |||
| e3f8633931 | |||
| b84e8b89f7 | |||
| 32889b9e8c | |||
| 75647fe289 | |||
| 03bfef6061 | |||
| 85c789bb1a | |||
| 049d8695be | |||
| 49c238155c | |||
| b79725acbe | |||
| 439ddb9d4a | |||
| a7a923e790 | |||
| 5ed63fc091 | |||
| f55cb3a813 | |||
| 2c82f38c59 | |||
| 8963f0bb3d | |||
| 733a1d8861 | |||
| 7916f7d7d0 | |||
| 45dbdd6c4c | |||
| 14deca13f4 | |||
| 72340db74c | |||
| d812197381 | |||
| c00c59b301 | |||
| 402e73582c | |||
| b637774179 | |||
| 87e7d3e0b1 | |||
| eba9fb2d09 | |||
| e03ecb2d77 | |||
| 7e03d33be0 | |||
| 779c7d1e0e | |||
| b1e1de0269 | |||
| 63dee08be5 | |||
| 582e3f5a05 | |||
| df59bfd1ee | |||
| c26786a107 | |||
| ca179b8811 | |||
| b413d8a915 | |||
| a07be7ecd7 | |||
| ab54d5af8f | |||
| 958f48f9e8 | |||
| 94b0c27775 | |||
| 953eefcb66 | |||
| 4f173c65d5 | |||
| 99d3de6833 | |||
| e0000ca89c | |||
| 32e6602dda | |||
| cda4373544 | |||
| 8d7614c512 | |||
| 955a3dd226 | |||
| d69861c148 | |||
| 66300aca66 | |||
| 2598f3649a | |||
| d3589dfca1 | |||
| dfb5394310 | |||
| bea23cc6e9 | |||
| 7c36f1e775 | |||
| 8dcd6d5bb8 | |||
| 5c87bd74cd | |||
| a400c12037 | |||
| 4d1aafe0d9 | |||
| 0e5cbb1fc8 | |||
| 56df81ef84 | |||
| 5c10ed32d5 | |||
| ed24beb0f6 | |||
| 928fed9ac7 | |||
| 2ec292bb91 | |||
| 79330015e0 | |||
| d05bc60f5e | |||
| c7861c6ad4 | |||
| 952bea369e | |||
| 980c6ee1a4 | |||
| 51c4a29751 | |||
| 01d72466a6 | |||
| 19c36eafde | |||
| 93d4abee58 | |||
| bb3403f10f | |||
| 0da30eb2e1 | |||
| f70604df72 | |||
| e75c8d49b3 | |||
| 4823c8d9dc |
@@ -4,32 +4,16 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'preprod'
|
||||
- 'production'
|
||||
|
||||
|
||||
jobs:
|
||||
notify-argocd:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -15,42 +16,59 @@ env:
|
||||
DOCKER_USER: 1001:127
|
||||
|
||||
jobs:
|
||||
trivy-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
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:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/people-backend
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.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
|
||||
@@ -62,39 +80,29 @@ jobs:
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/people-frontend
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
- name: create-version-json
|
||||
id: create-version-json
|
||||
uses: jsdaniell/create-json@v1.2.3
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
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
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.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
|
||||
@@ -110,29 +118,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
|
||||
|
||||
@@ -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,15 +180,15 @@ jobs:
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
|
||||
- name: Build and Start Docker Servers
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
run: |
|
||||
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
make run
|
||||
|
||||
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
@@ -192,19 +197,43 @@ jobs:
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Setup Dimail DB
|
||||
run: |
|
||||
make dimail-setup-db
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test
|
||||
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
|
||||
- name: Save logs
|
||||
if: always()
|
||||
run: docker compose logs > src/frontend/apps/e2e/report/logs.txt
|
||||
|
||||
- name: Save Dimail logs
|
||||
if: always()
|
||||
run: docker compose logs dimail > src/frontend/apps/e2e/report/dimail-logs.txt
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
name: playwright-report-${{ matrix.shardIndex }}
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
|
||||
tests-e2e-feedback:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-e2e]
|
||||
if: always()
|
||||
steps:
|
||||
- name: All tests ok
|
||||
if: ${{ !(contains(needs.*.result, 'failure')) }}
|
||||
run: exit 0
|
||||
- name: Some tests failed
|
||||
if: ${{ contains(needs.*.result, 'failure') }}
|
||||
run: exit 1
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -226,8 +255,8 @@ jobs:
|
||||
- name: Persist mails' templates
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -306,79 +335,3 @@ jobs:
|
||||
run: python manage.py compilemessages
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
|
||||
i18n-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
- name: Install gettext (required to make messages)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install development dependencies
|
||||
working-directory: src/backend
|
||||
run: pip install --user .[dev]
|
||||
|
||||
- name: Generate the translation base file
|
||||
run: ~/.local/bin/django-admin makemessages --keep-pot --all
|
||||
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: src/frontend/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Download sources from Crowdin to stay synchronized
|
||||
run: |
|
||||
docker run \
|
||||
--rm \
|
||||
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
|
||||
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
|
||||
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
|
||||
-v "${{ github.workspace }}:/app" \
|
||||
crowdin/cli:3.16.0 \
|
||||
crowdin download sources -c /app/crowdin/config.yml
|
||||
|
||||
- name: Extract the frontend translation
|
||||
run: make frontend-i18n-extract
|
||||
|
||||
- name: Upload files to Crowdin
|
||||
run: |
|
||||
docker run \
|
||||
--rm \
|
||||
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
|
||||
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
|
||||
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
|
||||
-v "${{ github.workspace }}:/app" \
|
||||
crowdin/cli:3.16.0 \
|
||||
crowdin upload sources -c /app/crowdin/config.yml
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Release Chart
|
||||
run-name: Release Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- ./src/helm/desk/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -rf ./src/helm/extra
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
linting: on
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+1
-6
@@ -42,7 +42,6 @@ env.bak/
|
||||
venv.bak/
|
||||
env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/terraform
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
@@ -59,13 +58,9 @@ src/frontend/tsclient
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Terraform
|
||||
.terraform
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
|
||||
# Test & lint
|
||||
.coverage
|
||||
coverage.json
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
db.sqlite3
|
||||
|
||||
+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
|
||||
+290
@@ -7,3 +7,293 @@ and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add count mailboxes to MailDomain serializer
|
||||
- ✨(dimail) manage 'action required' status for MailDomain
|
||||
- ✨(domains) add action required status on MailDomain
|
||||
- ✨(dimail) send pending mailboxes upon domain activation
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(teams) do not display add button when disallowed #676
|
||||
- 🚑️(plugins) fix name from SIRET specific case #674
|
||||
- 🐛(api) restrict mailbox sync to enabled domains
|
||||
|
||||
## [1.10.1] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(dimail) management command to fetch domain status
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(scripts) adapts release script after moving the deployment part
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(dimail) fix imported mailboxes should be enabled instead of pending #659
|
||||
- ⚡️(api) add missing cache for stats endpoint
|
||||
|
||||
## [1.10.0] - 2025-01-21
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) create stats endpoint
|
||||
- ✨(teams) add Team dependencies #560
|
||||
- ✨(organization) add admin action for plugin #640
|
||||
- ✨(anct) fetch and display organization names of communes #583
|
||||
- ✨(frontend) display email if no username #562
|
||||
- 🧑💻(oidc) add ability to pull registration ID (e.g. SIRET) from OIDC #577
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) improve e2e tests avoiding race condition from mocks #641
|
||||
- 🐛(backend) fix flaky test with search contact #605
|
||||
- 🐛(backend) fix flaky test with team access #646
|
||||
- 🧑💻(dimail) remove 'NoneType: None' log in debug mode
|
||||
- 🐛(frontend) fix flaky e2e test #636
|
||||
- 🐛(frontend) fix disable mailbox button display #631
|
||||
- 🐛(backend) fix dimail call despite mailbox creation failure on our side
|
||||
- 🧑💻(user) fix the User.language infinite migration #611
|
||||
|
||||
## [1.9.1] - 2024-12-18
|
||||
|
||||
## [1.9.0] - 2024-12-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) fix manage roles on domain admin view
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add admin action to check domain health
|
||||
- ✨(dimail) check domain health
|
||||
- ✨(frontend) disable mailbox and allow to create pending mailbox
|
||||
- ✨(organizations) add siret to name conversion #584
|
||||
- 💄(frontend) redirect home according to abilities #588
|
||||
- ✨(maildomain_access) add API endpoint to search users #508
|
||||
|
||||
## [1.8.0] - 2024-12-12
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(contacts) add "abilities" to API endpoint data #585
|
||||
- ✨(contacts) allow filter list API with email
|
||||
- ✨(contacts) return profile contact from same organization
|
||||
- ✨(dimail) automate allows requests to dimail
|
||||
- ✨(contacts) add notes & force full_name #565
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(contacts) move user profile to contact #572
|
||||
- ♻️(contacts) split api test module in actions #573
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) fix manage roles on domain admin view
|
||||
- 🐛(mailbox) fix activate mailbox feature
|
||||
- 🔧(helm) fix the configuration environment #579
|
||||
|
||||
## [1.7.1] - 2024-11-28
|
||||
|
||||
## [1.7.0] - 2024-11-28
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(mailbox) allow to activate mailbox
|
||||
- ✨(mailbox) allow to disable mailbox
|
||||
- ✨(backend) add ServiceProvider #522
|
||||
- 💄(admin) allow header color customization #552
|
||||
- ✨(organization) add API endpoints #551
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(admin) add organization on user #555
|
||||
|
||||
## [1.6.1] - 2024-11-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(mailbox) fix status of current mailboxes
|
||||
- 🚑️(backend) fix claim contains non-user field #548
|
||||
|
||||
## [1.6.0] - 2024-11-20
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(teams) remove search by trigram for team access and contact
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(domains) allow creation of "pending" mailboxes
|
||||
- ✨(teams) allow team management for team admins/owners #509
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔊(backend) update logger config to info #542
|
||||
|
||||
## [1.5.0] - 2024-11-14
|
||||
|
||||
### Removed
|
||||
|
||||
- ⬆️(dependencies) remove unneeded dependencies
|
||||
- 🔥(teams) remove pagination of teams listing
|
||||
- 🔥(teams) remove search users by trigram
|
||||
- 🗃️(teams) remove `slug` field
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(dimail) send domain creation requests to dimail #454
|
||||
- ✨(dimail) synchronize mailboxes from dimail to our db #453
|
||||
- ✨(ci) add security scan #429
|
||||
- ✨(teams) register contacts on admin views
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(mail) fix display button on outlook
|
||||
- 💚(ci) improve E2E tests #492
|
||||
- 🔧(sentry) restore default integrations
|
||||
- 🔇(backend) remove Sentry duplicated warning/errors
|
||||
- 👷(ci) add sharding e2e tests #467
|
||||
- 🐛(dimail) fix unexpected status_code for proper debug #454
|
||||
|
||||
## [1.4.1] - 2024-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(frontend) fix MailDomainsLayout
|
||||
|
||||
## [1.4.0] - 2024-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add tabs inside #466
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✏️(mail) fix typo into mailbox creation email
|
||||
- 🐛(sentry) fix duplicated sentry errors #479
|
||||
- 🐛(script) improve and fix release script
|
||||
|
||||
## [1.3.1] - 2024-10-18
|
||||
|
||||
## [1.3.0] - 2024-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add RELEASE version on config endpoint #459
|
||||
- ✨(backend) manage roles on domain admin view
|
||||
- ✨(frontend) show version number in footer #369
|
||||
- 👔(backend) add Organization model
|
||||
|
||||
### Changed
|
||||
|
||||
- 🛂(backend) match email if no existing user matches the sub
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💄(mail) improve mailbox creation email #462
|
||||
- 🐛(frontend) fix update accesses form #448
|
||||
- 🛂(backend) do not duplicate user when disabled
|
||||
|
||||
## [1.2.1] - 2024-10-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔧(mail) use new scaleway email gateway #435
|
||||
|
||||
|
||||
## [1.2.0] - 2024-09-30
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(ci) add helmfile linter and fix argocd sync #424
|
||||
- ✨(domains) add endpoint to list and retrieve domain accesses #404
|
||||
- 🍱(dev) embark dimail-api as container #366
|
||||
- ✨(dimail) allow la regie to request a token for another user #416
|
||||
- ✨(frontend) show username on AccountDropDown #412
|
||||
- 🥅(frontend) improve add & update group forms error handling #387
|
||||
- ✨(frontend) allow group members filtering #363
|
||||
- ✨(mailbox) send new mailbox confirmation email #397
|
||||
- ✨(domains) domain accesses update API #423
|
||||
- ✨(backend) domain accesses create API #428
|
||||
- 🥅(frontend) catch new errors on mailbox creation #392
|
||||
- ✨(api) domain accesses delete API #433
|
||||
- ✨(frontend) add mail domain access management #413
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿️(frontend) fix left nav panel #396
|
||||
- 🔧(backend) fix configuration to avoid different ssl warning #432
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(serializers) move business logic to serializers #414
|
||||
|
||||
## [1.1.0] - 2024-09-10
|
||||
|
||||
### Added
|
||||
|
||||
- 📈(monitoring) configure sentry monitoring #378
|
||||
- 🥅(frontend) improve api error handling #355
|
||||
|
||||
### Changed
|
||||
|
||||
- 🗃️(models) move dimail 'secret' to settings #372
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(dimail) improve handling of dimail errors on failed mailbox creation #377
|
||||
- 💬(frontend) fix group member removal text #382
|
||||
- 💬(frontend) fix add mail domain text #382
|
||||
- 🐛(frontend) fix keyboard navigation #379
|
||||
- 🐛(frontend) fix add mail domain form submission #355
|
||||
|
||||
## [1.0.2] - 2024-08-30
|
||||
|
||||
### Added
|
||||
|
||||
- 🔧Runtime config for the frontend (#345)
|
||||
- 🔧(helm) configure resource server in staging
|
||||
|
||||
### Fixed
|
||||
|
||||
- 👽️(mailboxes) fix mailbox creation after dimail api improvement (#360)
|
||||
|
||||
## [1.0.1] - 2024-08-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✨(frontend) user can add mail domains
|
||||
|
||||
## [1.0.0] - 2024-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(domains) create and manage domains on admin + API
|
||||
- ✨(domains) mailbox creation + link to email provisioning API
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.10.1...main
|
||||
[1.10.1]: https://github.com/numerique-gouv/people/releases/v1.10.1
|
||||
[1.10.0]: https://github.com/numerique-gouv/people/releases/v1.10.0
|
||||
[1.9.1]: https://github.com/numerique-gouv/people/releases/v1.9.1
|
||||
[1.9.0]: https://github.com/numerique-gouv/people/releases/v1.9.0
|
||||
[1.8.0]: https://github.com/numerique-gouv/people/releases/v1.8.0
|
||||
[1.7.1]: https://github.com/numerique-gouv/people/releases/v1.7.1
|
||||
[1.7.0]: https://github.com/numerique-gouv/people/releases/v1.7.0
|
||||
[1.6.1]: https://github.com/numerique-gouv/people/releases/v1.6.1
|
||||
[1.6.0]: https://github.com/numerique-gouv/people/releases/v1.6.0
|
||||
[1.5.0]: https://github.com/numerique-gouv/people/releases/v1.5.0
|
||||
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
|
||||
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
|
||||
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
|
||||
[1.3.0]: https://github.com/numerique-gouv/people/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/numerique-gouv/people/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/numerique-gouv/people/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/numerique-gouv/people/releases/v1.1.0
|
||||
[1.0.2]: https://github.com/numerique-gouv/people/releases/v1.0.2
|
||||
[1.0.1]: https://github.com/numerique-gouv/people/releases/v1.0.1
|
||||
[1.0.0]: https://github.com/numerique-gouv/people/releases/v1.0.0
|
||||
|
||||
+25
-33
@@ -1,18 +1,17 @@
|
||||
# Django People
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.10-slim-bullseye as base
|
||||
FROM python:3.12.6-alpine3.20 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apt-get update && \
|
||||
apt-get -y upgrade && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
### ---- Front-end dependencies image ----
|
||||
FROM node:20 as frontend-deps
|
||||
FROM node:20 AS frontend-deps
|
||||
|
||||
WORKDIR /deps
|
||||
|
||||
@@ -25,7 +24,7 @@ COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint
|
||||
RUN yarn --frozen-lockfile
|
||||
|
||||
### ---- Front-end builder dev image ----
|
||||
FROM node:20 as frontend-builder-dev
|
||||
FROM node:20 AS frontend-builder-dev
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -35,12 +34,12 @@ COPY ./src/frontend .
|
||||
WORKDIR ./apps/desk
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-builder-dev as frontend-builder
|
||||
FROM frontend-builder-dev AS frontend-builder
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
@@ -61,7 +60,7 @@ CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base as back-builder
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -73,7 +72,7 @@ RUN mkdir /install && \
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 as mail-builder
|
||||
FROM node:20 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
@@ -84,15 +83,13 @@ RUN yarn install --frozen-lockfile && \
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
FROM base as link-collector
|
||||
FROM base AS link-collector
|
||||
ARG PEOPLE_STATIC_ROOT=/data/static
|
||||
|
||||
# Install libpangocairo & rdfind
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
libpangocairo-1.0-0 \
|
||||
rdfind && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
@@ -111,21 +108,18 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${PEOPLE_STATIC_ROOT}
|
||||
|
||||
# ---- Core application image ----
|
||||
FROM base as core
|
||||
FROM base AS core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
gettext \
|
||||
libcairo2 \
|
||||
libffi-dev \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
shared-mime-info && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add \
|
||||
gettext \
|
||||
cairo \
|
||||
libffi-dev \
|
||||
gdk-pixbuf \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -149,15 +143,13 @@ WORKDIR /app
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM core as backend-development
|
||||
FROM core AS backend-development
|
||||
|
||||
# Switch back to the root user to install development dependencies
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
RUN apt-get update && \
|
||||
apt-get install -y postgresql-client && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall people and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
@@ -177,7 +169,7 @@ ENV DB_HOST=postgresql \
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core as backend-production
|
||||
FROM core AS backend-production
|
||||
|
||||
ARG PEOPLE_STATIC_ROOT=/data/static
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ bootstrap: \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build \
|
||||
dimail-setup-db \
|
||||
install-front-desk
|
||||
.PHONY: bootstrap
|
||||
|
||||
@@ -109,6 +110,7 @@ run: ## start the wsgi (production) and development server
|
||||
@$(COMPOSE) up --force-recreate -d app-dev
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d keycloak
|
||||
@$(COMPOSE) up -d dimail
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_KC_DB)
|
||||
@$(WAIT_DB)
|
||||
@@ -129,6 +131,7 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
lint: \
|
||||
@@ -166,6 +169,10 @@ test-back-parallel: ## run all back-end tests in parallel
|
||||
bin/pytest -n auto $${args:-${1}}
|
||||
.PHONY: test-back-parallel
|
||||
|
||||
test-coverage: ## compute, display and save test coverage
|
||||
bin/pytest --cov=. --cov-report json .
|
||||
.PHONY: test-coverage
|
||||
|
||||
makemigrations: ## run django makemigrations for the people project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@@ -189,7 +196,8 @@ showmigrations: ## run django showmigrations for the people project.
|
||||
|
||||
superuser: ## Create an admin superuser with password "admin"
|
||||
@echo "$(BOLD)Creating a Django superuser$(RESET)"
|
||||
@$(MANAGE) createsuperuser --admin_email admin@example.com --password admin
|
||||
$(MANAGE) createsuperuser --username admin --password admin
|
||||
|
||||
.PHONY: superuser
|
||||
|
||||
back-i18n-compile: ## compile the gettext files
|
||||
@@ -267,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
|
||||
|
||||
@@ -335,6 +354,56 @@ start-kind: ## Create the kubernetes cluster
|
||||
./bin/start-kind.sh
|
||||
.PHONY: start-kind
|
||||
|
||||
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
||||
./bin/install-external-secrets.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
tilt-up: ## start tilt - k8s local development
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: tilt-up
|
||||
|
||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
release: ## helper for release and deployment
|
||||
python scripts/release.py
|
||||
.PHONY: release
|
||||
|
||||
install-secret: ## install the kubernetes secrets from Vaultwarden
|
||||
if kubectl -n desk get secrets bitwarden-cli-desk; then \
|
||||
echo "Secret already present"; \
|
||||
else \
|
||||
echo "Please provide the following information:"; \
|
||||
read -p "Enter your vaultwarden email login: " LOGIN; \
|
||||
read -p "Enter your vaultwarden password: " PASSWORD; \
|
||||
read -p "Enter your vaultwarden server url: " URL; \
|
||||
echo "\nCreate vaultwarden secret"; \
|
||||
echo "apiVersion: v1" > /tmp/secret.yaml; \
|
||||
echo "kind: Secret" >> /tmp/secret.yaml; \
|
||||
echo "metadata:" >> /tmp/secret.yaml; \
|
||||
echo " name: bitwarden-cli-desk" >> /tmp/secret.yaml; \
|
||||
echo " namespace: desk" >> /tmp/secret.yaml; \
|
||||
echo "type: Opaque" >> /tmp/secret.yaml; \
|
||||
echo "stringData:" >> /tmp/secret.yaml; \
|
||||
echo " BW_HOST: $$URL" >> /tmp/secret.yaml; \
|
||||
echo " BW_PASSWORD: $$PASSWORD" >> /tmp/secret.yaml; \
|
||||
echo " BW_USERNAME: $$LOGIN" >> /tmp/secret.yaml; \
|
||||
kubectl -n desk apply -f /tmp/secret.yaml;\
|
||||
rm -f /tmp/secret.yaml; \
|
||||
fi; \
|
||||
if kubectl get ns external-secrets; then \
|
||||
echo "External secret already deployed"; \
|
||||
else \
|
||||
helm repo add external-secrets https://charts.external-secrets.io; \
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n external-secrets \
|
||||
--create-namespace \
|
||||
--set installCRDs=true; \
|
||||
fi
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
fetch-domain-status:
|
||||
@$(MANAGE) fetch_domain_status
|
||||
.PHONY: fetch-domain-status
|
||||
|
||||
@@ -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
|
||||
@@ -69,7 +63,24 @@ You first need to create a superuser account:
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
You can then login with credentials `admin@example` / `admin`.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+17
-1
@@ -29,7 +29,9 @@ docker_build(
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n desk -e dev template .'))
|
||||
# helmfile in docker mount the current working directory and the helmfile.yaml
|
||||
# requires the keycloak config in another directory
|
||||
k8s_yaml(local('cd .. && helmfile -n desk -e ${DEV_ENV:-dev} template --file ./src/helm/helmfile.yaml'))
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
@@ -56,3 +58,17 @@ cmd_button('Migrate db',
|
||||
icon_name='developer_board',
|
||||
text='Run database migration',
|
||||
)
|
||||
|
||||
# Command to created domain/users/access from people to dimail
|
||||
populate_dimail_from_people = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py setup_dimail_db --populate-from-people
|
||||
'''
|
||||
cmd_button('Populate dimail from people',
|
||||
argv=['sh', '-c', populate_dimail_from_people],
|
||||
resource='desk-backend',
|
||||
icon_name='developer_board',
|
||||
text='Populate dimail from people',
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
NAMESPACE=${1:-desk}
|
||||
SECRET_NAME=${2:-bitwarden-cli-desk}
|
||||
TEMP_SECRET_FILE=$(mktemp)
|
||||
|
||||
|
||||
cleanup() {
|
||||
rm -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
# Check if kubectl is available
|
||||
check_prerequisites() {
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
echo "Error: kubectl is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if secret already exists
|
||||
check_secret_exists() {
|
||||
kubectl -n "${NAMESPACE}" get secrets "${SECRET_NAME}" &> /dev/null
|
||||
}
|
||||
|
||||
|
||||
# Collect user input securely
|
||||
get_user_input() {
|
||||
echo "Please provide the following information:"
|
||||
read -p "Enter your Vaultwarden email login: " LOGIN
|
||||
read -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||
echo
|
||||
read -p "Enter your Vaultwarden server url: " URL
|
||||
}
|
||||
|
||||
# Create and apply the secret
|
||||
create_secret() {
|
||||
cat > "${TEMP_SECRET_FILE}" << EOF
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${SECRET_NAME}
|
||||
namespace: ${NAMESPACE}
|
||||
type: Opaque
|
||||
stringData:
|
||||
BW_HOST: ${URL}
|
||||
BW_PASSWORD: ${PASSWORD}
|
||||
BW_USERNAME: ${LOGIN}
|
||||
EOF
|
||||
|
||||
kubectl -n "${NAMESPACE}" apply -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
|
||||
# Install external-secrets using Helm
|
||||
install_external_secrets() {
|
||||
if ! kubectl get ns external-secrets &>/dev/null; then
|
||||
echo "Installing external-secrets…"
|
||||
helm repo add external-secrets https://charts.external-secrets.io
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n external-secrets \
|
||||
--create-namespace \
|
||||
--set installCRDs=true
|
||||
else
|
||||
echo "External secrets already deployed"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
check_prerequisites
|
||||
|
||||
if check_secret_exists; then
|
||||
echo "Secret '${SECRET_NAME}' already present in namespace '${NAMESPACE}'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e ${TEMP_SECRET_FILE}
|
||||
|
||||
get_user_input
|
||||
echo -e "\nCreating Vaultwarden secret…"
|
||||
create_secret
|
||||
install_external_secrets
|
||||
|
||||
echo "Secret installation completed successfully"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Regular → Executable
+2
-101
@@ -1,102 +1,3 @@
|
||||
#!/bin/sh
|
||||
set -o errexit
|
||||
#!/usr/bin/env bash
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
|
||||
# 0. Create ca
|
||||
echo "0. Create ca"
|
||||
mkcert -install
|
||||
cd /tmp
|
||||
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
|
||||
cd $CURRENT_DIR
|
||||
|
||||
# 1. Create registry container unless it already exists
|
||||
echo "1. Create registry container unless it already exists"
|
||||
reg_name='kind-registry'
|
||||
reg_port='5001'
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
|
||||
docker run \
|
||||
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
|
||||
registry:2
|
||||
fi
|
||||
|
||||
# 2. Create kind cluster with containerd registry config dir enabled
|
||||
echo "2. Create kind cluster with containerd registry config dir enabled"
|
||||
# TODO: kind will eventually enable this by default and this patch will
|
||||
# be unnecessary.
|
||||
#
|
||||
# See:
|
||||
# https://github.com/kubernetes-sigs/kind/issues/2875
|
||||
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
|
||||
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
|
||||
cat <<EOF | kind create cluster --config=-
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: kindest/node:v1.27.3
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: InitConfiguration
|
||||
nodeRegistration:
|
||||
kubeletExtraArgs:
|
||||
node-labels: "ingress-ready=true"
|
||||
extraPortMappings:
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
EOF
|
||||
|
||||
# 3. Add the registry config to the nodes
|
||||
echo "3. Add the registry config to the nodes"
|
||||
#
|
||||
# This is necessary because localhost resolves to loopback addresses that are
|
||||
# network-namespace local.
|
||||
# In other words: localhost in the container is not localhost on the host.
|
||||
#
|
||||
# We want a consistent name that works from both ends, so we tell containerd to
|
||||
# alias localhost:${reg_port} to the registry container when pulling images
|
||||
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
|
||||
for node in $(kind get nodes); do
|
||||
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
|
||||
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
|
||||
[host."http://${reg_name}:5000"]
|
||||
EOF
|
||||
done
|
||||
|
||||
# 4. Connect the registry to the cluster network if not already connected
|
||||
echo "4. Connect the registry to the cluster network if not already connected"
|
||||
# This allows kind to bootstrap the network but ensures they're on the same network
|
||||
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
|
||||
docker network connect "kind" "${reg_name}"
|
||||
fi
|
||||
|
||||
# 5. Document the local registry
|
||||
echo "5. Document the local registry"
|
||||
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: local-registry-hosting
|
||||
namespace: kube-public
|
||||
data:
|
||||
localRegistryHosting.v1: |
|
||||
host: "localhost:${reg_port}"
|
||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||
EOF
|
||||
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
||||
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- desk
|
||||
|
||||
@@ -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.1.1
|
||||
pull_policy: always
|
||||
environment:
|
||||
DIMAIL_MODE: FAKE
|
||||
DIMAIL_JWT_SECRET: fake_jwt_secret
|
||||
ports:
|
||||
- "8001:8000"
|
||||
|
||||
+268
-2
@@ -58,6 +58,23 @@
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.marie",
|
||||
"email": "marie.varzy@gmail.com",
|
||||
"firstName": "Marie",
|
||||
"lastName": "Delamairie",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "21580304000017"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.marie"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
@@ -99,6 +116,216 @@
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-member",
|
||||
"email": "e2e.team-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-administrator",
|
||||
"email": "e2e.team-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-owner",
|
||||
"email": "e2e.team-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.mail-member",
|
||||
"email": "e2e.mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.mail-administrator",
|
||||
"email": "e2e.mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.mail-owner",
|
||||
"email": "e2e.mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-member-mail-member",
|
||||
"email": "e2e.team-member-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-member-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-member-mail-administrator",
|
||||
"email": "e2e.team-member-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-member-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-member-mail-owner",
|
||||
"email": "e2e.team-member-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-member-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-administrator-mail-member",
|
||||
"email": "e2e.team-administrator-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-administrator-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-administrator-mail-administrator",
|
||||
"email": "e2e.team-administrator-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-administrator-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-administrator-mail-owner",
|
||||
"email": "e2e.team-administrator-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-administrator-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-owner-mail-member",
|
||||
"email": "e2e.team-owner-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-owner-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-owner-mail-administrator",
|
||||
"email": "e2e.team-owner-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-owner-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.team-owner-mail-owner",
|
||||
"email": "e2e.team-owner-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.team-owner-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
@@ -485,9 +712,17 @@
|
||||
"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false,
|
||||
"webAuthnPolicyPasswordlessAcceptableAaguids": [],
|
||||
"scopeMappings": [
|
||||
{
|
||||
"clientScope": "siret",
|
||||
"roles": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientScope": "offline_access",
|
||||
"roles": ["offline_access"]
|
||||
"roles": [
|
||||
"offline_access"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopeMappings": {
|
||||
@@ -737,6 +972,7 @@
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"siret",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
@@ -897,6 +1133,35 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eb220fbb-02ac-4105-95a3-727954f6565d",
|
||||
"name": "siret",
|
||||
"description": "siret",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "false",
|
||||
"gui.order": ""
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"id": "333a4e89-9363-4c36-b56f-79c6b019c6c6",
|
||||
"name": "siret",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"aggregate.attrs": "false",
|
||||
"userinfo.token.claim": "true",
|
||||
"multivalued": "false",
|
||||
"user.attribute": "siret",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "siret"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "af52ccc3-4ecb-49b4-9a67-5d4172f16070",
|
||||
"name": "role_list",
|
||||
@@ -1363,7 +1628,8 @@
|
||||
"email",
|
||||
"roles",
|
||||
"web-origins",
|
||||
"acr"
|
||||
"acr",
|
||||
"siret"
|
||||
],
|
||||
"defaultOptionalClientScopes": [
|
||||
"offline_access",
|
||||
|
||||
@@ -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,131 @@
|
||||
# Local development with Kubernetes
|
||||
|
||||
We use tilt to provide a local development environment for Kubernetes.
|
||||
Tilt is a tool that helps you develop applications for Kubernetes.
|
||||
It watches your files for changes, rebuilds your containers, and restarts your pods.
|
||||
It's like having a conversation with your cluster.
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This guide assumes you have the following tools installed:
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/)
|
||||
- [Kind](https://kind.sigs.k8s.io/docs/user/quick-start/)
|
||||
* [mkcert](https://github.com/FiloSottile/mkcert)
|
||||
- [ctlptl](https://github.com/tilt-dev/ctlptl)
|
||||
- [Tilt](https://docs.tilt.dev/install.html)
|
||||
* [helm](https://helm.sh/docs/intro/install/)
|
||||
* [helmfile](https://github.com/helmfile/helmfile)
|
||||
* [secrets](https://github.com/jkroepke/helm-secrets/wiki/Installation)
|
||||
* [sops](https://github.com/getsops/sops)
|
||||
|
||||
|
||||
### SOPS configuration
|
||||
|
||||
**Generate a SOPS key**
|
||||
|
||||
For this specific step you need to have the `age-keygen` tool installed.
|
||||
See https://github.com/FiloSottile/age.
|
||||
Then generate a key:
|
||||
|
||||
```bash
|
||||
age-keygen -o my-age.key
|
||||
```
|
||||
|
||||
**Install the SOPS key**
|
||||
|
||||
Read the SOPS documentation on how to install the key in your environment.
|
||||
https://github.com/getsops/sops?tab=readme-ov-file#22encrypting-using-age
|
||||
|
||||
On Ubuntu it's like:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/sops/age/
|
||||
cp my-age.key ~/.config/sops/age/keys.txt
|
||||
chmod 400 ~/.config/sops/age/keys.txt
|
||||
```
|
||||
|
||||
**Add the SOPS key to the repository**
|
||||
|
||||
Update the [.sops.yaml](../.sops.yaml) file with the **public** key id you generated.
|
||||
|
||||
|
||||
### Helmfile in Docker
|
||||
|
||||
If you use helmfile in Docker, you may need an additional configuration to make
|
||||
it work with you age key.
|
||||
|
||||
You need to mount `-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/"`
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
|
||||
docker run --rm --net=host \
|
||||
-v "${HOME}/.kube:/root/.kube" \
|
||||
-v "${HOME}/.config/helm:/root/.config/helm" \
|
||||
-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/" \
|
||||
-v "${HOME}/.minikube:/${HOME}/.minikube" \
|
||||
-v "${PWD}:/wd" \
|
||||
-e KUBECONFIG=/root/.kube/config \
|
||||
--workdir /wd ghcr.io/helmfile/helmfile:v0.150.0 helmfile "$@"
|
||||
```
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
### Create the kubernetes cluster
|
||||
|
||||
Run the following command to create a kubernetes cluster using kind:
|
||||
|
||||
```bash
|
||||
./bin/start-kind.sh
|
||||
```
|
||||
|
||||
**or** run the equivalent using the makefile
|
||||
|
||||
```bash
|
||||
make start-kind
|
||||
```
|
||||
|
||||
### [Optional] Install the secret
|
||||
|
||||
You don't need to do this if you are running the stack with keycloak.
|
||||
|
||||
```bash
|
||||
make install-external-secrets
|
||||
```
|
||||
|
||||
### Deploy the application
|
||||
|
||||
```bash
|
||||
# Pro Connect environment
|
||||
tilt up -f ./bin/Tiltfile
|
||||
|
||||
# Standalone environment with keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
```
|
||||
|
||||
**or** run the equivalent using the makefile
|
||||
|
||||
```bash
|
||||
# Pro Connect environment
|
||||
make tilt-up
|
||||
|
||||
# Standalone environment with keycloak
|
||||
make tilt-up-keycloak
|
||||
```
|
||||
|
||||
That's it! You should now have a local development environment for Kubernetes.
|
||||
|
||||
You can access the application at https://desk.127.0.0.1.nip.io
|
||||
|
||||
## Management
|
||||
|
||||
To manage the cluster, you can use k9s.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Add dimail to the local development environment
|
||||
- Add a reset demo `cmd_button` to Tilt
|
||||
+103
-73
@@ -6,16 +6,56 @@ interact efficiently.
|
||||
|
||||
Let's see how we could interact with Django's shell to recreate David's environment in the app.
|
||||
|
||||
## Base contacts from the organization records
|
||||
## Profile contact
|
||||
|
||||
David Bowman is an exemplary employee at Space Odyssey Corporation. His email is
|
||||
`david.bowman@spaceodyssey.com` and he is registered in the organization's records via a base
|
||||
contact as follows:
|
||||
The system identifies Dave through the `sub` associated with his OIDC session.
|
||||
The OIDC should also provide his email address.
|
||||
|
||||
Note: as some Identity Providers do not provide a constant `sub`, the system should be able to
|
||||
fallback to the email address to find it the user is already in the database.
|
||||
|
||||
When Dave logs in for the first time, the system checks if he is already in the database. If not
|
||||
a new user is created for him.
|
||||
|
||||
Users needs to be linked to an Organization (see [organizations.md](./organizations.md)).
|
||||
|
||||
```python
|
||||
david_base_contact = Contact.objects.create(
|
||||
space_odyssey, _created = Organization.objects.get_or_create(...)
|
||||
|
||||
david_user = User.objects.create(
|
||||
organization=space_odyssey,
|
||||
language="en-us",
|
||||
timezone="America/Los_Angeles",
|
||||
name="David Bowman",
|
||||
sub="1234567890",
|
||||
email="david.bowman@spaceodyssey.com",
|
||||
)
|
||||
```
|
||||
|
||||
The system then creates a profile contact for Dave, based on the information provided by the OIDC:
|
||||
|
||||
```python
|
||||
david_profile_contact = Contact.objects.create(
|
||||
owner=david_user,
|
||||
user=david_user, # this means it's the user's profile contact
|
||||
full_name="David Bowman",
|
||||
short_name="David",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**Future feature:**
|
||||
Dave will be prompted to confirm the details of the base contact stored in the database
|
||||
and fill the details for a better profile.
|
||||
|
||||
His profile contact will be updated with the new information.
|
||||
|
||||
```python
|
||||
Contact.objects.filter(user=david_user, owner=david_user).update(
|
||||
short_name="Dave",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
|
||||
@@ -48,91 +88,81 @@ david_base_contact = Contact.objects.create(
|
||||
)
|
||||
```
|
||||
|
||||
When David logs-in to the People application for the first time using the corporation's OIDC
|
||||
Single Sign-On service. A user is created for him on the fly by the system, together with an
|
||||
identity record representing the OIDC session:
|
||||
David's profile contact is available to all users of his company/organization.
|
||||
|
||||
```python
|
||||
david_user = User.objects.create(
|
||||
language="en-us",
|
||||
timezone="America/Los_Angeles",
|
||||
)
|
||||
david_identity = Identity.objects.create(
|
||||
"user": david_user,
|
||||
"sub": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
"email" : "david.bowman@spaceodyssey.com",
|
||||
"is_main": True,
|
||||
)
|
||||
```
|
||||
|
||||
## Profile contact
|
||||
|
||||
The system identifies Dave through the email associated with his OIDC session and prompts him to
|
||||
confirm the details of the base contact stored in the database.
|
||||
|
||||
When David confirms, giving an alternative short name that he prefers, a contact override is
|
||||
created on top of the organization's base contact. This new contact is marked as David's profile
|
||||
on the user:
|
||||
|
||||
```python
|
||||
david_contact = Contact.objects.create(
|
||||
base=david_base_contact,
|
||||
owner=david_user,
|
||||
full_name="David Bowman",
|
||||
short_name="Dave",
|
||||
data={}
|
||||
)
|
||||
david_user.profile_contact = david_contact
|
||||
david_user.save()
|
||||
```
|
||||
|
||||
If Dave had not had any existing contact in the organization's records, the profile contact would
|
||||
have been created independently, without any connection to a base contact:
|
||||
|
||||
```python
|
||||
david_contact = Contact.objects.create(
|
||||
base=None,
|
||||
owner=david_user,
|
||||
full_name="David Bowman",
|
||||
short_name="Dave",
|
||||
data={}
|
||||
)
|
||||
```
|
||||
|
||||
Now, Dave feels like sharing his mobile phone number with his colleagues. He can do this
|
||||
by editing his contact in the application:
|
||||
|
||||
```python
|
||||
contact.data["phones"] = [
|
||||
{"type": "Mobile", "value": "(123) 456-7890"},
|
||||
]
|
||||
contact.save()
|
||||
```
|
||||
|
||||
## Contact override
|
||||
|
||||
During a Space conference he attended, Dave met Dr Ryan Stone, a medical engineer who gave him
|
||||
her professional email address. Ryan is already present in the system but her email is missing.
|
||||
her personal email address (because she will quit the company she works for). Ryan is already
|
||||
present in the system.
|
||||
|
||||
Dave can add it to his private version of the contact:
|
||||
|
||||
```python
|
||||
ryan_base_contact = Contact.objects.create(
|
||||
full_name="Ryan Stone",
|
||||
data={}
|
||||
ryan_user = User.objects.create(
|
||||
organization=space_odyssey,
|
||||
language="en-us",
|
||||
timezone="America/Los_Angeles",
|
||||
name="Ryan Stone",
|
||||
sub="0987654321",
|
||||
email="ryan.stone@spaceodyssey.com",
|
||||
)
|
||||
ryan_contact = Contact.objects.create(
|
||||
base=ryan_base_contact,
|
||||
owner=david_user,
|
||||
|
||||
ryan_profile_contact = Contact.objects.create(
|
||||
user=ryan_user,
|
||||
owner=ryan_user,
|
||||
full_name="Ryan Stone",
|
||||
short_name="Dr Ryan",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "ryan.stone@hubblestation.com"},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
david_ryan_contact = Contact.objects.create(
|
||||
override=ryan_profile_contact,
|
||||
owner=david_user,
|
||||
full_name="Ryan Stone",
|
||||
short_name="Dr Ryan",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Home", "value": "ryan@stone.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Personal contacts
|
||||
|
||||
Dave met a lot of people during the last Opensource Experience event. He can add them to his personal contacts:
|
||||
|
||||
```python
|
||||
julie_personal_contact = Contact.objects.create(
|
||||
owner=david_user,
|
||||
full_name="Julie Powell",
|
||||
short_name="Julie",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "julie.powell@example.com"},
|
||||
],
|
||||
"phones": [
|
||||
{"type": "Work", "value": "(123) 456-7890"},
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
These contacts are only available to Dave.
|
||||
|
||||
|
||||
## Contacts from shared directories
|
||||
|
||||
This is a future feature that will allow Dave to access contacts from shared directories.
|
||||
Shared directories will provide contacts for all users from several organizations.
|
||||
|
||||
|
||||
## Team Collaboration
|
||||
|
||||
Dave wants to form a team with Ryan and other colleagues to work together better on using the organization's digital tools for their projects.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Organization model
|
||||
|
||||
## Purpose
|
||||
|
||||
The initial `Organization` model allows to regroup `User` and `Team` under a same object.
|
||||
|
||||
While the purpose was purely technical in the first place, few features emerged afterward.
|
||||
|
||||
|
||||
## Link with the `User` model
|
||||
|
||||
Each user must have a "default" organization.
|
||||
|
||||
When a user logs in:
|
||||
|
||||
- The backend tries to get an existing organization from a "registration ID" or from the user's mail domain
|
||||
- If the organization does not exist, it is created using the "registration ID" and fallbacks on the user's mail domain
|
||||
- The user organization is set to this organization.
|
||||
|
||||
**Note:** While deploying the new code, we allow the already existing user to not have one.
|
||||
|
||||
|
||||
## Link with the `Team` model
|
||||
|
||||
Each team must have an organization. This organization is not defined by the user, but automatically set
|
||||
using the user's organization.
|
||||
|
||||
The team's organization does not restrict the users who can be added to the team.
|
||||
|
||||
There is currently no feature relying on the team organization.
|
||||
|
||||
**Note:** While deploying the new code, we allow the teams to not have one.
|
||||
|
||||
|
||||
## Organization-related features
|
||||
|
||||
### Organization UI
|
||||
|
||||
A new interface can be created to allow users to see all members of an organization.
|
||||
This could be used along with the contacts.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
## Resource Server
|
||||
|
||||
For detailed information, please refer to the [OAuth 2.0 Resource Server documentation](https://www.oauth.com/oauth2-servers/the-resource-server/) and review the relevant commits that implement the resource server backend.
|
||||
|
||||
---
|
||||
#### Overview
|
||||
|
||||
A resource server is a crucial component in an OAuth 2.0 ecosystem. It is responsible for accepting access tokens issued by the authorization server (in this case, Agent Connect), introspecting and validating those tokens, and then returning the requested protected resources to the client.
|
||||
|
||||
By implementing a resource server, we can securely share data between different services within La Suite. This ensures that only clients with the appropriate scopes and permissions can access specific resources, thereby enhancing security and maintaining proper access control across services.
|
||||
|
||||
---
|
||||
#### Disclaimer
|
||||
|
||||
- Currently compatible only with Agent Connect.
|
||||
- The development setup requires simplification, with dependencies on Agent Connect ideally mocked.
|
||||
- Terminology aligns with the specification: what is referred to as a "resource server" is known as a "data provider" in Agent Connect.
|
||||
- This documentation is WIP.
|
||||
|
||||
---
|
||||
## Running Locally
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **Agent Connect Stack**: Ensure the local Agent Connect stack is running. A solid understanding of its configuration and operation is recommended (advanced level).
|
||||
- **People Stack**: Make sure the People stack is up and running.
|
||||
- **Ngrok**: Install and set up Ngrok for secure tunneling.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Update People's configurations
|
||||
|
||||
#### Environment variables
|
||||
|
||||
|
||||
Agent Connect includes two pre-configured mocked data providers in its default stack (`bdd-fca-low`).
|
||||
|
||||
Use the client ID and client secret from one of these data providers. **Note:** Make sure to retrieve the decrypted secret, as it is stored encrypted in the database. You can find these values in the `dp.js` fixture file, where they are exposed in a comment.
|
||||
|
||||
Configure your environment with the following values from Agent Connect:
|
||||
|
||||
```
|
||||
OIDC_RS_CLIENT_ID=<your-client-id-from-ac>
|
||||
OIDC_RS_CLIENT_SECRET=<your-decrypted-client-secret-from-ac>
|
||||
|
||||
# In development, the resource server use insecure settings
|
||||
OIDC_VERIFY_SSL=False
|
||||
|
||||
# Update the endpoints as follows
|
||||
OIDC_OP_JWKS_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/jwks
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=https://core-fca-low.docker.dev-franceconnect.fr/api/v2/checktoken
|
||||
OIDC_OP_URL=https://core-fca-low.docker.dev-franceconnect.fr/api/v2
|
||||
```
|
||||
|
||||
#### Docker Network Configuration
|
||||
|
||||
To enable communication between the Docker networks for People and Agent Connect, update your docker-compose configuration. This setup is required because the Authorization Server and Resource Server will exchange requests over a back-channel, necessitating their accessibility to each other.
|
||||
|
||||
1. **Create a Network**: Define a new network to bridge the two Docker networks.
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
authorization_server:
|
||||
external: true
|
||||
driver: bridge
|
||||
name: "${DESK_NETWORK:-fc_public}"
|
||||
```
|
||||
|
||||
2. **Update Network for `app-dev`**: Ensure your `app-dev` service is connected to both the default network and the new `authorization_server` network.
|
||||
|
||||
```yaml
|
||||
app-dev:
|
||||
...
|
||||
networks:
|
||||
- default
|
||||
- authorization_server
|
||||
```
|
||||
|
||||
|
||||
#### Ngrok
|
||||
|
||||
To expose your local resource server through an HTTP tunnel, use Ngrok. This is necessary because, in the Agent Connect development stack, the resource server needs to be accessible to a user agent via a publicly accessible URL.
|
||||
|
||||
```
|
||||
$ ngrok http 8071
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Update AgentConnect's configurations
|
||||
|
||||
Modify the AgentConnect configuration to include the local resource server settings.
|
||||
|
||||
**Update `fsa1-low` Environment File**:
|
||||
|
||||
Add your Ngrok URL and configure the `DATA_APIS` list with the appropriate values.
|
||||
|
||||
```env
|
||||
App_DATA_APIS=[{"name":"Data Provider 1","url":"https://your-ngrok-url/api/v1.0/any-path","secret":"***"}, ...]
|
||||
```
|
||||
|
||||
**Update Fixture in `dp.js`**:
|
||||
|
||||
Adjust the configuration for the data provider to match your local setup. Ensure that the `client_id`, `client_secret`, and other parameters are correctly set and aligned with your environment. This can be configured through environment variables.
|
||||
|
||||
```javascript
|
||||
const dps = [
|
||||
// Data Provider Configuration
|
||||
{
|
||||
uid: "6f21b751-ed06-48b6-a59c-36e1300a368a",
|
||||
title: "Mock Data Provider - 1",
|
||||
active: true,
|
||||
slug: "DESK",
|
||||
client_id: "***",
|
||||
client_secret: "***",
|
||||
// client_secret decrypted : ****
|
||||
jwks_uri: "https://your-ngrok-url/api/v1.0/jwks", // Update this line
|
||||
checktoken_signed_response_alg: "ES256",
|
||||
checktoken_encrypted_response_alg: "RSA-OAEP",
|
||||
checktoken_encrypted_response_enc: "A256GCM",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**Note**: Ensure that the `jwks_uri` and other cryptographic parameters (e.g., `checktoken_signed_response_alg`, `checktoken_encrypted_response_alg`, and `checktoken_encrypted_response_enc`) match your actual setup and are configured via environment variables where necessary.
|
||||
|
||||
---
|
||||
|
||||
### Usage
|
||||
|
||||
This section is a work in progress. Please note the following important points:
|
||||
|
||||
#### User `sub` Matching
|
||||
|
||||
Ensure that the `sub` (subject) field for users in AgentConnect matches the corresponding value in the People database. To synchronize this, you can run `make demo`, then edit the user's `sub` field to match the value returned by AgentConnect. For this, you'll need to update the editable field in Django Admin, specifically in `admin.py`. Adjust the `get_readonly_fields` method as follows:
|
||||
|
||||
```python
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""The 'sub' field should only be editable during creation, not for updates."""
|
||||
if obj:
|
||||
return self.readonly_fields
|
||||
return self.readonly_fields + ["sub"] # update this line adding 'sub'
|
||||
```
|
||||
|
||||
#### Scope for `groups`
|
||||
Ensure that the `groups` scope is requested from the service provider during authentication with AgentConnect.
|
||||
|
||||
#### Resource Server Requests
|
||||
By default, the `fsa1-low` environment calls the resource server using a POST request.
|
||||
|
||||
#### Testing
|
||||
|
||||
Most of the testing has been done using the `/users/me` endpoint. Update the `api/viewset.py` configuration to allow both GET and POST methods for this endpoint:
|
||||
|
||||
```python
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["get", "post"], # update this line adding 'post'
|
||||
url_name="me",
|
||||
url_path="me",
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# ServiceProvider model
|
||||
|
||||
## Purpose
|
||||
|
||||
The `ServiceProvider` model represents a ... service provider, also known as "tools using some data from this project".
|
||||
|
||||
|
||||
## Link with the `Organization` model
|
||||
|
||||
An organization can be linked to several service providers.
|
||||
The goal here, is to allow users of an organization to have access, to a service provider or not.
|
||||
The first implementation does not have any feature related, but the first feature will probably be
|
||||
to list applications visible in the "all application menu" (aka "la gaufre").
|
||||
|
||||
|
||||
## Link with the `Team` model
|
||||
|
||||
A team can be linked to several service providers.
|
||||
This is used as a filter when a service provider calls the resource server, only the teams linked to
|
||||
this service provider are returned. This is mandatory for data segregation: we don't want all service
|
||||
providers to be able to list all data regarding other service providers.
|
||||
|
||||
|
||||
## Limitations
|
||||
|
||||
There is currently no way to provision all the service providers automatically. So when a service provider
|
||||
creates a team via the resource server, we create the `ServiceProvider` on the fly, without any understandable name.
|
||||
This will need to be improved later.
|
||||
@@ -0,0 +1,51 @@
|
||||
# E2E tests
|
||||
|
||||
## Run E2E tests
|
||||
|
||||
``` bash
|
||||
# you need the dockers to be up and running
|
||||
make bootstrap
|
||||
|
||||
# you will need to have few accounts in the database
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
# run the tests
|
||||
cd src/frontend/apps/e2e
|
||||
yarn test:ui --workers=1
|
||||
```
|
||||
|
||||
A new browser window will open and you will be able to run the tests.
|
||||
|
||||
## Available accounts
|
||||
|
||||
The `make demo` command creates the following accounts:
|
||||
- `e2e.team-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only belong to a team with the specified role.
|
||||
- `e2e.mail-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only have a mailbox with the specified role access.
|
||||
- `e2e.team-<team_role>-mail-<domain_role>@example.com` with a combination of roles as for the
|
||||
previous accounts.
|
||||
|
||||
For each account, the password is `password-e2e.<role>`, for instance `password-e2e.team-member`.
|
||||
|
||||
In the E2E tests you can use these accounts to benefit from there accesses,
|
||||
using the `keyCloakSignIn(page, browserName, <account_name>)`. The account name is the
|
||||
username without the prefix `e2e.`.
|
||||
|
||||
``` typescript jsx
|
||||
await keyCloakSignIn(page, browserName, 'mail-owner');
|
||||
```
|
||||
|
||||
The `keyCloakSignIn` function will sign in the user on Keycloak using the proper username and password.
|
||||
|
||||
.. note::
|
||||
This only works because the OIDC setting is set to fallback on user email.
|
||||
|
||||
## Add a new account
|
||||
|
||||
In case you need to add a new account for specific tests you need:
|
||||
- to create a new user with the same format in the backend database:
|
||||
update `[create_demo.py](../src/backend/demo/management/commands/create_demo.py)`
|
||||
- to create a new account in Keycloak: update [realm.json](../docker/auth/realm.json)
|
||||
- if the keycloak was running locally, you need to destroy its database and
|
||||
restart the database and the keycloak containers.
|
||||
@@ -33,3 +33,35 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
OIDC_RS_CLIENT_ID=people
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RS_PRIVATE_KEY_STR="-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
|
||||
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
|
||||
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
|
||||
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
|
||||
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
|
||||
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
|
||||
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
|
||||
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
|
||||
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
|
||||
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
|
||||
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
|
||||
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
|
||||
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
|
||||
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
|
||||
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
|
||||
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
|
||||
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
|
||||
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
|
||||
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
|
||||
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
|
||||
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
|
||||
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
|
||||
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
|
||||
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
|
||||
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
|
||||
MbyqKyC6DAzv4jEEhHaN7oY=
|
||||
-----END PRIVATE KEY-----
|
||||
"
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["node", "node-fetch", "i18next-parser", "eslint"]
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||
|
||||
cat <<'EOF' >$PRE_COMMIT_FILE
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# directories containing potential secrets
|
||||
DIRS="."
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# 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)
|
||||
|
||||
# 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_helm_files(path):
|
||||
sys.stdout.write("Update helm files...\n")
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "tag:" in line:
|
||||
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def update_changelog(path, version):
|
||||
"""Update changelog file with release info
|
||||
"""
|
||||
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 deployment_part(version, kind):
|
||||
""" Update helm file of preprod on deployment repository numerique-gouv/lasuite-deploiement
|
||||
"""
|
||||
# Move to lasuite-deploiement repository
|
||||
try:
|
||||
os.chdir('../lasuite-deploiement')
|
||||
except FileNotFoundError:
|
||||
sys.stdout.write("\033[1;31m[Error] You must have a clone of https://github.com/numerique-gouv/lasuite-deploiement\x1b[0m")
|
||||
sys.stdout.write("\033[0;32mPlease clone it and re-run script with option --only-deployment-part\x1b[0m")
|
||||
sys.stdout.write(" >>> python scripts/release.py --only-deployment-part \x1b[0m")
|
||||
else:
|
||||
run_command("git checkout main", shell=True)
|
||||
run_command("git pull", shell=True)
|
||||
deployment_branch = f"regie/{version}/preprod"
|
||||
run_command(f"git checkout -b {deployment_branch}", shell=True)
|
||||
run_command("git pull --rebase origin main", shell=True)
|
||||
path = f"manifests/regie/env.d/preprod/values.desk.yaml.gotmpl"
|
||||
update_helm_files(path)
|
||||
run_command(f"git add {path}", shell=True)
|
||||
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}"""
|
||||
run_command(f"git commit -m '{message}'", shell=True)
|
||||
confirm = input(f"""\033[0;32m
|
||||
### DEPLOYMENT ###
|
||||
NEXT COMMAND on lasuite-deploiement repository:
|
||||
>> git push origin {deployment_branch}
|
||||
Continue ? (y,n)
|
||||
\x1b[0m""")
|
||||
if confirm == 'y':
|
||||
run_command(f"git push origin {deployment_branch}", shell=True)
|
||||
sys.stdout.write(f"""\033[1;34m
|
||||
PLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Please submit PR {deployment_branch} and merge code to main
|
||||
\x1b[0m""")
|
||||
|
||||
|
||||
def project_part(version, kind):
|
||||
"""Manage only la regie part with update of CHANGELOG, version files and tag"""
|
||||
sys.stdout.write('Let\'s go to create branch to release\n')
|
||||
branch_to_release = f"release/{version}"
|
||||
run_command(f"git checkout -b {branch_to_release}", shell=True)
|
||||
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"""\033[0;32m
|
||||
### RELEASE ###
|
||||
NEXT COMMAND on current repository:
|
||||
>> git push origin {branch_to_release}
|
||||
Continue ? (y,n)
|
||||
\x1b[0m""")
|
||||
if confirm == 'y':
|
||||
run_command(f"git push origin {branch_to_release}", shell=True)
|
||||
sys.stdout.write(f"""
|
||||
\033[1;34mPLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Please submit PR {branch_to_release} and merge code to main
|
||||
--> Then do:
|
||||
>> git checkout main
|
||||
>> git pull
|
||||
>> git tag v{version}
|
||||
>> git push origin v{version}
|
||||
--> Please check and wait for the docker image v{version} to be published here:
|
||||
- https://hub.docker.com/r/lasuite/people-backend/tags
|
||||
- https://hub.docker.com/r/lasuite/people-frontend/tags
|
||||
--> Now please generate release on github interface for the current tag v{version}
|
||||
\x1b[0m""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
version, kind = None, None
|
||||
while not version:
|
||||
version = input("Enter your release version:")
|
||||
while kind not in RELEASE_KINDS:
|
||||
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
|
||||
if "--only-deployment-part" not in sys.argv:
|
||||
project_part(version, kind)
|
||||
deployment_part(version, kind)
|
||||
@@ -0,0 +1,14 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_command(cmd, msg=None, shell=False, cwd='.', stdout=None):
|
||||
if msg is None:
|
||||
msg = f"# Running: {cmd}"
|
||||
if stdout is not None:
|
||||
stdout.write(msg + '\n')
|
||||
if stdout != sys.stdout:
|
||||
sys.stdout(msg)
|
||||
subprocess.check_call(cmd, shell=shell, cwd=cwd, stdout=stdout, stderr=stdout)
|
||||
if stdout is not None:
|
||||
stdout.flush()
|
||||
+1
-1
Submodule secrets updated: d7cfe7bcdc...b7ab5f1411
@@ -363,7 +363,8 @@ name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
# Ignore: private stuff and `Params` class from FactoryBoy
|
||||
no-docstring-rgx=(^_|^Params$)
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Root module."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People custom admin site."""
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Custom Django admin site application configuration."""
|
||||
|
||||
from django.contrib.admin.apps import AdminConfig
|
||||
|
||||
|
||||
class PeopleAdminConfig(AdminConfig):
|
||||
"""Declare our custom Django admin site."""
|
||||
|
||||
default_site = "admin.sites.PeopleAdminSite"
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Custom Django admin site for the People app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
class PeopleAdminSite(admin.AdminSite):
|
||||
"""People custom admin site."""
|
||||
|
||||
def each_context(self, request):
|
||||
"""Add custom context to the admin site."""
|
||||
return super().each_context(request) | {
|
||||
"ADMIN_HEADER_BACKGROUND": settings.ADMIN_HEADER_BACKGROUND,
|
||||
"ADMIN_HEADER_COLOR": settings.ADMIN_HEADER_COLOR,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
|
||||
{% block extrastyle %}{{ block.super }}
|
||||
<style>
|
||||
html[data-theme="light"], :root {
|
||||
{% if ADMIN_HEADER_BACKGROUND %}--header-bg: {{ ADMIN_HEADER_BACKGROUND }};{% endif %}
|
||||
{% if ADMIN_HEADER_COLOR %}--header-color: {{ ADMIN_HEADER_COLOR }};{% endif %}
|
||||
}
|
||||
ul.messagelist li.info {
|
||||
background-color: #EEEEEE;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
"""Core root module."""
|
||||
|
||||
+152
-48
@@ -1,47 +1,19 @@
|
||||
"""Admin classes and registrations for People's core app."""
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from treebeard.admin import TreeAdmin
|
||||
from treebeard.forms import movenodeform_factory
|
||||
|
||||
from mailbox_manager.admin import MailDomainAccessInline
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class IdentityFormSet(forms.BaseInlineFormSet):
|
||||
"""
|
||||
Make the "is_main" field readonly when it is True so that declaring another identity
|
||||
works in the admin.
|
||||
"""
|
||||
|
||||
def add_fields(self, form, index):
|
||||
"""Disable the "is_main" field when it is set to True"""
|
||||
super().add_fields(form, index)
|
||||
is_main_value = form.instance.is_main if form.instance else False
|
||||
form.fields["is_main"].disabled = is_main_value
|
||||
|
||||
|
||||
class IdentityInline(admin.TabularInline):
|
||||
"""Inline admin class for user identities."""
|
||||
|
||||
fields = (
|
||||
"sub",
|
||||
"email",
|
||||
"is_main",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
formset = IdentityFormSet
|
||||
model = models.Identity
|
||||
extra = 0
|
||||
readonly_fields = ("email", "created_at", "sub", "updated_at")
|
||||
|
||||
def has_add_permission(self, request, obj):
|
||||
"""
|
||||
Identities are automatically created on successful OIDC logins.
|
||||
Disable creating identities via the admin.
|
||||
"""
|
||||
return False
|
||||
from .plugins.loader import (
|
||||
get_organization_plugins,
|
||||
organization_plugins_run_after_create,
|
||||
)
|
||||
|
||||
|
||||
class TeamAccessInline(admin.TabularInline):
|
||||
@@ -53,6 +25,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."""
|
||||
|
||||
@@ -66,17 +47,20 @@ class TeamWebhookInline(admin.TabularInline):
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Admin class for the User model"""
|
||||
|
||||
autocomplete_fields = ["organization"]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"sub",
|
||||
"password",
|
||||
"organization",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("admin_email", "language", "timezone")}),
|
||||
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -97,13 +81,14 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": ("admin_email", "password1", "password2"),
|
||||
"fields": ("sub", "email", "password1", "password2"),
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = (IdentityInline, TeamAccessInline)
|
||||
inlines = (TeamAccessInline, MailDomainAccessInline, OrganizationAccessInline)
|
||||
list_display = (
|
||||
"admin_email",
|
||||
"get_user",
|
||||
"organization",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_active",
|
||||
@@ -113,22 +98,59 @@ 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", "admin_email", "identities__sub", "identities__email")
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
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."""
|
||||
if obj:
|
||||
return self.readonly_fields + ["sub"]
|
||||
return self.readonly_fields
|
||||
|
||||
def get_user(self, obj):
|
||||
"""Provide a nice display for user"""
|
||||
return (
|
||||
obj.name if obj.name else (obj.email if obj.email else f"[sub] {obj.sub}")
|
||||
)
|
||||
|
||||
get_user.short_description = _("User")
|
||||
|
||||
|
||||
class TeamServiceProviderInline(admin.TabularInline):
|
||||
"""Inline admin class for service providers."""
|
||||
|
||||
can_delete = False
|
||||
model = models.Team.service_providers.through
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(models.Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
class TeamAdmin(TreeAdmin):
|
||||
"""Team admin interface declaration."""
|
||||
|
||||
inlines = (TeamAccessInline, TeamWebhookInline)
|
||||
form = movenodeform_factory(models.Team)
|
||||
inlines = (TeamAccessInline, TeamWebhookInline, TeamServiceProviderInline)
|
||||
exclude = ("service_providers",) # Handled by the inline
|
||||
list_display = (
|
||||
"name",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
readonly_fields = ("path", "depth", "numchild")
|
||||
|
||||
|
||||
@admin.register(models.TeamAccess)
|
||||
class TeamAccessAdmin(admin.ModelAdmin):
|
||||
"""Team access admin interface declaration."""
|
||||
|
||||
list_display = (
|
||||
"user",
|
||||
"team",
|
||||
"role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Invitation)
|
||||
@@ -155,8 +177,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
|
||||
|
||||
@@ -169,5 +191,87 @@ class InvitationAdmin(admin.ModelAdmin):
|
||||
return super().change_view(request, object_id, extra_context=extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Fill in current logged-in user as issuer."""
|
||||
obj.issuer = request.user
|
||||
obj.save()
|
||||
|
||||
|
||||
@admin.register(models.Contact)
|
||||
class ContactAdmin(admin.ModelAdmin):
|
||||
"""Contact admin interface declaration."""
|
||||
|
||||
list_display = (
|
||||
"full_name",
|
||||
"owner",
|
||||
"override",
|
||||
)
|
||||
|
||||
|
||||
class OrganizationServiceProviderInline(admin.TabularInline):
|
||||
"""Inline admin class for service providers."""
|
||||
|
||||
can_delete = False
|
||||
model = models.Organization.service_providers.through
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.action(description=_("Run post creation plugins"), permissions=["change"])
|
||||
def run_post_creation_plugins(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
"""Run the post creation plugins for the selected organizations."""
|
||||
for organization in queryset:
|
||||
organization_plugins_run_after_create(organization)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_("Post creation plugins have been run for the selected organizations."),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for organizations."""
|
||||
|
||||
actions = [run_post_creation_plugins]
|
||||
list_display = (
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
inlines = (OrganizationAccessInline, OrganizationServiceProviderInline)
|
||||
exclude = ("service_providers",) # Handled by the inline
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Adapt actions list to the context."""
|
||||
actions = super().get_actions(request)
|
||||
if not get_organization_plugins():
|
||||
actions.pop("run_post_creation_plugins", None)
|
||||
return actions
|
||||
|
||||
|
||||
@admin.register(models.OrganizationAccess)
|
||||
class OrganizationAccessAdmin(admin.ModelAdmin):
|
||||
"""Organization access admin interface declaration."""
|
||||
|
||||
autocomplete_fields = ("user", "organization")
|
||||
list_display = (
|
||||
"user",
|
||||
"organization",
|
||||
"role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.ServiceProvider)
|
||||
class ServiceProviderAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for service providers."""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"audience_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name", "audience_id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People core client API endpoints"""
|
||||
@@ -4,28 +4,43 @@ from rest_framework import exceptions, serializers
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models
|
||||
from core.models import ServiceProvider
|
||||
|
||||
|
||||
class ContactSerializer(serializers.ModelSerializer):
|
||||
"""Serialize contacts."""
|
||||
|
||||
abilities = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
fields = [
|
||||
"id",
|
||||
"base",
|
||||
"abilities",
|
||||
"override",
|
||||
"data",
|
||||
"full_name",
|
||||
"notes",
|
||||
"owner",
|
||||
"short_name",
|
||||
]
|
||||
read_only_fields = ["id", "owner"]
|
||||
read_only_fields = ["id", "owner", "abilities"]
|
||||
extra_kwargs = {
|
||||
"override": {"required": False},
|
||||
}
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "base" field readonly but only for update/patch."""
|
||||
validated_data.pop("base", None)
|
||||
"""Make "override" field readonly but only for update/patch."""
|
||||
validated_data.pop("override", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def get_abilities(self, contact) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return contact.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
@@ -34,6 +49,8 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Pass arguments to superclass except 'fields', then drop fields not listed therein."""
|
||||
|
||||
# Don't pass the 'fields' arg up to the superclass
|
||||
fields = kwargs.pop("fields", None)
|
||||
|
||||
@@ -48,12 +65,40 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
self.fields.pop(field_name)
|
||||
|
||||
|
||||
class OrganizationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize organizations."""
|
||||
|
||||
abilities = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
fields = ["id", "name", "registration_id_list", "domain_list", "abilities"]
|
||||
read_only_fields = ["id", "registration_id_list", "domain_list"]
|
||||
|
||||
def get_abilities(self, organization) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return organization.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class UserOrganizationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize organizations for users."""
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
fields = ["id", "name", "registration_id_list"]
|
||||
read_only_fields = ["id", "name", "registration_id_list"]
|
||||
|
||||
|
||||
class UserSerializer(DynamicFieldsModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
|
||||
email = serializers.ReadOnlyField()
|
||||
name = serializers.ReadOnlyField()
|
||||
organization = UserOrganizationSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
@@ -62,6 +107,7 @@ class UserSerializer(DynamicFieldsModelSerializer):
|
||||
"email",
|
||||
"language",
|
||||
"name",
|
||||
"organization",
|
||||
"timezone",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
@@ -69,6 +115,41 @@ class UserSerializer(DynamicFieldsModelSerializer):
|
||||
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
|
||||
|
||||
|
||||
class UserMeSerializer(UserSerializer):
|
||||
"""
|
||||
Serialize the current user.
|
||||
|
||||
Same as the `UserSerializer` but with abilities.
|
||||
"""
|
||||
|
||||
abilities = serializers.SerializerMethodField()
|
||||
organization = UserOrganizationSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"email",
|
||||
"id",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
"language",
|
||||
"name",
|
||||
"organization",
|
||||
"timezone",
|
||||
# added fields
|
||||
"abilities",
|
||||
]
|
||||
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
|
||||
|
||||
def get_abilities(self, user: models.User) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
if user != self.context["request"].user: # Should not happen
|
||||
raise RuntimeError(
|
||||
"UserMeSerializer.get_abilities: user is not the same as the request user",
|
||||
)
|
||||
return user.get_abilities()
|
||||
|
||||
|
||||
class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize team accesses."""
|
||||
|
||||
@@ -170,28 +251,50 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
slug = serializers.SerializerMethodField()
|
||||
service_providers = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ServiceProvider.objects.all(), many=True, required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"accesses",
|
||||
"created_at",
|
||||
"depth",
|
||||
"name",
|
||||
"numchild",
|
||||
"path",
|
||||
"service_providers",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"accesses",
|
||||
"created_at",
|
||||
"depth",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new team with organization enforcement."""
|
||||
# When called as a resource server, we enforce the team service provider
|
||||
if sp_audience := self.context.get("from_service_provider_audience", None):
|
||||
service_provider, _created = models.ServiceProvider.objects.get_or_create(
|
||||
audience_id=sp_audience
|
||||
)
|
||||
validated_data["service_providers"] = [service_provider]
|
||||
|
||||
# Note: this is not the purpose of this API to check the user has an organization
|
||||
return super().create(
|
||||
validated_data=validated_data
|
||||
| {"organization_id": self.context["request"].user.organization_id}
|
||||
)
|
||||
|
||||
def get_abilities(self, team) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
@@ -199,10 +302,6 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
return team.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
def get_slug(self, instance):
|
||||
"""Return slug from the team's name."""
|
||||
return instance.get_slug()
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize invitations."""
|
||||
@@ -237,3 +336,12 @@ class InvitationSerializer(serializers.ModelSerializer):
|
||||
attrs["team_id"] = team_id
|
||||
attrs["issuer"] = user
|
||||
return attrs
|
||||
|
||||
|
||||
class ServiceProviderSerializer(serializers.ModelSerializer):
|
||||
"""Serialize service providers."""
|
||||
|
||||
class Meta:
|
||||
model = models.ServiceProvider
|
||||
fields = ["id", "audience_id", "name"]
|
||||
read_only_fields = ["id", "audience_id"]
|
||||
@@ -1,8 +1,14 @@
|
||||
"""API endpoints"""
|
||||
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
|
||||
import datetime
|
||||
import operator
|
||||
from functools import reduce
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
@@ -12,14 +18,17 @@ from rest_framework import (
|
||||
pagination,
|
||||
response,
|
||||
throttling,
|
||||
views,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from core import models
|
||||
from core.api import permissions
|
||||
from core.api.client import serializers
|
||||
from core.utils.raw_sql import gen_sql_filter_json_array
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
SIMILARITY_THRESHOLD = 0.04
|
||||
from mailbox_manager import models as domains_models
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
@@ -82,16 +91,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):
|
||||
@@ -127,39 +143,46 @@ class ContactViewSet(
|
||||
):
|
||||
"""Contact ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsOwnedOrPublic]
|
||||
queryset = models.Contact.objects.all()
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.Contact.objects.select_related("user").all()
|
||||
serializer_class = serializers.ContactSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
ordering_fields = ["full_name", "short_name", "created_at"]
|
||||
ordering = ["full_name"]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Limit listed users by a query with throttle protection."""
|
||||
user = self.request.user
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Exclude contacts that:
|
||||
# List only contacts that:
|
||||
queryset = queryset.filter(
|
||||
# - belong to another user (keep public and owned contacts)
|
||||
Q(owner__isnull=True) | Q(owner=user),
|
||||
# - are profile contacts for a user
|
||||
user__isnull=True,
|
||||
# - are overriden base contacts
|
||||
overriding_contacts__isnull=True,
|
||||
# - is public (owner is None)
|
||||
Q(owner__isnull=True)
|
||||
# - are owned by the user
|
||||
| Q(owner=user)
|
||||
# - are profile contacts for a user from the same organization
|
||||
| Q(user__organization_id=user.organization_id),
|
||||
# - are not overriden by another contact
|
||||
overridden_by__isnull=True,
|
||||
)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive trigram similarity
|
||||
# Search by case-insensitive and accent-insensitive on:
|
||||
# - full name
|
||||
# - short name
|
||||
# - email (from data `emails` field)
|
||||
if query := self.request.GET.get("q", ""):
|
||||
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)
|
||||
| Q(
|
||||
id__in=gen_sql_filter_json_array(
|
||||
queryset.model,
|
||||
"data->'emails'",
|
||||
"value",
|
||||
query,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
@@ -172,21 +195,64 @@ class ContactViewSet(
|
||||
return super().perform_create(serializer)
|
||||
|
||||
|
||||
class OrganizationViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
Organization ViewSet
|
||||
|
||||
GET /api/organizations/<organization_id>/
|
||||
Return the organization details for the given id.
|
||||
|
||||
PUT /api/organizations/<organization_id>/
|
||||
Update the organization details for the given id.
|
||||
|
||||
PATCH /api/organizations/<organization_id>/
|
||||
Partially update the organization details for the given id.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.Organization.objects.all()
|
||||
serializer_class = serializers.OrganizationSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Limit listed organizations to the one the user belongs to."""
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(pk=self.request.user.organization_id)
|
||||
.annotate(
|
||||
user_role=Subquery(
|
||||
models.OrganizationAccess.objects.filter(
|
||||
user=self.request.user, organization=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
|
||||
SerializerPerActionMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
):
|
||||
"""
|
||||
User viewset for all interactions with user infos and teams.
|
||||
|
||||
GET /api/users/&q=query
|
||||
Return a list of users whose email matches the query. Similarity is
|
||||
calculated using trigram similarity, allowing for partial,
|
||||
case-insensitive matches and accented queries.
|
||||
Return a list of users whose email or name matches the query.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all().order_by("-created_at")
|
||||
queryset = (
|
||||
models.User.objects.select_related("organization").all().order_by("-created_at")
|
||||
)
|
||||
serializer_class = serializers.UserSerializer
|
||||
get_me_serializer_class = serializers.UserMeSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
|
||||
@@ -198,38 +264,17 @@ class UserViewSet(
|
||||
# Exclude inactive contacts
|
||||
queryset = queryset.filter(
|
||||
is_active=True,
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
"identities",
|
||||
queryset=models.Identity.objects.filter(is_main=True),
|
||||
to_attr="_identities_main",
|
||||
)
|
||||
)
|
||||
|
||||
# Exclude all users already in the given team
|
||||
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("identities__email", function="unaccent"), Value("")
|
||||
),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
+ TrigramSimilarity(
|
||||
Coalesce(
|
||||
Func("identities__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
|
||||
@@ -245,9 +290,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(
|
||||
@@ -260,20 +303,57 @@ class TeamViewSet(
|
||||
):
|
||||
"""Team ViewSet"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
permission_classes = [permissions.TeamPermission, permissions.AccessPermission]
|
||||
serializer_class = serializers.TeamSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering_fields = ["created_at", "name", "path"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related teams."""
|
||||
teams_queryset = models.Team.objects.filter(
|
||||
accesses__user=self.request.user,
|
||||
)
|
||||
depth_path = teams_queryset.values("depth", "path")
|
||||
|
||||
if not depth_path:
|
||||
return models.Team.objects.none()
|
||||
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
return models.Team.objects.filter(accesses__user=self.request.user).annotate(
|
||||
user_role=Subquery(user_role_query)
|
||||
|
||||
return (
|
||||
models.Team.objects.prefetch_related("accesses", "service_providers")
|
||||
.filter(
|
||||
reduce(
|
||||
operator.or_,
|
||||
(
|
||||
Q(
|
||||
# The team the user has access to
|
||||
depth=d["depth"],
|
||||
path=d["path"],
|
||||
)
|
||||
| Q(
|
||||
# The parent team the user has access to
|
||||
depth__lt=d["depth"],
|
||||
path__startswith=d["path"][: models.Team.steplen],
|
||||
organization_id=self.request.user.organization_id,
|
||||
)
|
||||
for d in depth_path
|
||||
),
|
||||
),
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role for the team
|
||||
# and if the user does not have access, it's ok to consider them as a member
|
||||
# because it's a parent team.
|
||||
.annotate(
|
||||
user_role=Coalesce(
|
||||
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -329,7 +409,7 @@ class TeamAccessViewSet(
|
||||
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering = ["role"]
|
||||
ordering_fields = ["role", "email", "name"]
|
||||
ordering_fields = ["role", "user__email", "user__name"]
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list team accesses"""
|
||||
@@ -347,6 +427,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
|
||||
@@ -357,34 +438,28 @@ class TeamAccessViewSet(
|
||||
queryset = queryset.filter(team=self.kwargs["team_id"])
|
||||
|
||||
if self.action in {"list", "retrieve"}:
|
||||
if query := self.request.GET.get("q", ""):
|
||||
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
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=self.kwargs["team_id"]
|
||||
).values("role")[:1]
|
||||
|
||||
user_main_identity_query = models.Identity.objects.filter(
|
||||
user=OuterRef("user_id"), is_main=True
|
||||
)
|
||||
|
||||
queryset = (
|
||||
# The logged-in user should be part of a team to see its accesses
|
||||
queryset.filter(
|
||||
team__accesses__user=self.request.user,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"user__identities",
|
||||
queryset=models.Identity.objects.filter(is_main=True),
|
||||
to_attr="_identities_main",
|
||||
)
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role and
|
||||
# the user role on each team access
|
||||
.annotate(
|
||||
user_role=Subquery(user_role_query),
|
||||
email=Subquery(user_main_identity_query.values("email")[:1]),
|
||||
name=Subquery(user_main_identity_query.values("name")[:1]),
|
||||
)
|
||||
.select_related("user")
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
@@ -496,3 +571,91 @@ class InvitationViewset(
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
class ConfigView(views.APIView):
|
||||
"""API ViewSet for sharing some public settings."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/config/
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = ["LANGUAGES", "FEATURES", "RELEASE", "COMMIT"]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
return response.Response(dict_settings)
|
||||
|
||||
|
||||
class StatView(views.APIView):
|
||||
"""API ViewSet for sharing some public metrics."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@method_decorator(cache_page(3600))
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/stats/
|
||||
Return a dictionary of public metrics.
|
||||
"""
|
||||
context = {
|
||||
"total_users": models.User.objects.count(),
|
||||
"mau": models.User.objects.filter(
|
||||
last_login__gte=datetime.datetime.now() - datetime.timedelta(30)
|
||||
).count(),
|
||||
"teams": models.Team.objects.count(),
|
||||
"domains": domains_models.MailDomain.objects.count(),
|
||||
"mailboxes": domains_models.Mailbox.objects.count(),
|
||||
}
|
||||
return response.Response(context)
|
||||
|
||||
|
||||
class ServiceProviderFilter(filters.BaseFilterBackend):
|
||||
"""
|
||||
Filter service providers.
|
||||
"""
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
"""
|
||||
Filter service providers by audience or name.
|
||||
"""
|
||||
if name := request.GET.get("name"):
|
||||
queryset = queryset.filter(name__icontains=name)
|
||||
if audience_id := request.GET.get("audience_id"):
|
||||
queryset = queryset.filter(audience_id=audience_id)
|
||||
return queryset
|
||||
|
||||
|
||||
class ServiceProviderViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with service providers.
|
||||
|
||||
GET /api/v1.0/service-providers/
|
||||
Return a list of service providers.
|
||||
|
||||
GET /api/v1.0/service-providers/<service_provider_id>/
|
||||
Return a service provider.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
queryset = models.ServiceProvider.objects.all()
|
||||
serializer_class = serializers.ServiceProviderSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
filter_backends = [filters.OrderingFilter, ServiceProviderFilter]
|
||||
ordering = ["name"]
|
||||
ordering_fields = ["name", "created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter the queryset to limit results to user's organization."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(organizations__id=self.request.user.organization_id)
|
||||
return queryset
|
||||
@@ -12,13 +12,13 @@ class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check auth token first."""
|
||||
return bool(request.auth) if request.auth else request.user.is_authenticated
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
of the auth token to avoid hitting the database.
|
||||
Allows access only to user's own data.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
@@ -53,3 +53,18 @@ class AccessPermission(IsAuthenticated):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
|
||||
class TeamPermission(IsAuthenticated):
|
||||
"""Permission class for team objects viewset."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check permission only when the user tries to create a new team."""
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
if request.method != "POST":
|
||||
return True
|
||||
|
||||
abilities = request.user.get_abilities()
|
||||
return abilities["teams"]["can_create"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People core resource server API endpoints"""
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Client serializers for the People core app resource server API."""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"depth",
|
||||
"name",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"depth",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Create a new team with organization enforcement.
|
||||
|
||||
In this context, called as a resource server,
|
||||
the team service provider is enforced.
|
||||
|
||||
When the service provider audience is unknown it is created on the fly.
|
||||
"""
|
||||
sp_audience = self.context["from_service_provider_audience"]
|
||||
service_provider, _created = models.ServiceProvider.objects.get_or_create(
|
||||
audience_id=sp_audience
|
||||
)
|
||||
|
||||
# Note: this is not the purpose of this API to check the user has an organization
|
||||
return super().create(
|
||||
validated_data=validated_data
|
||||
| {
|
||||
"organization_id": self.context["request"].user.organization_id,
|
||||
"service_providers": [service_provider],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Resource server API endpoints"""
|
||||
|
||||
import operator
|
||||
from functools import reduce
|
||||
|
||||
from django.db.models import OuterRef, Prefetch, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from rest_framework import (
|
||||
filters,
|
||||
mixins,
|
||||
viewsets,
|
||||
)
|
||||
|
||||
from core import models
|
||||
from core.api import permissions
|
||||
from core.api.client.viewsets import Pagination
|
||||
from core.resource_server.mixins import ResourceServerMixin
|
||||
|
||||
from . import serializers
|
||||
|
||||
|
||||
class TeamViewSet( # pylint: disable=too-many-ancestors
|
||||
ResourceServerMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
Team ViewSet dedicated to the resource server.
|
||||
|
||||
The DELETE method is not allowed for now, because the use case is
|
||||
not clear yet and it comes with complexity to know if we can delete
|
||||
a team or not (eg. if a team has other SP, it might not be deleted
|
||||
but what do we do then, only remove the current SP?).
|
||||
|
||||
GET /resource-server/v1.0/teams/
|
||||
Return list of Teams of the user and available for the audience.
|
||||
|
||||
POST /resource-server/v1.0/teams/
|
||||
Create a new Team for the user for the audience.
|
||||
|
||||
GET /resource-server/v1.0/teams/{team_id}/
|
||||
Return the Team details if available for the audience.
|
||||
|
||||
PUT /resource-server/v1.0/teams/{team_id}/
|
||||
Update the Team details (only name for now).
|
||||
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
serializer_class = serializers.TeamSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
pagination_class = Pagination
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related teams."""
|
||||
teams_queryset = models.Team.objects.filter(
|
||||
accesses__user=self.request.user,
|
||||
)
|
||||
depth_path = teams_queryset.values("depth", "path")
|
||||
|
||||
if not depth_path:
|
||||
return models.Team.objects.none()
|
||||
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
|
||||
service_provider_audience = self._get_service_provider_audience()
|
||||
service_provider_prefetch = Prefetch(
|
||||
"service_providers",
|
||||
queryset=models.ServiceProvider.objects.filter(
|
||||
audience_id=service_provider_audience
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
models.Team.objects.prefetch_related(
|
||||
"accesses",
|
||||
service_provider_prefetch,
|
||||
)
|
||||
.filter(
|
||||
reduce(
|
||||
operator.or_,
|
||||
(
|
||||
Q(
|
||||
# The team the user has access to
|
||||
depth=d["depth"],
|
||||
path=d["path"],
|
||||
)
|
||||
| Q(
|
||||
# The parent team the user has access to
|
||||
depth__lt=d["depth"],
|
||||
path__startswith=d["path"][: models.Team.steplen],
|
||||
organization_id=self.request.user.organization_id,
|
||||
)
|
||||
for d in depth_path
|
||||
),
|
||||
),
|
||||
service_providers__audience_id=service_provider_audience,
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role for the team
|
||||
# and if the user does not have access, it's ok to consider them as a member
|
||||
# because it's a parent team.
|
||||
.annotate(
|
||||
user_role=Coalesce(
|
||||
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created team."""
|
||||
team = serializer.save()
|
||||
models.TeamAccess.objects.create(
|
||||
team=team,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Authentication module."""
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""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.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
@@ -10,14 +14,31 @@ from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import Identity
|
||||
from core.models import (
|
||||
Contact,
|
||||
Organization,
|
||||
OrganizationAccess,
|
||||
OrganizationRoleChoices,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def get_domain_from_email(email: Optional[str]) -> Optional[str]:
|
||||
"""Extract domain from email."""
|
||||
try:
|
||||
return Address(addr_spec=email).domain
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
in the User model, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
@@ -51,7 +72,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
"""Return a User based on userinfo. Create a new user if no match is found.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
@@ -67,42 +88,69 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
# Compute user name from OIDC name fields as defined in settings
|
||||
names_list = [
|
||||
user_info[field]
|
||||
for field in settings.USER_OIDC_FIELDS_TO_NAME
|
||||
if user_info.get(field)
|
||||
]
|
||||
user_info["name"] = " ".join(names_list) or None
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if sub is None:
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
user = (
|
||||
self.UserModel.objects.filter(identities__sub=sub)
|
||||
.annotate(
|
||||
identity_email=models.F("identities__email"),
|
||||
identity_name=models.F("identities__name"),
|
||||
)
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
if user:
|
||||
email = user_info.get("email")
|
||||
name = user_info.get("name")
|
||||
if (
|
||||
email
|
||||
and email != user.identity_email
|
||||
or name
|
||||
and name != user.identity_name
|
||||
):
|
||||
Identity.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(user_info)
|
||||
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
|
||||
|
||||
@@ -113,10 +161,88 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable user identification")
|
||||
)
|
||||
email = claims.get("email")
|
||||
name = claims.get("name")
|
||||
|
||||
user = self.UserModel.objects.create(password="!") # noqa: S106
|
||||
Identity.objects.create(
|
||||
user=user, sub=sub, email=claims.get("email"), name=claims.get("name")
|
||||
# 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=email,
|
||||
name=name,
|
||||
)
|
||||
if organization_created:
|
||||
# Warning: we may remove this behavior in the near future when we
|
||||
# add a feature to claim the organization ownership.
|
||||
OrganizationAccess.objects.create(
|
||||
organization=organization,
|
||||
user=user,
|
||||
role=OrganizationRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
# Initiate the user's profile
|
||||
Contact.objects.create(
|
||||
owner=user,
|
||||
user=user,
|
||||
full_name=name or email,
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": email},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def compute_full_name(self, user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
name_fields = settings.USER_OIDC_FIELDS_TO_NAME
|
||||
full_name = " ".join(
|
||||
user_info[field] for field in name_fields if user_info.get(field)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
updated_claims = {}
|
||||
for key in ["email", "name"]:
|
||||
claim_value = claims.get(key)
|
||||
if claim_value and claim_value != getattr(user, key):
|
||||
updated_claims[key] = claim_value
|
||||
|
||||
if updated_claims:
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -115,8 +115,62 @@ class ContactFactory(BaseContactFactory):
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
|
||||
base = factory.SubFactory("core.factories.ContactFactory", base=None, owner=None)
|
||||
owner = factory.SubFactory("core.factories.UserFactory", profile_contact=None)
|
||||
owner = factory.SubFactory("core.factories.UserFactory")
|
||||
|
||||
|
||||
class ProfileContactFactory(BaseContactFactory):
|
||||
"""A factory to create profile contacts for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
|
||||
owner = factory.SelfAttribute(".user")
|
||||
user = factory.SubFactory("core.factories.UserFactory")
|
||||
|
||||
|
||||
class OverrideContactFactory(BaseContactFactory):
|
||||
"""A factory to create contacts for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
|
||||
override = factory.SubFactory("core.factories.ContactFactory", owner=None)
|
||||
owner = factory.SubFactory("core.factories.UserFactory")
|
||||
|
||||
|
||||
class OrganizationFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory to create organizations for testing purposes."""
|
||||
|
||||
name = factory.Faker("company")
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
|
||||
class Params: # pylint: disable=missing-class-docstring
|
||||
with_registration_id = factory.Trait(
|
||||
registration_id_list=factory.List(
|
||||
[factory.Sequence(lambda n: f"{n:014d}")]
|
||||
),
|
||||
)
|
||||
with_domain = factory.Trait(
|
||||
domain_list=factory.List([factory.Faker("domain_name")]),
|
||||
)
|
||||
|
||||
|
||||
class OrganizationAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory to create organization accesses for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.OrganizationAccess
|
||||
|
||||
user = factory.SubFactory(
|
||||
"core.factories.UserFactory",
|
||||
organization=factory.SelfAttribute("..organization"),
|
||||
)
|
||||
organization = factory.SubFactory(
|
||||
"core.factories.OrganizationFactory", with_registration_id=True
|
||||
)
|
||||
role = factory.fuzzy.FuzzyChoice(models.OrganizationRoleChoices.values)
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
@@ -124,24 +178,20 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
django_get_or_create = ("admin_email",)
|
||||
|
||||
admin_email = factory.Faker("email")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
|
||||
|
||||
class IdentityFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create identities for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Identity
|
||||
django_get_or_create = ("sub",)
|
||||
|
||||
user = factory.SubFactory(UserFactory)
|
||||
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")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
|
||||
|
||||
class TeamFactory(factory.django.DjangoModelFactory):
|
||||
@@ -149,7 +199,6 @@ class TeamFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
django_get_or_create = ("name",)
|
||||
skip_postgeneration_save = True
|
||||
|
||||
name = factory.Sequence(lambda n: f"team{n}")
|
||||
@@ -165,6 +214,13 @@ class TeamFactory(factory.django.DjangoModelFactory):
|
||||
else:
|
||||
TeamAccessFactory(team=self, user=user_entry[0], role=user_entry[1])
|
||||
|
||||
@factory.post_generation
|
||||
def service_providers(self, create, extracted, **kwargs):
|
||||
"""Add service providers to team from a given list of service providers."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.service_providers.set(extracted)
|
||||
|
||||
|
||||
class TeamAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake team user accesses for testing."""
|
||||
@@ -197,3 +253,27 @@ class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
email = factory.Faker("email")
|
||||
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
||||
issuer = factory.SubFactory(UserFactory)
|
||||
|
||||
|
||||
class ServiceProviderFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create service providers for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.ServiceProvider
|
||||
skip_postgeneration_save = True
|
||||
|
||||
audience_id = factory.Faker("uuid4")
|
||||
|
||||
@factory.post_generation
|
||||
def teams(self, create, extracted, **kwargs):
|
||||
"""Add teams to service provider from a given list."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.teams.set(extracted)
|
||||
|
||||
@factory.post_generation
|
||||
def organizations(self, create, extracted, **kwargs):
|
||||
"""Add organization to service provider from a given list."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.organizations.set(extracted)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.3 on 2024-03-25 22:58
|
||||
# Generated by Django 5.0.3 on 2024-06-08 09:25
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.core.validators
|
||||
@@ -45,8 +45,10 @@ class Migration(migrations.Migration):
|
||||
('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')),
|
||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
|
||||
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
|
||||
('language', models.CharField(choices=settings.LANGUAGES, default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
@@ -87,25 +89,6 @@ class Migration(migrations.Migration):
|
||||
name='profile_contact',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='core.contact'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Identity',
|
||||
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')),
|
||||
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
|
||||
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
|
||||
('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'identity',
|
||||
'verbose_name_plural': 'identities',
|
||||
'db_table': 'people_identity',
|
||||
'ordering': ('-is_main', 'email'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invitation',
|
||||
fields=[
|
||||
@@ -163,20 +146,16 @@ 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',
|
||||
unique_together={('owner', 'base')},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='identity',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'email'), name='unique_user_email', violation_error_message='This email address is already declared for this user.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='invitation',
|
||||
constraint=models.UniqueConstraint(fields=('email', 'team'), name='email_and_team_unique_together'),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-22 10:07
|
||||
|
||||
import core.models
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Organization',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('name', models.CharField(max_length=100, verbose_name='name')),
|
||||
('registration_id_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=128), blank=True, default=list, size=None, verbose_name='registration ID list')),
|
||||
('domain_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=256), blank=True, default=list, size=None, verbose_name='domain list')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'organization',
|
||||
'verbose_name_plural': 'organizations',
|
||||
'db_table': 'people_organization',
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('registration_id_list__len__gt', 0), ('domain_list__len__gt', 0), _connector='OR'), name='registration_id_or_domain', violation_error_message='An organization must have at least a registration ID or a domain.')],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='organization',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='teams', to='core.organization'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='organization',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='core.organization'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganizationAccess',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('role', models.CharField(choices=[('administrator', 'Administrator')], default='administrator', max_length=20)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_accesses', to='core.organization')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_accesses', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organization/user relation',
|
||||
'verbose_name_plural': 'Organization/user relations',
|
||||
'db_table': 'people_organization_access',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'organization'), name='unique_organization_user', violation_error_message='This user is already in this organization.')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-04 14:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_add_organization_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-20 10:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_team_slug_nullable'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='team',
|
||||
name='slug',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-07 16:24
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_remove_team_slug'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ServiceProvider',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('name', models.CharField(max_length=256, unique=True, verbose_name='name')),
|
||||
('audience_id', models.CharField(db_index=True, max_length=256, unique=True, verbose_name='audience id')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'service provider',
|
||||
'verbose_name_plural': 'service providers',
|
||||
'db_table': 'people_service_provider',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='service_providers',
|
||||
field=models.ManyToManyField(blank=True, related_name='organizations', to='core.serviceprovider'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='service_providers',
|
||||
field=models.ManyToManyField(blank=True, related_name='teams', to='core.serviceprovider'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-28 09:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_add_serviceprovider'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, default='', verbose_name='notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contact',
|
||||
name='full_name',
|
||||
field=models.CharField(default='-', max_length=150, verbose_name='full name'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 10:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_contact_notes_alter_contact_full_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='contact',
|
||||
name='base_owner_constraint',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='contact',
|
||||
name='base_not_self',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='override',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='overridden_by', to='core.contact'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('override__isnull', False), ('owner__isnull', True), _negated=True), name='override_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('override', models.F('id')), _negated=True), name='override_not_self', violation_error_message='A contact cannot override itself.'),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contact',
|
||||
name='base',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
unique_together={('owner', 'override')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 10:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def init_profile_contact(apps, schema_editor):
|
||||
"""Create a Contact for each User to be used as their profile."""
|
||||
User = apps.get_model("core", "User")
|
||||
Contact = apps.get_model("core", "Contact")
|
||||
|
||||
for user in User.objects.all(): # This is a small table for now
|
||||
Contact.objects.create(
|
||||
owner=user,
|
||||
user=user,
|
||||
full_name=user.name or user.email,
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": user.email},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_rename_contact_base_to_override'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='profile_contact',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='user',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='profile_contact', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('user__isnull', True), models.Q(('owner', models.F('user')), ('owner__isnull', False)), _connector='OR'), name='profile_contact_owner_constraint', violation_error_message='Users can only declare as profile a contact they own.'),
|
||||
),
|
||||
migrations.RunPython(init_profile_contact, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-05 13:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_change_user_profile_to_contact'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# From 100ms to 78ms for 5 000 contacts on my computer
|
||||
# This is not a big improvement and may need further investigation
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX json_field_index_emails_value ON people_contact USING GIN ((data->'emails'->'value') jsonb_path_ops);"
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-26 13:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from treebeard.numconv import NumConv
|
||||
|
||||
|
||||
def update_team_paths(apps, schema_editor):
|
||||
Team = apps.get_model("core", "Team")
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
steplen = 5
|
||||
|
||||
# Initialize NumConv with the specified custom alphabet
|
||||
converter = NumConv(len(alphabet), alphabet)
|
||||
|
||||
nodes = Team.objects.all().order_by("created_at")
|
||||
for i, node in enumerate(nodes, 1):
|
||||
node.depth = 1 # root nodes are at depth 1
|
||||
|
||||
# Use NumConv to encode the index `i` to a base representation and
|
||||
# pad it to the specified step length using the custom alphabet
|
||||
node.path = converter.int2str(i).rjust(steplen, alphabet[0])
|
||||
|
||||
if nodes:
|
||||
Team.objects.bulk_update(nodes, ["depth", "path"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_contacts_add_index_on_data_emails'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='depth',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='numchild',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='path',
|
||||
field=models.CharField(default='', max_length=400, db_collation="C"),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(update_team_paths, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name="team",
|
||||
name="path",
|
||||
field=models.CharField(unique=True, max_length=400, db_collation="C"),
|
||||
),
|
||||
]
|
||||
+469
-166
@@ -6,27 +6,32 @@ 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, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import override
|
||||
|
||||
import jsonschema
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
|
||||
from core.enums import WebhookStatusChoices
|
||||
from core.plugins.loader import organization_plugins_run_after_create
|
||||
from core.utils.webhooks import scim_synchronizer
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -36,7 +41,7 @@ with open(contact_schema_path, "r", encoding="utf-8") as contact_schema_file:
|
||||
contact_schema = json.load(contact_schema_file)
|
||||
|
||||
|
||||
class RoleChoices(models.TextChoices):
|
||||
class RoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
|
||||
"""Defines the possible roles a user can have in a team."""
|
||||
|
||||
MEMBER = "member", _("Member")
|
||||
@@ -44,6 +49,17 @@ class RoleChoices(models.TextChoices):
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
|
||||
class OrganizationRoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
|
||||
"""
|
||||
Defines the possible roles a user can have in an organization.
|
||||
For now, we only have one role, but we might add more in the future.
|
||||
|
||||
administrator: The user can manage the organization: change name, add/remove users.
|
||||
"""
|
||||
|
||||
ADMIN = "administrator", _("Administrator")
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -84,10 +100,10 @@ class BaseModel(models.Model):
|
||||
class Contact(BaseModel):
|
||||
"""User contacts"""
|
||||
|
||||
base = models.ForeignKey(
|
||||
override = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="overriding_contacts",
|
||||
related_name="overridden_by",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
@@ -98,11 +114,19 @@ class Contact(BaseModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
full_name = models.CharField(_("full name"), max_length=150, null=True, blank=True)
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="profile_contact",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
full_name = models.CharField(_("full name"), max_length=150)
|
||||
short_name = models.CharField(_("short name"), max_length=30, null=True, blank=True)
|
||||
|
||||
# avatar =
|
||||
# notes =
|
||||
notes = models.TextField(_("notes"), blank=True, default="")
|
||||
data = models.JSONField(
|
||||
_("contact information"),
|
||||
help_text=_("A JSON object containing the contact information"),
|
||||
@@ -121,31 +145,38 @@ class Contact(BaseModel):
|
||||
ordering = ("full_name", "short_name")
|
||||
verbose_name = _("contact")
|
||||
verbose_name_plural = _("contacts")
|
||||
unique_together = ("owner", "base")
|
||||
unique_together = ("owner", "override")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=~models.Q(base__isnull=False, owner__isnull=True),
|
||||
name="base_owner_constraint",
|
||||
condition=~models.Q(override__isnull=False, owner__isnull=True),
|
||||
name="override_owner_constraint",
|
||||
violation_error_message="A contact overriding a base contact must be owned.",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=~models.Q(base=models.F("id")),
|
||||
name="base_not_self",
|
||||
violation_error_message="A contact cannot be based on itself.",
|
||||
condition=~models.Q(override=models.F("id")),
|
||||
name="override_not_self",
|
||||
violation_error_message="A contact cannot override itself.",
|
||||
),
|
||||
# When a user is set, the owner must be the user
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(user__isnull=True)
|
||||
| models.Q(owner=models.F("user"), owner__isnull=False),
|
||||
name="profile_contact_owner_constraint",
|
||||
violation_error_message="Users can only declare as profile a contact they own.",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name or self.short_name
|
||||
return self.full_name
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
# Check if the contact points to a base contact that itself points to another base contact
|
||||
if self.base_id and self.base.base_id:
|
||||
# Check if the contact overrides a contact that itself overrides another base contact
|
||||
if self.override_id and self.override.override_id:
|
||||
raise exceptions.ValidationError(
|
||||
"A contact cannot point to a base contact that itself points to a base contact."
|
||||
"A contact cannot override a contact that itself overrides a contact."
|
||||
)
|
||||
|
||||
# Validate the content of the "data" field against our jsonschema definition
|
||||
@@ -157,23 +188,260 @@ class Contact(BaseModel):
|
||||
error_message = f"Validation error in '{field_path:s}': {e.message}"
|
||||
raise exceptions.ValidationError({"data": [error_message]}) from e
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the contact.
|
||||
|
||||
Beware that the model allows owner to be None, we are still not
|
||||
sure about the use case for this and the API does not allow this.
|
||||
For now, we still consider here, a contact without owner is "public"
|
||||
so we allow access to it.
|
||||
"""
|
||||
is_owner = user.pk == self.owner_id
|
||||
is_profile_member_or_same_organization = bool(self.user_id) and (
|
||||
self.user.organization_id == user.organization_id
|
||||
)
|
||||
|
||||
return {
|
||||
"get": is_owner or is_profile_member_or_same_organization or not self.owner,
|
||||
"patch": is_owner,
|
||||
"put": is_owner,
|
||||
"delete": is_owner and not self.user, # Can't delete a profile contact
|
||||
}
|
||||
|
||||
|
||||
class ServiceProvider(BaseModel):
|
||||
"""
|
||||
Represents a service provider that will consume our information.
|
||||
|
||||
Organization uses this model to define the list of SP available to their users.
|
||||
Team uses this model to define their visibility to the various SP.
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=256, unique=True)
|
||||
audience_id = models.CharField(
|
||||
_("audience id"), max_length=256, unique=True, db_index=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_service_provider"
|
||||
verbose_name = _("service provider")
|
||||
verbose_name_plural = _("service providers")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Enforce name (even if ugly) from the `audience_id` field."""
|
||||
if not self.name:
|
||||
self.name = self.audience_id # ok, same length
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class OrganizationManager(models.Manager):
|
||||
"""
|
||||
Custom manager for the Organization model, to manage complexity/automation.
|
||||
"""
|
||||
|
||||
def get_or_create_from_user_claims(
|
||||
self, registration_id: str = None, domain: str = None, **kwargs
|
||||
) -> Tuple["Organization", bool]:
|
||||
"""
|
||||
Get or create an organization using the most fitting information from the user's claims.
|
||||
|
||||
We expect to have only one organization per registration_id, but
|
||||
the registration_id might not be provided.
|
||||
When the registration_id is not provided, we use the domain to identify the organization.
|
||||
|
||||
If both are provided, we use the registration_id first to create missing organization.
|
||||
|
||||
Dev note: When a registration_id is provided by the Identity Provider, we don't want
|
||||
to use the domain to create the organization, because it is less reliable: for example,
|
||||
a professional user, may have a personal email address, and the domain would be gmail.com
|
||||
which is not a good identifier for an organization. The domain email is just a fallback
|
||||
when the registration_id is not provided by the Identity Provider. We can use the domain
|
||||
to create the organization manually when we are sure about the "safety" of it.
|
||||
"""
|
||||
if not any([registration_id, domain]):
|
||||
raise ValueError("You must provide either a registration_id or a domain.")
|
||||
|
||||
filters = models.Q()
|
||||
if registration_id:
|
||||
filters |= models.Q(registration_id_list__icontains=registration_id)
|
||||
if domain:
|
||||
filters |= models.Q(domain_list__icontains=domain)
|
||||
|
||||
with suppress(self.model.DoesNotExist):
|
||||
# If there are several organizations, we must raise an error and fix the data
|
||||
# If there is an organization, we return it
|
||||
return self.get(filters, **kwargs), False
|
||||
|
||||
# Manage the case where the organization does not exist: we create one
|
||||
if registration_id:
|
||||
return self.create(
|
||||
name=registration_id, registration_id_list=[registration_id], **kwargs
|
||||
), True
|
||||
|
||||
if domain:
|
||||
return self.create(name=domain, domain_list=[domain], **kwargs), True
|
||||
|
||||
raise ValueError("Should never reach this point.")
|
||||
|
||||
def create(self, **kwargs):
|
||||
"""
|
||||
Create an organization with the given kwargs.
|
||||
|
||||
This method is overridden to call the Organization plugins.
|
||||
"""
|
||||
instance = super().create(**kwargs)
|
||||
organization_plugins_run_after_create(instance)
|
||||
return instance
|
||||
|
||||
|
||||
class Organization(BaseModel):
|
||||
"""
|
||||
Organization model used to regroup Teams.
|
||||
|
||||
Each User have an Organization, which corresponds actually to a default organization
|
||||
because a user can belong to a Team from another organization.
|
||||
Each Team have an Organization, which is the Organization from the User who created
|
||||
the Team.
|
||||
|
||||
Organization is managed automatically, the User should never choose their Organization.
|
||||
When creating a User, you must use the `get_or_create` method from the
|
||||
OrganizationManager to find the proper Organization.
|
||||
|
||||
An Organization can have several registration IDs and domains but during automatic
|
||||
creation process, only one will be used. We may want to allow (manual) organization merge
|
||||
later, to regroup several registration IDs or domain in the same Organization.
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=100)
|
||||
registration_id_list = ArrayField(
|
||||
models.CharField(
|
||||
max_length=128,
|
||||
validators=get_field_validators_from_setting(
|
||||
"ORGANIZATION_REGISTRATION_ID_VALIDATORS"
|
||||
),
|
||||
),
|
||||
verbose_name=_("registration ID list"),
|
||||
default=list,
|
||||
blank=True,
|
||||
# list overlap validation is done in the validate_unique method
|
||||
)
|
||||
domain_list = ArrayField(
|
||||
models.CharField(max_length=256),
|
||||
verbose_name=_("domain list"),
|
||||
default=list,
|
||||
blank=True,
|
||||
# list overlap validation is done in the validate_unique method
|
||||
)
|
||||
|
||||
service_providers = models.ManyToManyField(
|
||||
ServiceProvider,
|
||||
related_name="organizations",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = OrganizationManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "people_organization"
|
||||
verbose_name = _("organization")
|
||||
verbose_name_plural = _("organizations")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
name="registration_id_or_domain",
|
||||
condition=models.Q(registration_id_list__len__gt=0)
|
||||
| models.Q(domain_list__len__gt=0),
|
||||
violation_error_message=_(
|
||||
"An organization must have at least a registration ID or a domain."
|
||||
),
|
||||
),
|
||||
# Check a registration ID str can only be present in one
|
||||
# organization registration ID list
|
||||
# Check a domain str can only be present in one organization domain list
|
||||
# Those checks cannot be done with Django constraints
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} (# {self.pk})"
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""
|
||||
Validate Registration/Domain values in an array field are unique
|
||||
across all instances.
|
||||
|
||||
This can't be done in the field validator because we need to
|
||||
exclude the current object if already in database.
|
||||
"""
|
||||
super().validate_unique(exclude=exclude)
|
||||
|
||||
if self.pk:
|
||||
organization_qs = Organization.objects.exclude(pk=self.pk)
|
||||
else:
|
||||
organization_qs = Organization.objects.all()
|
||||
|
||||
# Check a registration ID can only be present in one organization
|
||||
if (
|
||||
self.registration_id_list
|
||||
and organization_qs.filter(
|
||||
registration_id_list__overlap=self.registration_id_list
|
||||
).exists()
|
||||
):
|
||||
raise ValidationError(
|
||||
"registration_id_list value must be unique across all instances."
|
||||
)
|
||||
|
||||
# Check a domain can only be present in one organization
|
||||
if (
|
||||
self.domain_list
|
||||
and organization_qs.filter(domain_list__overlap=self.domain_list).exists()
|
||||
):
|
||||
raise ValidationError(
|
||||
"domain_list value must be unique across all instances."
|
||||
)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the organization.
|
||||
"""
|
||||
# Use the role from queryset annotation will raise on purpose if not used properly
|
||||
is_admin = self.user_role == OrganizationRoleChoices.ADMIN # pylint: disable=no-member
|
||||
|
||||
return {
|
||||
"get": user.organization_id == self.pk,
|
||||
"patch": is_admin,
|
||||
"put": is_admin,
|
||||
"delete": False,
|
||||
}
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
admin_email = models.EmailField(
|
||||
_("admin email address"), unique=True, null=True, blank=True
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_ characters."
|
||||
),
|
||||
)
|
||||
profile_contact = models.OneToOneField(
|
||||
Contact,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="user",
|
||||
blank=True,
|
||||
null=True,
|
||||
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
validators=[sub_validator],
|
||||
)
|
||||
email = models.EmailField(_("email address"), null=True, blank=True)
|
||||
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
choices=settings.LANGUAGES,
|
||||
default=settings.LANGUAGE_CODE,
|
||||
verbose_name=_("language"),
|
||||
help_text=_("The language in which the user wants to see the interface."),
|
||||
@@ -202,10 +470,17 @@ 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()
|
||||
|
||||
USERNAME_FIELD = "admin_email"
|
||||
USERNAME_FIELD = "sub"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
@@ -214,116 +489,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
str(self.profile_contact)
|
||||
if self.profile_contact
|
||||
else self.admin_email or str(self.id)
|
||||
)
|
||||
|
||||
def _get_identities_main(self):
|
||||
"""Return a list with the main identity or an empty list."""
|
||||
try:
|
||||
return self._identities_main
|
||||
except AttributeError:
|
||||
return self.identities.filter(is_main=True)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return main identity's name."""
|
||||
try:
|
||||
return self._get_identities_main()[0].name
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def email(self):
|
||||
"""Return main identity's email."""
|
||||
try:
|
||||
return self._get_identities_main()[0].email
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
if self.profile_contact_id and not self.profile_contact.owner == self:
|
||||
raise exceptions.ValidationError(
|
||||
"Users can only declare as profile a contact they own."
|
||||
)
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
email = self.email or self.admin_email
|
||||
if not email:
|
||||
raise ValueError("You must first set an email for the user.")
|
||||
mail.send_mail(subject, message, from_email, [email], **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_email_field_name(cls):
|
||||
"""
|
||||
Raise error when trying to get email field name from the user as we are using
|
||||
a separate Email model to allow several emails per user.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"This feature is deactivated to allow several emails per user."
|
||||
)
|
||||
|
||||
|
||||
class Identity(BaseModel):
|
||||
"""User identity"""
|
||||
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_ characters."
|
||||
),
|
||||
)
|
||||
|
||||
user = models.ForeignKey(User, related_name="identities", on_delete=models.CASCADE)
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
validators=[sub_validator],
|
||||
)
|
||||
email = models.EmailField(_("email address"), null=True, blank=True)
|
||||
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
|
||||
is_main = models.BooleanField(
|
||||
_("main"),
|
||||
default=False,
|
||||
help_text=_("Designates whether the email is the main one."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_identity"
|
||||
ordering = ("-is_main", "email")
|
||||
verbose_name = _("identity")
|
||||
verbose_name_plural = _("identities")
|
||||
constraints = [
|
||||
# Uniqueness
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "email"],
|
||||
name="unique_user_email",
|
||||
violation_error_message=_(
|
||||
"This email address is already declared for this user."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
main_str = "[main]" if self.is_main else ""
|
||||
id_str = self.email or self.sub
|
||||
return f"{id_str:s}{main_str:s}"
|
||||
return self.name if self.name else self.email or f"User {self.sub}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Saves identity, ensuring users always have exactly one main identity.
|
||||
If it's a new identity, give its user access to the relevant teams.
|
||||
If it's a new user, give her access to the relevant teams.
|
||||
"""
|
||||
|
||||
if self._state.adding:
|
||||
@@ -331,9 +501,11 @@ class Identity(BaseModel):
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Ensure users always have one and only one main identity.
|
||||
if self.is_main is True:
|
||||
self.user.identities.exclude(id=self.id).update(is_main=False)
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
if self.email:
|
||||
self.email = User.objects.normalize_email(self.email)
|
||||
|
||||
def _convert_valid_invitations(self):
|
||||
"""
|
||||
@@ -354,33 +526,158 @@ class Identity(BaseModel):
|
||||
|
||||
TeamAccess.objects.bulk_create(
|
||||
[
|
||||
TeamAccess(user=self.user, team=invitation.team, role=invitation.role)
|
||||
TeamAccess(user=self, team=invitation.team, role=invitation.role)
|
||||
for invitation in valid_invitations
|
||||
]
|
||||
)
|
||||
valid_invitations.delete()
|
||||
|
||||
def clean(self):
|
||||
"""Normalize the email field and clean the 'is_main' field."""
|
||||
if self.email:
|
||||
self.email = User.objects.normalize_email(self.email)
|
||||
if not self.user.identities.exclude(pk=self.pk).filter(is_main=True).exists():
|
||||
if not self.created_at:
|
||||
self.is_main = True
|
||||
elif not self.is_main:
|
||||
raise exceptions.ValidationError(
|
||||
{"is_main": "A user should have one and only one main identity."}
|
||||
)
|
||||
super().clean()
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
if not self.email:
|
||||
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 or settings.FEATURES["TEAMS_DISPLAY"]
|
||||
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,
|
||||
"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 Team(BaseModel):
|
||||
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 TeamManager(MP_NodeManager):
|
||||
"""
|
||||
Custom manager for the Team model, to manage complexity/automation.
|
||||
"""
|
||||
|
||||
def create(self, parent_id=None, **kwargs):
|
||||
"""
|
||||
Replace the default create method to ease the Team creation process.
|
||||
|
||||
Notes:
|
||||
- the `add_*` methods from django-treebeard does not support the "using db".
|
||||
Which means it will always use the default db.
|
||||
- the `add_*` methods from django-treebeard does not support the "force_insert".
|
||||
|
||||
"""
|
||||
if parent_id is None:
|
||||
return self.model.add_root(**kwargs)
|
||||
|
||||
# Retrieve parent object, because django-treebeard uses raw queries for most
|
||||
# write operations, and raw queries don’t update the django objects of the db
|
||||
# entries they modify. See caveats in the django-treebeard documentation.
|
||||
# This might be changed later if we never do any operation on the parent object
|
||||
# before creating the child.
|
||||
# Beware the N+1 here.
|
||||
return self.get(pk=parent_id).add_child(**kwargs)
|
||||
|
||||
|
||||
class Team(MP_Node, BaseModel):
|
||||
"""
|
||||
Represents the link between teams and users, specifying the role a user has in a team.
|
||||
|
||||
When a team is created from here, the user have to choose which Service Providers
|
||||
can see it.
|
||||
When a team is created from a Service Provider this one is automatically set in the
|
||||
Team `service_providers`.
|
||||
"""
|
||||
|
||||
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
# Django treebeard does not allow max_length = None...
|
||||
steplen = 5
|
||||
path = models.CharField(max_length=5 * 80, unique=True, db_collation="C")
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(max_length=100, unique=True, null=False, editable=False)
|
||||
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
@@ -388,6 +685,20 @@ class Team(BaseModel):
|
||||
through_fields=("team", "user"),
|
||||
related_name="teams",
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
Organization,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="teams",
|
||||
null=True, # Need to be set to False when everything is migrated
|
||||
blank=True, # Need to be set to False when everything is migrated
|
||||
)
|
||||
service_providers = models.ManyToManyField(
|
||||
ServiceProvider,
|
||||
related_name="teams",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = TeamManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "people_team"
|
||||
@@ -398,15 +709,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.
|
||||
@@ -615,8 +917,8 @@ class Invitation(BaseModel):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
# Check if an identity already exists for the provided email
|
||||
if Identity.objects.filter(email=self.email).exists():
|
||||
# Check if a user already exists for the provided email
|
||||
if User.objects.filter(email=self.email).exists():
|
||||
raise exceptions.ValidationError(
|
||||
{"email": _("This email is already associated to a registered user.")}
|
||||
)
|
||||
@@ -659,14 +961,15 @@ class Invitation(BaseModel):
|
||||
"""Email invitation to the user."""
|
||||
try:
|
||||
with override(self.issuer.language):
|
||||
subject = gettext("Invitation to join La Régie!")
|
||||
template_vars = {
|
||||
"title": _("Invitation to join Desk!"),
|
||||
"title": subject,
|
||||
"site": Site.objects.get_current(),
|
||||
}
|
||||
msg_html = render_to_string("mail/html/invitation.html", template_vars)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||
mail.send_mail(
|
||||
_("Invitation to join Desk!"),
|
||||
subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[self.email],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core plugins package."""
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Base plugin class for organization plugins."""
|
||||
|
||||
|
||||
class BaseOrganizationPlugin:
|
||||
"""
|
||||
Base class for organization plugins.
|
||||
|
||||
Plugins must implement all methods of this class even if it is only to "pass".
|
||||
"""
|
||||
|
||||
def run_after_create(self, organization) -> None:
|
||||
"""Method called after creating an organization."""
|
||||
raise NotImplementedError("Plugins must implement the run_after_create method")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Helper functions to load and run organization plugins."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from core.plugins.base import BaseOrganizationPlugin
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_organization_plugins() -> List[BaseOrganizationPlugin]:
|
||||
"""
|
||||
Return a list of all organization plugins.
|
||||
While the plugins initialization does not depend on the request, we can cache the result.
|
||||
"""
|
||||
return [
|
||||
import_string(plugin_path)() for plugin_path in settings.ORGANIZATION_PLUGINS
|
||||
]
|
||||
|
||||
|
||||
def organization_plugins_run_after_create(organization):
|
||||
"""
|
||||
Run the after create method for all organization plugins.
|
||||
|
||||
Each plugin will be called in the order they are listed in the settings.
|
||||
Each plugin is responsible to save changes if needed, this is not optimized
|
||||
but this could be easily improved later if needed.
|
||||
"""
|
||||
for plugin_instance in get_organization_plugins():
|
||||
plugin_instance.run_after_create(organization)
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend resource server module."""
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Resource Server Authentication"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from mozilla_django_oidc.contrib.drf import OIDCAuthentication
|
||||
|
||||
from .backend import ResourceServerBackend, ResourceServerImproperlyConfiguredBackend
|
||||
from .clients import AuthorizationServerClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceServerAuthentication(OIDCAuthentication):
|
||||
"""Authenticate clients using the token received from the authorization server."""
|
||||
|
||||
def __init__(self):
|
||||
"""Require authentication to be configured in order to instantiate."""
|
||||
super().__init__()
|
||||
|
||||
try:
|
||||
authorization_server_client = AuthorizationServerClient(
|
||||
url=settings.OIDC_OP_URL,
|
||||
verify_ssl=settings.OIDC_VERIFY_SSL,
|
||||
timeout=settings.OIDC_TIMEOUT,
|
||||
proxy=settings.OIDC_PROXY,
|
||||
url_jwks=settings.OIDC_OP_JWKS_ENDPOINT,
|
||||
url_introspection=settings.OIDC_OP_INTROSPECTION_ENDPOINT,
|
||||
)
|
||||
self.backend = ResourceServerBackend(authorization_server_client)
|
||||
|
||||
except ImproperlyConfigured as err:
|
||||
message = "Resource Server authentication is disabled"
|
||||
logger.debug("%s. Exception: %s", message, err)
|
||||
self.backend = ResourceServerImproperlyConfiguredBackend()
|
||||
|
||||
def get_access_token(self, request):
|
||||
"""Retrieve and decode the access token from the request.
|
||||
|
||||
This method overrides the 'get_access_token' method from the parent class,
|
||||
to support service providers that would base64 encode the bearer token.
|
||||
"""
|
||||
|
||||
access_token = super().get_access_token(request)
|
||||
|
||||
try:
|
||||
access_token = base64.b64decode(access_token).decode("utf-8")
|
||||
except (binascii.Error, TypeError):
|
||||
pass
|
||||
|
||||
return access_token
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the request and return a tuple of (user, token) or None.
|
||||
|
||||
We override the 'authenticate' method from the parent class to store
|
||||
the introspected token audience inside the request.
|
||||
"""
|
||||
result = super().authenticate(request) # Might raise AuthenticationFailed
|
||||
|
||||
if result is None: # Case when there is no access token
|
||||
return None
|
||||
|
||||
# Note: at this stage, the request is a "drf_request" object
|
||||
request.resource_server_token_audience = self.backend.token_origin_audience
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Resource Server Backend"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.errors import InvalidClaimError, InvalidTokenError
|
||||
from requests.exceptions import HTTPError
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from . import utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceServerBackend:
|
||||
"""Backend of an OAuth 2.0 resource server.
|
||||
|
||||
This backend is designed to authenticate resource owners to our API using the access token
|
||||
they received from the authorization server.
|
||||
|
||||
In the context of OAuth 2.0, a resource server is a server that hosts protected resources and
|
||||
is capable of accepting and responding to protected resource requests using access tokens.
|
||||
The resource server verifies the validity of the access tokens issued by the authorization
|
||||
server to ensure secure access to the resources.
|
||||
|
||||
For more information, visit: https://www.oauth.com/oauth2-servers/the-resource-server/
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
def __init__(self, authorization_server_client):
|
||||
"""Require client_id, client_secret set and authorization_server_client provided."""
|
||||
# pylint: disable=invalid-name
|
||||
self.UserModel = auth.get_user_model()
|
||||
|
||||
self._client_id = settings.OIDC_RS_CLIENT_ID
|
||||
self._client_secret = settings.OIDC_RS_CLIENT_SECRET
|
||||
self._encryption_encoding = settings.OIDC_RS_ENCRYPTION_ENCODING
|
||||
self._encryption_algorithm = settings.OIDC_RS_ENCRYPTION_ALGO
|
||||
self._signing_algorithm = settings.OIDC_RS_SIGNING_ALGO
|
||||
self._scopes = settings.OIDC_RS_SCOPES
|
||||
|
||||
if (
|
||||
not self._client_id
|
||||
or not self._client_secret
|
||||
or not authorization_server_client
|
||||
):
|
||||
raise ImproperlyConfigured(
|
||||
"Could not instantiate ResourceServerBackend, some parameters are missing."
|
||||
)
|
||||
|
||||
self._authorization_server_client = authorization_server_client
|
||||
|
||||
self._claims_registry = jose_jwt.JWTClaimsRegistry(
|
||||
iss={"essential": True, "value": self._authorization_server_client.url},
|
||||
aud={"essential": True, "value": self._client_id},
|
||||
token_introspection={"essential": True},
|
||||
)
|
||||
|
||||
# Declare the token origin audience: to know where the token comes from
|
||||
# and store it for further use in the application
|
||||
self.token_origin_audience = None
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Maintain API compatibility with OIDCAuthentication class from mozilla-django-oidc
|
||||
|
||||
Params 'id_token', 'payload' won't be used, and our implementation will only
|
||||
support 'get_user', not 'get_or_create_user'.
|
||||
"""
|
||||
|
||||
return self.get_user(access_token)
|
||||
|
||||
def get_user(self, access_token):
|
||||
"""Get user from an access token emitted by the authorization server.
|
||||
|
||||
This method will submit the access token to the authorization server for
|
||||
introspection, to ensure its validity and obtain the associated metadata.
|
||||
|
||||
It follows the specifications outlined in RFC7662 https://www.rfc-editor.org/info/rfc7662,
|
||||
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
|
||||
|
||||
In our eGovernment applications, the standard RFC 7662 doesn't provide sufficient security.
|
||||
Its introspection response is a plain JSON object. Therefore, we use the draft RFC
|
||||
that extends RFC 7662 by returning a signed and encrypted JWT for stronger assurance that
|
||||
the authorization server issued the token introspection response.
|
||||
"""
|
||||
self.token_origin_audience = None # Reset the token origin audience
|
||||
|
||||
jwt = self._introspect(access_token)
|
||||
claims = self._verify_claims(jwt)
|
||||
user_info = self._verify_user_info(claims["token_introspection"])
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if sub is None:
|
||||
message = "User info contained no recognizable user identification"
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
try:
|
||||
user = self.UserModel.objects.get(sub=sub)
|
||||
except self.UserModel.DoesNotExist:
|
||||
logger.debug("Login failed: No user with %s found", sub)
|
||||
return None
|
||||
|
||||
self.token_origin_audience = str(user_info["aud"])
|
||||
|
||||
return user
|
||||
|
||||
def _verify_user_info(self, introspection_response):
|
||||
"""Verify the 'introspection_response' to get valid and relevant user info.
|
||||
|
||||
The 'introspection_response' should be still active, and while authenticating
|
||||
the resource owner should have requested relevant scope to access her data in
|
||||
our resource server.
|
||||
|
||||
Scope should be configured to match between the AS and the RS. The AS will filter
|
||||
all the scopes the resource owner requested to expose only the relevant ones to
|
||||
our resource server.
|
||||
"""
|
||||
|
||||
active = introspection_response.get("active", None)
|
||||
|
||||
if not active:
|
||||
message = "Introspection response is not active."
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
requested_scopes = introspection_response.get("scope", None).split(" ")
|
||||
if set(self._scopes).isdisjoint(set(requested_scopes)):
|
||||
message = "Introspection response contains any required scopes."
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
audience = introspection_response.get("aud", None)
|
||||
if not audience:
|
||||
raise SuspiciousOperation(
|
||||
"Introspection response does not provide source audience."
|
||||
)
|
||||
|
||||
return introspection_response
|
||||
|
||||
def _introspect(self, token):
|
||||
"""Introspect an access token to the authorization server."""
|
||||
try:
|
||||
jwe = self._authorization_server_client.get_introspection(
|
||||
self._client_id,
|
||||
self._client_secret,
|
||||
token,
|
||||
)
|
||||
except HTTPError as err:
|
||||
message = "Could not fetch introspection"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
private_key = utils.import_private_key_from_settings()
|
||||
jws = self._decrypt(jwe, private_key=private_key)
|
||||
|
||||
try:
|
||||
public_key_set = self._authorization_server_client.import_public_keys()
|
||||
except (TypeError, ValueError, AttributeError, HTTPError) as err:
|
||||
message = "Could get authorization server JWKS"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
jwt = self._decode(jws, public_key_set)
|
||||
|
||||
return jwt
|
||||
|
||||
def _decrypt(self, encrypted_token, private_key):
|
||||
"""Decrypt the token encrypted by the Authorization Server (AS).
|
||||
|
||||
Resource Server (RS)'s public key is used for encryption, and its private
|
||||
key is used for decryption. The RS's public key is exposed to the AS via a JWKS endpoint.
|
||||
Encryption Algorithm and Encoding should be configured to match between the AS
|
||||
and the RS.
|
||||
"""
|
||||
|
||||
try:
|
||||
decrypted_token = jose_jwe.decrypt_compact(
|
||||
encrypted_token,
|
||||
private_key,
|
||||
algorithms=[self._encryption_algorithm, self._encryption_encoding],
|
||||
)
|
||||
except Exception as err:
|
||||
message = "Token decryption failed"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return decrypted_token
|
||||
|
||||
def _decode(self, encoded_token, public_key_set):
|
||||
"""Decode the token signed by the Authorization Server (AS).
|
||||
|
||||
AS's private key is used for signing, and its public key is used for decoding.
|
||||
The AS public key is exposed via a JWK endpoint.
|
||||
Signing Algorithm should be configured to match between the AS and the RS.
|
||||
"""
|
||||
try:
|
||||
token = jose_jwt.decode(
|
||||
encoded_token.plaintext,
|
||||
public_key_set,
|
||||
algorithms=[self._signing_algorithm],
|
||||
)
|
||||
except ValueError as err:
|
||||
message = "Token decoding failed"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return token
|
||||
|
||||
def _verify_claims(self, token):
|
||||
"""Verify the claims of the token to ensure authentication security.
|
||||
|
||||
By verifying these claims, we ensure that the token was issued by a
|
||||
trusted authorization server and is intended for this specific
|
||||
resource server. This prevents various types of attacks, such as
|
||||
token substitution or misuse of tokens issued for different clients.
|
||||
"""
|
||||
try:
|
||||
self._claims_registry.validate(token.claims)
|
||||
except (InvalidClaimError, InvalidTokenError) as err:
|
||||
message = "Failed to validate token's claims"
|
||||
logger.debug("%s. Exception:", message, exc_info=True)
|
||||
raise SuspiciousOperation(message) from err
|
||||
|
||||
return token.claims
|
||||
|
||||
|
||||
class ResourceServerImproperlyConfiguredBackend:
|
||||
"""Fallback backend for improperly configured Resource Servers."""
|
||||
|
||||
token_origin_audience = None
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Indicate that the Resource Server is improperly configured."""
|
||||
raise AuthenticationFailed("Resource Server is improperly configured")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Resource Server Clients classes"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import requests
|
||||
from joserfc.jwk import KeySet
|
||||
|
||||
|
||||
class AuthorizationServerClient:
|
||||
"""Client for interacting with an OAuth 2.0 authorization server.
|
||||
|
||||
An authorization server issues access tokens to client applications after authenticating
|
||||
and obtaining authorization from the resource owner. It also provides endpoints for token
|
||||
introspection and JSON Web Key Sets (JWKS) to validate and decode tokens.
|
||||
|
||||
This client facilitates communication with the authorization server, including:
|
||||
- Fetching token introspection responses.
|
||||
- Fetching JSON Web Key Sets (JWKS) for token validation.
|
||||
- Setting appropriate headers for secure communication as recommended by RFC drafts.
|
||||
"""
|
||||
|
||||
# ruff: noqa: PLR0913 PLR0917
|
||||
# pylint: disable=too-many-positional-arguments
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
url,
|
||||
url_jwks,
|
||||
url_introspection,
|
||||
verify_ssl,
|
||||
timeout,
|
||||
proxy,
|
||||
):
|
||||
"""Require at a minimum url, url_jwks and url_introspection."""
|
||||
|
||||
if not url or not url_jwks or not url_introspection:
|
||||
raise ImproperlyConfigured(
|
||||
"Could not instantiate AuthorizationServerClient, some parameters are missing."
|
||||
)
|
||||
|
||||
self.url = url
|
||||
self._url_introspection = url_introspection
|
||||
self._url_jwks = url_jwks
|
||||
self._verify_ssl = verify_ssl
|
||||
self._timeout = timeout
|
||||
self._proxy = proxy
|
||||
|
||||
@property
|
||||
def _introspection_headers(self):
|
||||
"""Get HTTP header for the introspection request.
|
||||
|
||||
Notify the authorization server that we expect a signed and encrypted response
|
||||
by setting the appropriate 'Accept' header.
|
||||
|
||||
This follows the recommendation from the draft RFC:
|
||||
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-introspection-response-12.
|
||||
"""
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/token-introspection+jwt",
|
||||
}
|
||||
|
||||
def get_introspection(self, client_id, client_secret, token):
|
||||
"""Retrieve introspection response about a token."""
|
||||
response = requests.post(
|
||||
self._url_introspection,
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"token": token,
|
||||
},
|
||||
headers=self._introspection_headers,
|
||||
verify=self._verify_ssl,
|
||||
timeout=self._timeout,
|
||||
proxies=self._proxy,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
def get_jwks(self):
|
||||
"""Retrieve Authorization Server JWKS."""
|
||||
response = requests.get(
|
||||
self._url_jwks,
|
||||
verify=self._verify_ssl,
|
||||
timeout=self._timeout,
|
||||
proxies=self._proxy,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def import_public_keys(self):
|
||||
"""Retrieve and import Authorization Server JWKS."""
|
||||
|
||||
jwks = self.get_jwks()
|
||||
public_keys = KeySet.import_key_set(jwks)
|
||||
|
||||
return public_keys
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Mixins for resource server views.
|
||||
"""
|
||||
|
||||
from rest_framework import exceptions as drf_exceptions
|
||||
|
||||
from .authentication import ResourceServerAuthentication
|
||||
|
||||
|
||||
class ResourceServerMixin:
|
||||
"""
|
||||
Mixin for resource server views:
|
||||
- Restrict the authentication class to ResourceServerAuthentication.
|
||||
- Adds the Service Provider ID to the serializer context.
|
||||
- Fetch the Service Provider ID from the OIDC introspected token stored
|
||||
in the request.
|
||||
|
||||
This Mixin *must* be used in every view that should act as a resource server.
|
||||
"""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# When used as a resource server, we need to know the audience to automatically:
|
||||
# - add the Service Provider to the team "scope" on creation
|
||||
context["from_service_provider_audience"] = (
|
||||
self._get_service_provider_audience()
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def _get_service_provider_audience(self):
|
||||
"""Return the audience of the Service Provider from the OIDC introspected token."""
|
||||
if not isinstance(
|
||||
self.request.successful_authenticator, ResourceServerAuthentication
|
||||
):
|
||||
# We could check request.resource_server_token_audience here, but it's
|
||||
# more explicit to check the authenticator type and assert the attribute
|
||||
# existence.
|
||||
return None
|
||||
|
||||
# When used as a resource server, the request has a token audience
|
||||
service_provider_audience = self.request.resource_server_token_audience
|
||||
|
||||
if not service_provider_audience: # should not happen
|
||||
raise drf_exceptions.AuthenticationFailed(
|
||||
"Resource server token audience not found in request"
|
||||
)
|
||||
|
||||
return service_provider_audience
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Resource Server URL Configuration"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from .views import JWKSView
|
||||
|
||||
urlpatterns = [
|
||||
path("jwks", JWKSView.as_view(), name="resource_server_jwks"),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Resource Server utils functions"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from joserfc.jwk import JWKRegistry
|
||||
|
||||
|
||||
def import_private_key_from_settings():
|
||||
"""Import the private key used by the resource server when interacting with the OIDC provider.
|
||||
|
||||
This private key is crucial; its public components are exposed in the JWK endpoints,
|
||||
while its private component is used for decrypting the introspection token retrieved
|
||||
from the OIDC provider.
|
||||
|
||||
By default, we recommend using RSAKey for asymmetric encryption,
|
||||
known for its strong security features.
|
||||
|
||||
Note:
|
||||
- The function requires the 'OIDC_RS_PRIVATE_KEY_STR' setting to be configured.
|
||||
- The 'OIDC_RS_ENCRYPTION_KEY_TYPE' and 'OIDC_RS_ENCRYPTION_ALGO' settings can be customized
|
||||
based on the chosen key type.
|
||||
|
||||
Raises:
|
||||
ImproperlyConfigured: If the private key setting is missing, empty, or incorrect.
|
||||
|
||||
Returns:
|
||||
joserfc.jwk.JWK: The imported private key as a JWK object.
|
||||
"""
|
||||
|
||||
private_key_str = getattr(settings, "OIDC_RS_PRIVATE_KEY_STR", None)
|
||||
if not private_key_str:
|
||||
raise ImproperlyConfigured(
|
||||
"OIDC_RS_PRIVATE_KEY_STR setting is missing or empty."
|
||||
)
|
||||
|
||||
private_key_pem = private_key_str.encode()
|
||||
|
||||
try:
|
||||
private_key = JWKRegistry.import_key(
|
||||
private_key_pem,
|
||||
key_type=settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
parameters={"alg": settings.OIDC_RS_ENCRYPTION_ALGO, "use": "enc"},
|
||||
)
|
||||
except ValueError as err:
|
||||
raise ImproperlyConfigured("OIDC_RS_PRIVATE_KEY_STR setting is wrong.") from err
|
||||
|
||||
return private_key
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Resource Server views"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from joserfc.jwk import KeySet
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from . import utils
|
||||
|
||||
|
||||
class JWKSView(APIView):
|
||||
"""
|
||||
API endpoint for retrieving a JSON Web Keys Set (JWKS).
|
||||
|
||||
Returns:
|
||||
Response: JSON response containing the JWKS data.
|
||||
"""
|
||||
|
||||
authentication_classes = [] # disable authentication
|
||||
permission_classes = [] # disable permission
|
||||
|
||||
def get(self, request):
|
||||
"""Handle GET requests to retrieve JSON Web Keys Set (JWKS).
|
||||
|
||||
Returns:
|
||||
Response: JSON response containing the JWKS data.
|
||||
"""
|
||||
|
||||
try:
|
||||
private_key = utils.import_private_key_from_settings()
|
||||
except (ImproperlyConfigured, ValueError) as err:
|
||||
return Response({"error": str(err)}, status=500)
|
||||
|
||||
try:
|
||||
jwk = KeySet([private_key]).as_dict(private=False)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return Response({"error": "Could not load key"}, status=500)
|
||||
|
||||
return Response(jwk)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
"""Core template tags module."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core tests."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core authentication tests."""
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
|
||||
from core import models
|
||||
from core import factories, models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import IdentityFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_email(
|
||||
@@ -19,26 +20,19 @@ def test_authentication_getter_existing_user_no_email(
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
# Create a user and its identity
|
||||
identity = IdentityFactory(name=None)
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
user = factories.UserFactory()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": identity.sub}
|
||||
return {"sub": user.sub}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity.refresh_from_db()
|
||||
assert user == identity.user
|
||||
assert user == authenticated_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(
|
||||
@@ -49,18 +43,12 @@ def test_authentication_getter_existing_user_with_email(
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
identity = IdentityFactory(name="John Doe")
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
user = factories.UserFactory(name="John Doe", with_organization=True)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": identity.sub,
|
||||
"email": identity.email,
|
||||
"sub": user.sub,
|
||||
"email": user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
@@ -69,11 +57,11 @@ def test_authentication_getter_existing_user_with_email(
|
||||
|
||||
# Only 1 query because email and names have not changed
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert models.User.objects.get() == user
|
||||
assert user == authenticated_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -89,23 +77,16 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the email or name fields on the identity when they change.
|
||||
The email on the user should not be changed.
|
||||
It should update the email or name fields on the user when they change.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
identity = IdentityFactory(name="John Doe", email="john.doe@example.com")
|
||||
user_email = identity.user.admin_email
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
user = factories.UserFactory(
|
||||
name="John Doe", email="john.doe@example.com", with_organization=True
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": identity.sub,
|
||||
"sub": user.sub,
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
@@ -114,29 +95,90 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
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 == email
|
||||
assert user.name == f"{first_name:s} {last_name:s}"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_keep_fields(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
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": 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
|
||||
)
|
||||
|
||||
identity.refresh_from_db()
|
||||
assert identity.email == email
|
||||
assert identity.name == f"{first_name:s} {last_name:s}"
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
assert user == identity.user
|
||||
assert user.admin_email == user_email
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's info doesn't contain an email, created user's email should be empty.
|
||||
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"}
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
@@ -144,23 +186,39 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email is 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.admin_email 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):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's email and name should be set on the identity.
|
||||
The "email" field on the User model should not be set as it is reserved for staff users.
|
||||
User's email and name should be set on the user.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "people@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
@@ -172,12 +230,10 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email == email
|
||||
assert identity.name == "John Doe"
|
||||
|
||||
assert user.admin_email is None
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.name == "John Doe"
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -202,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"]
|
||||
|
||||
@@ -38,8 +38,7 @@ def test_view_logout_anonymous():
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -60,8 +59,7 @@ def test_view_logout(mocked_oidc_logout_url):
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -96,8 +94,7 @@ def test_view_logout_callback_anonymous():
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -128,8 +125,7 @@ def test_view_logout_construct_oidc_logout_url(
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -159,8 +155,7 @@ def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -180,8 +175,7 @@ def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -207,8 +201,7 @@ def test_view_logout_callback_wrong_state(initial_state):
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Contacts tests package."""
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
CONTACT_DATA = {
|
||||
"emails": [
|
||||
{"type": "Work", "value": "john.doe@work.com"},
|
||||
{"type": "Home", "value": "john.doe@home.com"},
|
||||
],
|
||||
"phones": [
|
||||
{"type": "Work", "value": "(123) 456-7890"},
|
||||
{"type": "Other", "value": "(987) 654-3210"},
|
||||
],
|
||||
"addresses": [
|
||||
{
|
||||
"type": "Home",
|
||||
"street": "123 Main St",
|
||||
"city": "Cityville",
|
||||
"state": "CA",
|
||||
"zip": "12345",
|
||||
"country": "USA",
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{"type": "Blog", "value": "http://personalwebsite.com"},
|
||||
{"type": "Website", "value": "http://workwebsite.com"},
|
||||
],
|
||||
"customFields": {"custom_field_1": "value1", "custom_field_2": "value2"},
|
||||
"organizations": [
|
||||
{
|
||||
"name": "ACME Corporation",
|
||||
"department": "IT",
|
||||
"jobTitle": "Software Engineer",
|
||||
},
|
||||
{
|
||||
"name": "XYZ Ltd",
|
||||
"department": "Marketing",
|
||||
"jobTitle": "Marketing Specialist",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_anonymous_forbidden():
|
||||
"""Anonymous users should not be able to create contacts via the API."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David",
|
||||
"short_name": "Bowman",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert not models.Contact.objects.exists()
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_missing_base():
|
||||
"""Authenticated user should be able to create contact without override."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": {},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
new_contact = models.Contact.objects.get(owner=user)
|
||||
|
||||
assert response.json() == {
|
||||
"override": None,
|
||||
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
|
||||
"data": {},
|
||||
"full_name": "David Bowman",
|
||||
"id": str(new_contact.pk),
|
||||
"notes": "",
|
||||
"owner": str(user.pk),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_successful():
|
||||
"""Authenticated users should be able to create contacts."""
|
||||
user = factories.UserFactory()
|
||||
base_contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Existing override for another user should not interfere
|
||||
factories.ContactFactory(override=base_contact)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"override": str(base_contact.id),
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Contact.objects.count() == 3
|
||||
|
||||
contact = models.Contact.objects.get(owner=user)
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
|
||||
"override": str(base_contact.id),
|
||||
"data": CONTACT_DATA,
|
||||
"full_name": "David Bowman",
|
||||
"notes": "",
|
||||
"owner": str(user.id),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
assert contact.full_name == "David Bowman"
|
||||
assert contact.short_name == "Dave"
|
||||
assert contact.data == CONTACT_DATA
|
||||
assert contact.override == base_contact
|
||||
assert contact.owner == user
|
||||
|
||||
|
||||
@override_settings(ALLOW_API_USER_CREATE=True)
|
||||
def test_api_contacts_create_authenticated_existing_override():
|
||||
"""
|
||||
Trying to create a contact overriding a contact that is already overridden by the user
|
||||
should receive a 400 error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
base_contact = factories.BaseContactFactory()
|
||||
factories.ContactFactory(override=base_contact, owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"override": str(base_contact.id),
|
||||
"full_name": "David Bowman",
|
||||
"notes": "",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
assert response.json() == {
|
||||
"__all__": ["Contact with this Owner and Override already exists."]
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_successful_with_notes():
|
||||
"""Authenticated users should be able to create contacts with notes."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
"notes": "This is a note",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
contact = models.Contact.objects.get(owner=user)
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
|
||||
"override": None,
|
||||
"data": CONTACT_DATA,
|
||||
"full_name": "David Bowman",
|
||||
"notes": "This is a note",
|
||||
"owner": str(user.id),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
assert contact.full_name == "David Bowman"
|
||||
assert contact.short_name == "Dave"
|
||||
assert contact.data == CONTACT_DATA
|
||||
assert contact.owner == user
|
||||
assert contact.notes == "This is a note"
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_delete_list_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a list of contacts."""
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_contacts_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of contacts."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = client.delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 405
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_contacts_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a contact."""
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
response = client.delete(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a public contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_owner():
|
||||
"""
|
||||
Authenticated users should be allowed to delete a contact they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Contact.objects.count() == 0
|
||||
assert models.Contact.objects.filter(id=contact.id).exists() is False
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete their profile contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ProfileContactFactory(user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.exists() is True
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a contact they don't own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.count() == 1
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list contacts."""
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_no_query(django_assert_num_queries):
|
||||
"""
|
||||
Authenticated users should be able to list contacts without applying a query.
|
||||
Profile and overridden contacts should be excluded.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
|
||||
# The user's profile contact should be listed (why not)
|
||||
user_profile_contact = factories.ContactFactory(
|
||||
owner=user, user=user, full_name="Dave Bowman"
|
||||
)
|
||||
|
||||
# A contact that belongs to another user should not be listed
|
||||
factories.ContactFactory()
|
||||
# even if from the same organization
|
||||
factories.ContactFactory(owner__organization=organization)
|
||||
|
||||
# A profile contact should not be listed if from another organization
|
||||
factories.ProfileContactFactory()
|
||||
|
||||
# A profile contact for someone in the same organization should be listed
|
||||
profile_contact = factories.ProfileContactFactory(
|
||||
user__organization=organization, full_name="Frank Poole"
|
||||
)
|
||||
|
||||
# An overridden contact should not be listed, but the override must be
|
||||
overriden_contact = factories.ProfileContactFactory(user__organization=organization)
|
||||
override_contact = factories.ContactFactory(
|
||||
owner=user, override=overriden_contact, full_name="Nicole Foole"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{
|
||||
"id": str(user_profile_contact.pk),
|
||||
"abilities": {"delete": False, "get": True, "patch": True, "put": True},
|
||||
"override": None,
|
||||
"owner": str(user.pk),
|
||||
"data": user_profile_contact.data,
|
||||
"full_name": user_profile_contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": user_profile_contact.short_name,
|
||||
},
|
||||
{
|
||||
"id": str(profile_contact.pk),
|
||||
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
|
||||
"override": None,
|
||||
"owner": str(profile_contact.user.pk),
|
||||
"data": profile_contact.data,
|
||||
"full_name": profile_contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": profile_contact.short_name,
|
||||
},
|
||||
{
|
||||
"id": str(override_contact.pk),
|
||||
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
|
||||
"override": str(overriden_contact.pk),
|
||||
"owner": str(user.pk),
|
||||
"data": override_contact.data,
|
||||
"full_name": override_contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": override_contact.short_name,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_by_full_name():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case insensitive and
|
||||
partial query on the full name.
|
||||
"""
|
||||
user = factories.UserFactory(name="Prudence Crandall")
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="David Bowman", data={})
|
||||
nicole = factories.BaseContactFactory(full_name="Nicole Foole", data={})
|
||||
frank = factories.BaseContactFactory(full_name="Frank Poole", data={})
|
||||
factories.BaseContactFactory(full_name="Heywood Floyd", data={})
|
||||
|
||||
# Full query should work
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=David%20Bowman")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/contacts/?q=ank")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=ole")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=ool")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_by_email():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case insensitive and
|
||||
partial query on the email.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(
|
||||
full_name="0", # don't match on full name but allow ordering
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Home", "value": "dave@personal.com"},
|
||||
{"type": "Work", "value": "david.bowman@example.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
nicole = factories.BaseContactFactory(
|
||||
full_name="1", # don't match on full name but allow ordering
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "nicole.foole@example.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
frank = factories.BaseContactFactory(
|
||||
full_name="2", # don't match on full name but allow ordering
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Home", "value": "francky@personal.com"},
|
||||
{"type": "Work", "value": "franck.poole@example.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
factories.BaseContactFactory(
|
||||
full_name="3", # don't match on full name but allow ordering
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "heywood.floyd@example.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# Full query should work
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=david.bowman@example.com")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.pk)]
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/contacts/?q=anc")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.pk)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=olé") # accented
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(nicole.pk), str(frank.pk)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=oOl") # mixed case
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(nicole.pk), str(frank.pk)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
|
||||
|
||||
# Unaccented full name
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=EEE")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=AAA")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_content():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented query should find unaccented content."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=ééé")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=ààà")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a user."""
|
||||
client = APIClient()
|
||||
contact = factories.ContactFactory()
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_owned(django_assert_num_queries):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a contact they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
|
||||
"override": None,
|
||||
"owner": str(contact.owner.id),
|
||||
"data": contact.data,
|
||||
"full_name": contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": contact.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_public():
|
||||
"""
|
||||
Authenticated users should be able to retrieve public contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
|
||||
"override": None,
|
||||
"owner": None,
|
||||
"data": contact.data,
|
||||
"full_name": contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": contact.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve another user's contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.client import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a contact."""
|
||||
contact = factories.ContactFactory()
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
assert contact_values == old_contact_values
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_owned(django_assert_num_queries):
|
||||
"""
|
||||
Authenticated users should be allowed to update their own contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user) # Owned by the logged-in user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
with django_assert_num_queries(8):
|
||||
# user, 2x contact, user, 3x check, update contact
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
for key, value in contact_values.items():
|
||||
if key in ["override", "owner", "id"]:
|
||||
assert value == old_contact_values[key]
|
||||
else:
|
||||
assert value == new_contact_values[key]
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to update their profile contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user, user=user)
|
||||
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
for key, value in contact_values.items():
|
||||
if key in ["override", "owner", "id"]:
|
||||
assert value == old_contact_values[key]
|
||||
else:
|
||||
assert value == new_contact_values[key]
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to update contacts owned by other users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory() # owned by another user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
assert contact_values == old_contact_values
|
||||
@@ -0,0 +1 @@
|
||||
"""Test organization API endpoints."""
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Tests for Organizations API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_anonymous(client):
|
||||
"""Anonymous users should not be allowed to retrieve an organization."""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
response = client.get(f"/api/v1.0/organizations/{organization.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_authenticated_unrelated(client):
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve an organization to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/organizations/{organization.pk!s}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Organization matches the given query."}
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_authenticated_belong_to_organization(client):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve an organization to which they
|
||||
belong to.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(
|
||||
registration_id_list=["56618615316840", "31561861231231", "98781236231482"],
|
||||
domain_list=["example.com", "example.org"],
|
||||
)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/organizations/{organization.pk!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(organization.pk),
|
||||
"name": organization.name,
|
||||
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
|
||||
"domain_list": ["example.com", "example.org"],
|
||||
"registration_id_list": ["56618615316840", "31561861231231", "98781236231482"],
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_authenticated_administrator(client):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve an organization
|
||||
which they administrate.
|
||||
"""
|
||||
organization_access = (
|
||||
factories.OrganizationAccessFactory()
|
||||
) # only role is administrator for now
|
||||
user = organization_access.user
|
||||
organization = organization_access.organization
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/organizations/{organization.pk!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"patch": True,
|
||||
"put": True,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Tests for Organizations API endpoint in People's core app: update
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_organizations_update_anonymous(client):
|
||||
"""Anonymous users should not be allowed to update an organization."""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_update_authenticated_unrelated(client):
|
||||
"""
|
||||
Authenticated users should not be allowed to update an organization to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Organization matches the given query."}
|
||||
|
||||
|
||||
def test_api_organizations_update_authenticated_belong_to_organization(client):
|
||||
"""
|
||||
Authenticated users should NOT be allowed to update an organization to which they
|
||||
belong to.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_update_authenticated_administrator(client):
|
||||
"""
|
||||
Authenticated users should be allowed to update an organization
|
||||
which they administrate.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(
|
||||
registration_id_list=["56618615316840", "31561861231231", "98781236231482"],
|
||||
domain_list=["example.com", "example.org"],
|
||||
)
|
||||
organization_access = factories.OrganizationAccessFactory(organization=organization)
|
||||
user = organization_access.user
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(organization.pk),
|
||||
"name": "New Name",
|
||||
"abilities": {"delete": False, "get": True, "patch": True, "put": True},
|
||||
"domain_list": ["example.com", "example.org"],
|
||||
"registration_id_list": ["56618615316840", "31561861231231", "98781236231482"],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Core resource server tests."""
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Tests for the authentication process of the resource server."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.rfc7518.rsa_key import RSAKey
|
||||
from jwt.utils import to_base64url_uint
|
||||
from rest_framework.request import Request as DRFRequest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.models import ServiceProvider
|
||||
from core.resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def build_authorization_bearer(token):
|
||||
"""
|
||||
Build an Authorization Bearer header value from a token.
|
||||
|
||||
This can be used like this:
|
||||
client.post(
|
||||
...
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
"""
|
||||
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_authentication_class(client, settings):
|
||||
"""
|
||||
Defines the settings for the resource server
|
||||
for a full authentication with introspection process.
|
||||
|
||||
This is an integration test that checks the authentication process
|
||||
when using the ResourceServerAuthentication class.
|
||||
|
||||
This test asserts the DRF request object contains the
|
||||
`resource_server_token_audience` attribute which is used in
|
||||
the resource server views.
|
||||
|
||||
This test uses the `/resource-server/v1.0/teams/` URL as an example
|
||||
because we don't want to create a new URL just for this test.
|
||||
"""
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
unencrypted_pem_private_key = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
pem_public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = unencrypted_pem_private_key.decode("utf-8")
|
||||
settings.OIDC_RS_ENCRYPTION_KEY_TYPE = "RSA"
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
|
||||
settings.OIDC_RS_SIGNING_ALGO = "RS256"
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
# Mock the JWKS endpoint
|
||||
public_numbers = private_key.public_key().public_numbers()
|
||||
responses.add(
|
||||
responses.GET,
|
||||
settings.OIDC_OP_JWKS_ENDPOINT,
|
||||
body=json.dumps(
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
"use": "sig",
|
||||
"kid": "1234567890",
|
||||
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
|
||||
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def encrypt_jwt(json_data):
|
||||
"""Encrypt the JWT token for the backend to decrypt."""
|
||||
token = jose_jwt.encode(
|
||||
{
|
||||
"kid": "1234567890",
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
},
|
||||
json_data,
|
||||
RSAKey.import_key(unencrypted_pem_private_key),
|
||||
algorithms=[settings.OIDC_RS_SIGNING_ALGO],
|
||||
)
|
||||
|
||||
return jose_jwe.encrypt_compact(
|
||||
protected={
|
||||
"alg": settings.OIDC_RS_ENCRYPTION_ALGO,
|
||||
"enc": settings.OIDC_RS_ENCRYPTION_ENCODING,
|
||||
},
|
||||
plaintext=token,
|
||||
public_key=RSAKey.import_key(pem_public_key),
|
||||
algorithms=[
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO,
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING,
|
||||
],
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
body=encrypt_jwt(
|
||||
{
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"token_introspection": {
|
||||
"sub": "very-specific-sub",
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_service_provider",
|
||||
"scope": "openid groups",
|
||||
"active": True,
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Try to authenticate while the user does not exist => 401
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/", # use an exising URL here
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
|
||||
# Create a user with the specific sub, the access is authorized
|
||||
UserFactory(sub="very-specific-sub")
|
||||
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/", # use an exising URL here
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
response_request = response.renderer_context.get("request")
|
||||
assert isinstance(response_request, DRFRequest)
|
||||
assert isinstance(
|
||||
response_request.successful_authenticator, ResourceServerAuthentication
|
||||
)
|
||||
|
||||
# Check that the user is authenticated
|
||||
assert response_request.user.is_authenticated
|
||||
|
||||
# Check the request contains the resource server token audience
|
||||
assert response_request.resource_server_token_audience == "some_service_provider"
|
||||
|
||||
# Check that no service provider is created here
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
@@ -0,0 +1,447 @@
|
||||
"""
|
||||
Test for the Resource Server (RS) Backend.
|
||||
"""
|
||||
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from logging import Logger
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from joserfc.errors import InvalidClaimError, InvalidTokenError
|
||||
from joserfc.jwt import JWTClaimsRegistry
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from core.resource_server.backend import ResourceServerBackend
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_authorization_server")
|
||||
def fixture_mock_authorization_server():
|
||||
"""Mock an Authorization Server client."""
|
||||
mock_server = Mock()
|
||||
mock_server.url = "https://auth.server.com"
|
||||
return mock_server
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_token")
|
||||
def fixture_mock_token():
|
||||
"""Mock a token"""
|
||||
mock_token = Mock()
|
||||
mock_token.claims = {"sub": "user123", "iss": "https://auth.server.com"}
|
||||
return mock_token
|
||||
|
||||
|
||||
@pytest.fixture(name="resource_server_backend")
|
||||
def fixture_resource_server_backend(settings, mock_authorization_server):
|
||||
"""Generate a Resource Server backend."""
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "client_secret"
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
|
||||
settings.OIDC_RS_SIGNING_ALGO = "ES256"
|
||||
settings.OIDC_RS_SCOPES = ["groups"]
|
||||
|
||||
return ResourceServerBackend(mock_authorization_server)
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_CLIENT_ID="client_id")
|
||||
@override_settings(OIDC_RS_CLIENT_SECRET="client_secret")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ENCODING="A256GCM")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RSA-OAEP")
|
||||
@override_settings(OIDC_RS_SIGNING_ALGO="RS256")
|
||||
@override_settings(OIDC_RS_SCOPES=["scopes"])
|
||||
@patch.object(auth, "get_user_model", return_value="foo")
|
||||
def test_backend_initialization(mock_get_user_model, mock_authorization_server):
|
||||
"""Test the ResourceServerBackend initialization."""
|
||||
|
||||
backend = ResourceServerBackend(mock_authorization_server)
|
||||
|
||||
mock_get_user_model.assert_called_once()
|
||||
assert backend.UserModel == "foo"
|
||||
|
||||
assert backend._client_id == "client_id"
|
||||
assert backend._client_secret == "client_secret"
|
||||
assert backend._encryption_encoding == "A256GCM"
|
||||
assert backend._encryption_algorithm == "RSA-OAEP"
|
||||
assert backend._signing_algorithm == "RS256"
|
||||
assert backend._scopes == ["scopes"]
|
||||
|
||||
assert backend._authorization_server_client == mock_authorization_server
|
||||
assert isinstance(backend._claims_registry, JWTClaimsRegistry)
|
||||
|
||||
assert backend._claims_registry.options == {
|
||||
"iss": {"essential": True, "value": "https://auth.server.com"},
|
||||
"aud": {"essential": True, "value": "client_id"},
|
||||
"token_introspection": {"essential": True},
|
||||
}
|
||||
|
||||
|
||||
@patch.object(ResourceServerBackend, "get_user", return_value="user")
|
||||
def test_get_or_create_user(mock_get_user, resource_server_backend):
|
||||
"""Test 'get_or_create_user' method."""
|
||||
|
||||
access_token = "access_token"
|
||||
res = resource_server_backend.get_or_create_user(access_token, None, None)
|
||||
|
||||
mock_get_user.assert_called_once_with(access_token)
|
||||
assert res == "user"
|
||||
|
||||
|
||||
def test_verify_claims_success(resource_server_backend, mock_token):
|
||||
"""Test '_verify_claims' method with a successful response."""
|
||||
|
||||
with patch.object(
|
||||
resource_server_backend._claims_registry, "validate"
|
||||
) as mock_validate:
|
||||
resource_server_backend._verify_claims(mock_token)
|
||||
mock_validate.assert_called_once_with(mock_token.claims)
|
||||
|
||||
|
||||
def test_verify_claims_invalid_claim_error(resource_server_backend, mock_token):
|
||||
"""Test '_verify_claims' method with an invalid claim error."""
|
||||
|
||||
with patch.object(
|
||||
resource_server_backend._claims_registry, "validate"
|
||||
) as mock_validate:
|
||||
mock_validate.side_effect = InvalidClaimError("claim_name")
|
||||
|
||||
expected_message = "Failed to validate token's claims"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_claims(mock_token)
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_verify_claims_invalid_token_error(resource_server_backend, mock_token):
|
||||
"""Test '_verify_claims' method with an invalid token error."""
|
||||
|
||||
with patch.object(
|
||||
resource_server_backend._claims_registry, "validate"
|
||||
) as mock_validate:
|
||||
mock_validate.side_effect = InvalidTokenError
|
||||
|
||||
expected_message = "Failed to validate token's claims"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_claims(mock_token)
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_decode_success(resource_server_backend):
|
||||
"""Test '_decode' method with a successful response."""
|
||||
|
||||
encoded_token = Mock()
|
||||
encoded_token.plaintext = "valid_encoded_token"
|
||||
public_key_set = Mock()
|
||||
|
||||
expected_decoded_token = {"sub": "user123"}
|
||||
|
||||
with patch(
|
||||
"joserfc.jwt.decode", return_value=expected_decoded_token
|
||||
) as mock_decode:
|
||||
decoded_token = resource_server_backend._decode(encoded_token, public_key_set)
|
||||
|
||||
mock_decode.assert_called_once_with(
|
||||
"valid_encoded_token", public_key_set, algorithms=["ES256"]
|
||||
)
|
||||
|
||||
assert decoded_token == expected_decoded_token
|
||||
|
||||
|
||||
def test_decode_failure(resource_server_backend):
|
||||
"""Test '_decode' method with a ValueError"""
|
||||
encoded_token = Mock()
|
||||
encoded_token.plaintext = "invalid_encoded_token"
|
||||
public_key_set = Mock()
|
||||
|
||||
with patch("joserfc.jwt.decode", side_effect=ValueError):
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match="Token decoding failed"):
|
||||
resource_server_backend._decode(encoded_token, public_key_set)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", "Token decoding failed", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_decrypt_success(resource_server_backend):
|
||||
"""Test '_decrypt' method with a successful response."""
|
||||
encrypted_token = "valid_encrypted_token"
|
||||
private_key = "private_key"
|
||||
|
||||
expected_decrypted_token = {"sub": "user123"}
|
||||
|
||||
with patch(
|
||||
"joserfc.jwe.decrypt_compact", return_value=expected_decrypted_token
|
||||
) as mock_decrypt:
|
||||
decrypted_token = resource_server_backend._decrypt(encrypted_token, private_key)
|
||||
mock_decrypt.assert_called_once_with(
|
||||
encrypted_token, private_key, algorithms=["RSA-OAEP", "A256GCM"]
|
||||
)
|
||||
|
||||
assert decrypted_token == expected_decrypted_token
|
||||
|
||||
|
||||
def test_decrypt_failure(resource_server_backend):
|
||||
"""Test '_decrypt' method with an Exception."""
|
||||
encrypted_token = "invalid_encrypted_token"
|
||||
private_key = "private_key"
|
||||
|
||||
with patch(
|
||||
"joserfc.jwe.decrypt_compact", side_effect=Exception("Decryption error")
|
||||
):
|
||||
expected_message = "Token decryption failed"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._decrypt(encrypted_token, private_key)
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"core.resource_server.utils.import_private_key_from_settings",
|
||||
return_value="private_key",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def test_introspect_success(
|
||||
mock_import_private_key_from_settings, resource_server_backend
|
||||
):
|
||||
"""Test '_introspect' method with a successful response."""
|
||||
token = "valid_token"
|
||||
jwe = "valid_jwe"
|
||||
jws = "valid_jws"
|
||||
jwt = {"sub": "user123"}
|
||||
|
||||
resource_server_backend._authorization_server_client.get_introspection = Mock(
|
||||
return_value=jwe
|
||||
)
|
||||
resource_server_backend._decrypt = Mock(return_value=jws)
|
||||
resource_server_backend._authorization_server_client.import_public_keys = Mock(
|
||||
return_value="public_key_set"
|
||||
)
|
||||
resource_server_backend._decode = Mock(return_value=jwt)
|
||||
|
||||
result = resource_server_backend._introspect(token)
|
||||
|
||||
assert result == jwt
|
||||
resource_server_backend._authorization_server_client.get_introspection.assert_called_once_with(
|
||||
"client_id", "client_secret", token
|
||||
)
|
||||
resource_server_backend._decrypt.assert_called_once_with(
|
||||
jwe, private_key="private_key"
|
||||
)
|
||||
resource_server_backend._authorization_server_client.import_public_keys.assert_called_once()
|
||||
resource_server_backend._decode.assert_called_once_with(jws, "public_key_set")
|
||||
|
||||
|
||||
def test_introspect_introspection_failure(resource_server_backend):
|
||||
"""Test '_introspect' method when introspection to the AS fails."""
|
||||
token = "invalid_token"
|
||||
resource_server_backend._authorization_server_client.get_introspection.side_effect = HTTPError(
|
||||
"Introspection error"
|
||||
)
|
||||
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
expected_message = "Could not fetch introspection"
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._introspect(token)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"core.resource_server.utils.import_private_key_from_settings",
|
||||
return_value="private_key",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def test_introspect_public_key_import_failure(
|
||||
mock_import_private_key_from_settings, resource_server_backend
|
||||
):
|
||||
"""Test '_introspect' method when fetching AS's jwks fails."""
|
||||
token = "valid_token"
|
||||
jwe = "valid_jwe"
|
||||
jws = "valid_jws"
|
||||
|
||||
resource_server_backend._authorization_server_client.get_introspection = Mock(
|
||||
return_value=jwe
|
||||
)
|
||||
resource_server_backend._decrypt = Mock(return_value=jws)
|
||||
|
||||
resource_server_backend._authorization_server_client.import_public_keys.side_effect = HTTPError(
|
||||
"Public key error"
|
||||
)
|
||||
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
expected_message = "Could get authorization server JWKS"
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._introspect(token)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"%s. Exception:", expected_message, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def test_verify_user_info_success(resource_server_backend):
|
||||
"""Test '_verify_user_info' with a successful response."""
|
||||
introspection_response = {"active": True, "scope": "groups", "aud": "123"}
|
||||
|
||||
result = resource_server_backend._verify_user_info(introspection_response)
|
||||
assert result == introspection_response
|
||||
|
||||
|
||||
def test_verify_user_info_inactive(resource_server_backend):
|
||||
"""Test '_verify_user_info' with an inactive introspection response."""
|
||||
|
||||
introspection_response = {"active": False, "scope": "groups"}
|
||||
|
||||
expected_message = "Introspection response is not active."
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_user_info(introspection_response)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(expected_message)
|
||||
|
||||
|
||||
def test_verify_user_info_wrong_scopes(resource_server_backend):
|
||||
"""Test '_verify_user_info' with wrong requested scopes."""
|
||||
|
||||
introspection_response = {"active": True, "scope": "wrong-scopes"}
|
||||
|
||||
expected_message = "Introspection response contains any required scopes."
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend._verify_user_info(introspection_response)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(expected_message)
|
||||
|
||||
|
||||
def test_get_user_success(resource_server_backend):
|
||||
"""Test '_get_user' with a successful response."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
mock_claims = {"token_introspection": {"sub": "user123", "aud": "123"}}
|
||||
mock_user = Mock()
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
|
||||
resource_server_backend._verify_user_info = Mock(
|
||||
return_value=mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get = Mock(return_value=mock_user)
|
||||
|
||||
user = resource_server_backend.get_user(access_token)
|
||||
|
||||
assert user == mock_user
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
|
||||
resource_server_backend._verify_user_info.assert_called_once_with(
|
||||
mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get.assert_called_once_with(sub="user123")
|
||||
|
||||
|
||||
def test_get_user_could_not_introspect(resource_server_backend):
|
||||
"""Test '_get_user' with introspection failing."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
|
||||
resource_server_backend._introspect = Mock(
|
||||
side_effect=SuspiciousOperation("Invalid jwt")
|
||||
)
|
||||
resource_server_backend._verify_claims = Mock()
|
||||
resource_server_backend._verify_user_info = Mock()
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="Invalid jwt"):
|
||||
resource_server_backend.get_user(access_token)
|
||||
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_not_called()
|
||||
resource_server_backend._verify_user_info.assert_not_called()
|
||||
|
||||
|
||||
def test_get_user_invalid_introspection_response(resource_server_backend):
|
||||
"""Test '_get_user' with an invalid introspection response."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(
|
||||
side_effect=SuspiciousOperation("Invalid claims")
|
||||
)
|
||||
resource_server_backend._verify_user_info = Mock()
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="Invalid claims"):
|
||||
resource_server_backend.get_user(access_token)
|
||||
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
|
||||
resource_server_backend._verify_user_info.assert_not_called()
|
||||
|
||||
|
||||
def test_get_user_user_not_found(resource_server_backend):
|
||||
"""Test '_get_user' if the user is not found."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
mock_claims = {"token_introspection": {"sub": "user123"}}
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
|
||||
resource_server_backend._verify_user_info = Mock(
|
||||
return_value=mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get = Mock(
|
||||
side_effect=resource_server_backend.UserModel.DoesNotExist
|
||||
)
|
||||
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
user = resource_server_backend.get_user(access_token)
|
||||
assert user is None
|
||||
resource_server_backend._introspect.assert_called_once_with(access_token)
|
||||
resource_server_backend._verify_claims.assert_called_once_with(mock_jwt)
|
||||
resource_server_backend._verify_user_info.assert_called_once_with(
|
||||
mock_claims["token_introspection"]
|
||||
)
|
||||
resource_server_backend.UserModel.objects.get.assert_called_once_with(
|
||||
sub="user123"
|
||||
)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(
|
||||
"Login failed: No user with %s found", "user123"
|
||||
)
|
||||
|
||||
|
||||
def test_get_user_no_user_identification(resource_server_backend):
|
||||
"""Test '_get_user' if the response miss a user identification."""
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
mock_claims = {"token_introspection": {}}
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
resource_server_backend._verify_claims = Mock(return_value=mock_claims)
|
||||
resource_server_backend._verify_user_info = Mock(
|
||||
return_value=mock_claims["token_introspection"]
|
||||
)
|
||||
|
||||
expected_message = "User info contained no recognizable user identification"
|
||||
with patch.object(Logger, "debug") as mock_logger_debug:
|
||||
with pytest.raises(SuspiciousOperation, match=expected_message):
|
||||
resource_server_backend.get_user(access_token)
|
||||
|
||||
mock_logger_debug.assert_called_once_with(expected_message)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Test for the Resource Server (RS) clients classes.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from joserfc.jwk import KeySet, RSAKey
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from core.resource_server.clients import AuthorizationServerClient
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def fixture_client():
|
||||
"""Generate an Authorization Server client."""
|
||||
return AuthorizationServerClient(
|
||||
url="https://auth.example.com/api/v2",
|
||||
url_jwks="https://auth.example.com/api/v2/jwks",
|
||||
url_introspection="https://auth.example.com/api/v2/introspect",
|
||||
verify_ssl=True,
|
||||
timeout=5,
|
||||
proxy=None,
|
||||
)
|
||||
|
||||
|
||||
def test_authorization_server_client_initialization():
|
||||
"""Test the AuthorizationServerClient initialization."""
|
||||
|
||||
new_client = AuthorizationServerClient(
|
||||
url="https://auth.example.com/api/v2",
|
||||
url_jwks="https://auth.example.com/api/v2/jwks",
|
||||
url_introspection="https://auth.example.com/api/v2/checktoken/foo",
|
||||
verify_ssl=True,
|
||||
timeout=5,
|
||||
proxy=None,
|
||||
)
|
||||
|
||||
assert new_client.url == "https://auth.example.com/api/v2"
|
||||
assert (
|
||||
new_client._url_introspection
|
||||
== "https://auth.example.com/api/v2/checktoken/foo"
|
||||
)
|
||||
assert new_client._url_jwks == "https://auth.example.com/api/v2/jwks"
|
||||
assert new_client._verify_ssl is True
|
||||
assert new_client._timeout == 5
|
||||
assert new_client._proxy is None
|
||||
|
||||
|
||||
def test_introspection_headers(client):
|
||||
"""Test the introspection headers to ensure they match the expected values."""
|
||||
assert client._introspection_headers == {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/token-introspection+jwt",
|
||||
}
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_get_introspection_success(mock_post, client):
|
||||
"""Test 'get_introspection' method with a successful response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.text = "introspection response"
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = client.get_introspection("client_id", "client_secret", "token")
|
||||
assert result == "introspection response"
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
"https://auth.example.com/api/v2/introspect",
|
||||
data={
|
||||
"client_id": "client_id",
|
||||
"client_secret": "client_secret",
|
||||
"token": "token",
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/token-introspection+jwt",
|
||||
},
|
||||
verify=True,
|
||||
timeout=5,
|
||||
proxies=None,
|
||||
)
|
||||
|
||||
|
||||
@patch("requests.post", side_effect=HTTPError())
|
||||
# pylint: disable=(unused-argument
|
||||
def test_get_introspection_error(mock_post, client):
|
||||
"""Test 'get_introspection' method with an HTTPError."""
|
||||
with pytest.raises(HTTPError):
|
||||
client.get_introspection("client_id", "client_secret", "token")
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_get_jwks_success(mock_get, client):
|
||||
"""Test 'get_jwks' method with a successful response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"jwks": "foo"}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
result = client.get_jwks()
|
||||
assert result == {"jwks": "foo"}
|
||||
|
||||
mock_get.assert_called_once_with(
|
||||
"https://auth.example.com/api/v2/jwks",
|
||||
verify=client._verify_ssl,
|
||||
timeout=client._timeout,
|
||||
proxies=client._proxy,
|
||||
)
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_get_jwks_error(mock_get, client):
|
||||
"""Test 'get_jwks' method with an HTTPError."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = HTTPError(
|
||||
response=MagicMock(status=500)
|
||||
)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
client.get_jwks()
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_valid(mock_get, client):
|
||||
"""Test 'import_public_keys' method with a successful response."""
|
||||
|
||||
mocked_key = RSAKey.generate_key(2048)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"keys": [mocked_key.as_dict()]}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.import_public_keys()
|
||||
|
||||
assert isinstance(response, KeySet)
|
||||
assert response.as_dict() == KeySet([mocked_key]).as_dict()
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_http_error(mock_get, client):
|
||||
"""Test 'import_public_keys' method with an HTTPError."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = HTTPError(
|
||||
response=MagicMock(status=500)
|
||||
)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
client.import_public_keys()
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_empty_jwks(mock_get, client):
|
||||
"""Test 'import_public_keys' method with empty keys response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"keys": []}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.import_public_keys()
|
||||
|
||||
assert isinstance(response, KeySet)
|
||||
assert response.as_dict() == {"keys": []}
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_import_public_keys_invalid_jwks(mock_get, client):
|
||||
"""Test 'import_public_keys' method with invalid keys response."""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"keys": [{"foo": "foo"}]}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
client.import_public_keys()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Test for the Resource Server (RS) utils functions.
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from joserfc.jwk import ECKey, RSAKey
|
||||
|
||||
from core.resource_server.utils import import_private_key_from_settings
|
||||
|
||||
PRIVATE_KEY_STR_MOCKED = """-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3boG1kwEGUYL+
|
||||
U58RPrVToIsF9jHB64S6WJIIInPmAclBciXFb6BWG11mbRIgo8ha3WVnC/tGHbXb
|
||||
ndiKdrH2vKHOsDhV9AmgHgNgWaUK9L0uuKEb/xMLePYWsYlgzcQJx8RZY7RQyWqE
|
||||
20WfzFxeuCE7QMb6VXSOgwQMnJsKocguIh3VCI9RIBq3B1kdgW35AD63YKOygmGx
|
||||
qjcWwbjhKLvkF7LpBdlyAEzOKqg4T5uCcHMfksMW2+foTJx70RrZM/KHU+Zysuw7
|
||||
uhhVsgPBG+CsqBSjHQhs7jzymqxtQAfe1FkrCRxOq5Pv2Efr7kgtVSkJJiX3KutM
|
||||
vnWuEypxAgMBAAECggEAGqKS9pbrN+vnmb7yMsqYgVVnQn0aggZNHlLkl4ZLLnuV
|
||||
aemlhur7zO0JzajqUC+AFQOfaQxiFu8S/FoJ+qccFdATrcPEVmTKbgPVqSyzLKlX
|
||||
fByGll5eOVT95NMwN8yBGgt2HSW/ZditXS/KxxahVgamGqjAC9MTSutGz/8Ae1U+
|
||||
DNDBJCc6RAqu3T02tV9A2pSpVC1rSktDMpLUTscnsfxpaEQATd9DJUcHEvIwoX8q
|
||||
GJpycPEhNhdPXqpln5SoMHcf/zS5ssF/Mce0lJJXYyE0LnEk9X12jMWyBqmLqXUY
|
||||
cKLyynaFbis0DpQppwKx2y8GpL76k+Ci4dOHIvFknQKBgQDj/2WRMcWOvfBrggzj
|
||||
FHpcme2gSo5A5c0CVyI+Xkf1Zab6UR6T7GiImEoj9tq0+o2WEix9rwoypgMBq8rz
|
||||
/rrJAPSZjgv6z71k4EnO2FIB5R03vQmoBRCN8VlgvLM0xv52zyjV4Wx66Q4MDjyH
|
||||
EgkpHyB0FzRZh0UzhnE/pYSetQKBgQDN9eLB1nA4CBSr1vMGNfQyfBQl3vpO9EP4
|
||||
VSS3KnUqCIjJeLu682Ylu7SFxcJAfzUpy5S43hEvcuJsagsVKfmCAGcYZs9/xq3I
|
||||
vzYyhaEOS5ezNxLSh4+yCNBPlmrmDyoazag0t8H8YQFBN6BVcxbATHqdWGUhIhYN
|
||||
eEpEMOh2TQKBgGBr7kRNTENlyHtu8IxIaMcowfn8DdUcWmsW9oBx1vTNHKTYEZp1
|
||||
bG/4F8LF7xCCtcY1wWMV17Y7xyG5yYcOv2eqY8dc72wO1wYGZLB5g5URlB2ycJcC
|
||||
LVIaM7ZZl2BGl+8fBSIOx5XjYfFvQ+HLmtwtMchm19jVAEseHF7SXRfRAoGAK15j
|
||||
aT2mU6Yf9C9G7T/fM+I8u9zACHAW/+ut14PxN/CkHQh3P16RW9CyqpiB1uLyZuKf
|
||||
Zm4cYElotDuAKey0xVMgYlsDxnwni+X3m5vX1hLE1s/5/qrc7zg75QZfbCI1U3+K
|
||||
s88d4e7rPLhh4pxhZgy0pP1ADkIHMr7ppIJH8OECgYEApNfbgsJVPAMzucUhJoJZ
|
||||
OmZHbyCtJvs4b+zxnmhmSbopifNCgS4zjXH9qC7tsUph1WE6L2KXvtApHGD5H4GQ
|
||||
IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
|
||||
MbyqKyC6DAzv4jEEhHaN7oY=
|
||||
-----END PRIVATE KEY-----
|
||||
"""
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@pytest.mark.parametrize("mocked_private_key", [None, ""])
|
||||
def test_import_private_key_from_settings_missing_or_empty_key(
|
||||
settings, mocked_private_key
|
||||
):
|
||||
"""Should raise an exception if the settings 'OIDC_RS_PRIVATE_KEY_STR' is missing or empty."""
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured,
|
||||
match="OIDC_RS_PRIVATE_KEY_STR setting is missing or empty.",
|
||||
):
|
||||
import_private_key_from_settings()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_private_key", ["123", "foo", "invalid_key"])
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
|
||||
def test_import_private_key_from_settings_incorrect_key(settings, mocked_private_key):
|
||||
"""Should raise an exception if the setting 'OIDC_RS_PRIVATE_KEY_STR' has an incorrect value."""
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = mocked_private_key
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured, match="OIDC_RS_PRIVATE_KEY_STR setting is wrong."
|
||||
):
|
||||
import_private_key_from_settings()
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="RSA")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="RS256")
|
||||
def test_import_private_key_from_settings_success_rsa_key():
|
||||
"""Should import private key string as an RSA key."""
|
||||
private_key = import_private_key_from_settings()
|
||||
assert isinstance(private_key, RSAKey)
|
||||
|
||||
|
||||
@override_settings(OIDC_RS_PRIVATE_KEY_STR=PRIVATE_KEY_STR_MOCKED)
|
||||
@override_settings(OIDC_RS_ENCRYPTION_KEY_TYPE="EC")
|
||||
@override_settings(OIDC_RS_ENCRYPTION_ALGO="ES256")
|
||||
def test_import_private_key_from_settings_success_ec_key():
|
||||
"""Should import private key string as an EC key."""
|
||||
private_key = import_private_key_from_settings()
|
||||
assert isinstance(private_key, ECKey)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Tests for the Resource Server (RS) Views.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from joserfc.jwk import RSAKey
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
|
||||
def test_view_jwks_valid_public_key(mock_import_private_key_from_settings):
|
||||
"""JWKs endpoint should return a set of valid Json Web Key"""
|
||||
|
||||
mocked_key = RSAKey.generate_key(2048)
|
||||
mock_import_private_key_from_settings.return_value = mocked_key
|
||||
|
||||
url = reverse("resource_server_jwks")
|
||||
response = APIClient().get(url)
|
||||
|
||||
mock_import_private_key_from_settings.assert_called_once()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/json"
|
||||
|
||||
jwks = response.json()
|
||||
assert jwks == {"keys": [mocked_key.as_dict(private=False)]}
|
||||
|
||||
# Security checks to make sure no details from the private key are exposed
|
||||
private_details = ["d", "p", "q", "dp", "dq", "qi", "oth", "r", "t"]
|
||||
assert all(
|
||||
private_detail not in jwks["keys"][0].keys()
|
||||
for private_detail in private_details
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
|
||||
def test_view_jwks_invalid_private_key(mock_import_private_key_from_settings):
|
||||
"""JWKS endpoint should return a proper exception when loading keys fails."""
|
||||
|
||||
mock_import_private_key_from_settings.return_value = "wrong_key"
|
||||
|
||||
url = reverse("resource_server_jwks")
|
||||
response = APIClient().get(url)
|
||||
|
||||
mock_import_private_key_from_settings.assert_called_once()
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"error": "Could not load key"}
|
||||
|
||||
|
||||
@mock.patch("core.resource_server.utils.import_private_key_from_settings")
|
||||
def test_view_jwks_missing_private_key(mock_import_private_key_from_settings):
|
||||
"""JWKS endpoint should return a proper exception when private key is missing."""
|
||||
|
||||
mock_import_private_key_from_settings.side_effect = ImproperlyConfigured("foo.")
|
||||
|
||||
url = reverse("resource_server_jwks")
|
||||
response = APIClient().get(url)
|
||||
|
||||
mock_import_private_key_from_settings.assert_called_once()
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"error": "foo."}
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the resource server API endpoints."""
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Defines fixtures for the resource server API tests."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
|
||||
from core.resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
User = get_user_model()
|
||||
fake = Faker()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _force_login_via_resource_server(
|
||||
client_fixture,
|
||||
user: User,
|
||||
service_provider_audience: Optional[str],
|
||||
):
|
||||
"""
|
||||
Context manager to authenticate a user with a service provider via
|
||||
a resource server call.
|
||||
|
||||
This allows to authenticate a user with a service provider without doing
|
||||
all the introspection process.
|
||||
|
||||
This is a private function, use the `force_login_via_resource_server`
|
||||
fixture instead.
|
||||
|
||||
The `service_provider_audience` parameter might not match any existing
|
||||
service provider audience, doing so allow to check the behavior when
|
||||
the service provider is not yet known.
|
||||
"""
|
||||
|
||||
def mock_authenticate(self, request): # pylint: disable=unused-argument
|
||||
request.resource_server_token_audience = (
|
||||
service_provider_audience or fake.pystr(min_chars=10, max_chars=10)
|
||||
)
|
||||
return user, "unused-token"
|
||||
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication, "authenticate", mock_authenticate
|
||||
):
|
||||
client_fixture.force_login(
|
||||
user,
|
||||
backend="core.resource_server.authentication.ResourceServerAuthentication",
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="force_login_via_resource_server")
|
||||
@responses.activate
|
||||
def force_login_via_resource_server_fixture():
|
||||
"""
|
||||
Fixture to authenticate a user with a service provider via a resource server call.
|
||||
|
||||
Usage:
|
||||
```
|
||||
def test_login_with_resource_server(
|
||||
client, force_login_via_resource_server,
|
||||
):
|
||||
user = UserFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/<whatever>/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
# response is authenticated
|
||||
```
|
||||
"""
|
||||
return _force_login_via_resource_server
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the resource server Team API endpoints."""
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: create
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_201_CREATED,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import OrganizationFactory, ServiceProviderFactory, UserFactory
|
||||
from core.models import ServiceProvider, Team
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create teams."""
|
||||
response = APIClient().post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert not Team.objects.exists()
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_new_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and should automatically be declared
|
||||
as the owner of the newly created team and a new service provider should be created and
|
||||
associated to the team.
|
||||
"""
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
|
||||
with force_login_via_resource_server(client, user, "some_service_provider"):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
team = Team.objects.get()
|
||||
team_access = team.accesses.get()
|
||||
service_provider = ServiceProvider.objects.get() # service provider created
|
||||
|
||||
assert response.json() == {
|
||||
"created_at": team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"id": str(team.pk),
|
||||
"depth": team.depth,
|
||||
"name": "my team",
|
||||
"numchild": team.numchild,
|
||||
"path": team.path,
|
||||
"updated_at": team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
}
|
||||
|
||||
# check team data
|
||||
assert team.name == "my team"
|
||||
assert team.organization == organization
|
||||
|
||||
# check team access data
|
||||
assert team_access.role == "owner"
|
||||
assert team_access.user == user
|
||||
|
||||
# check service provider data
|
||||
assert service_provider.audience_id == "some_service_provider"
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_existing_service_provider(
|
||||
client,
|
||||
force_login_via_resource_server,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and should automatically be declared
|
||||
as the owner of the newly created team and an existing service provider should be associated
|
||||
to the team.
|
||||
"""
|
||||
user = UserFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
assert ServiceProvider.objects.count() == 1 # no object created
|
||||
team = Team.objects.get() # team created
|
||||
assert team.service_providers.get().audience_id == service_provider.audience_id
|
||||
assert team.name == "my team"
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_teams_create_cannot_override_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and not
|
||||
be able to set the organization manually (for now).
|
||||
"""
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
service_provider = ServiceProviderFactory()
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
"organization": OrganizationFactory(
|
||||
with_registration_id=True
|
||||
).pk, # ignored
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.organization == organization
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_teams_create_cannot_override_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and not
|
||||
be able to set the team service provider manually.
|
||||
"""
|
||||
user = UserFactory(with_organization=True)
|
||||
service_provider = ServiceProviderFactory()
|
||||
|
||||
other_service_provider = ServiceProviderFactory()
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
"service_providers": [str(other_service_provider.pk)], # ignored
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.service_providers.get().audience_id == service_provider.audience_id
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: delete
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a team."""
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert models.Team.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_teams_delete_not_allowed(client, force_login_via_resource_server):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team from a resource
|
||||
server, even if they have the right permissions.
|
||||
|
||||
This may be implemented in the future, but for now, it is not allowed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, "owner")], service_providers=[service_provider]
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.delete(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "DELETE" not allowed.'}
|
||||
assert models.Team.objects.count() == 1
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list teams."""
|
||||
factories.TeamFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get("/resource-server/v1.0/teams/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
|
||||
client, django_assert_num_queries, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list teams
|
||||
they are an owner/administrator/member of, and only list from the
|
||||
requesting service provider should appear.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
hidden_service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team_access_1 = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[service_provider], role="member"
|
||||
)
|
||||
team_1 = team_access_1.team
|
||||
|
||||
team_access_2 = factories.TeamAccessFactory(
|
||||
user=user,
|
||||
team__service_providers=[hidden_service_provider, service_provider],
|
||||
role="member",
|
||||
)
|
||||
team_2 = team_access_2.team
|
||||
|
||||
team_access_3 = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[service_provider], role="administrator"
|
||||
)
|
||||
team_3 = team_access_3.team
|
||||
|
||||
team_access_4 = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[service_provider], role="owner"
|
||||
)
|
||||
team_4 = team_access_4.team
|
||||
|
||||
# Team filtered out because of the service provider
|
||||
_not_included_team_access = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[hidden_service_provider]
|
||||
)
|
||||
|
||||
# Authenticate using the resource server, ie via the Authorization header
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
with django_assert_num_queries(5):
|
||||
# queries: Team path, Count, Team, ServiceProvider, TeamAccess
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/?ordering=created_at",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"count": 4,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"created_at": team_1.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_1.depth,
|
||||
"id": str(team_1.pk),
|
||||
"name": team_1.name,
|
||||
"numchild": team_1.numchild,
|
||||
"path": team_1.path,
|
||||
"updated_at": team_1.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
"created_at": team_2.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_2.depth,
|
||||
"id": str(team_2.pk),
|
||||
"name": team_2.name,
|
||||
"numchild": team_2.numchild,
|
||||
"path": team_2.path,
|
||||
"updated_at": team_2.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
"created_at": team_3.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_3.depth,
|
||||
"id": str(team_3.pk),
|
||||
"name": team_3.name,
|
||||
"numchild": team_3.numchild,
|
||||
"path": team_3.path,
|
||||
"updated_at": team_3.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
"created_at": team_4.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_4.depth,
|
||||
"id": str(team_4.pk),
|
||||
"name": team_4.name,
|
||||
"numchild": team_4.numchild,
|
||||
"path": team_4.path,
|
||||
"updated_at": team_4.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated_new_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Team list from not yet known service provider should be empty (not fail).
|
||||
|
||||
Teams without service providers should not be listed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
_team = factories.TeamFactory(users=[user])
|
||||
|
||||
with force_login_via_resource_server(client, user, "some_service_provider"):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated_distinct(client, force_login_via_resource_server):
|
||||
"""A team with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[user, other_user], service_providers=[service_provider]
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert content["count"] == 1
|
||||
|
||||
results = content["results"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == str(team.id)
|
||||
|
||||
|
||||
def test_api_teams_order_param(client, force_login_via_resource_server):
|
||||
"""
|
||||
Test that the 'created_at' field is sorted in ascending order
|
||||
when the 'ordering' query parameter is set.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team_ids = [
|
||||
str(team.id)
|
||||
for team in factories.TeamFactory.create_batch(
|
||||
5, users=[user], service_providers=[service_provider]
|
||||
)
|
||||
]
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/?ordering=created_at",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
|
||||
assert response_team_ids == team_ids, (
|
||||
"created_at values are not sorted from oldest to newest"
|
||||
)
|
||||
|
||||
|
||||
def test_api_teams_list_with_parent_teams(client, force_login_via_resource_server):
|
||||
"""
|
||||
Authenticated users should be able to list teams including parent teams.
|
||||
Parent teams should not be listed if they don't have the service provider.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
root_team = factories.TeamFactory(name="Root", service_providers=[service_provider])
|
||||
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second", parent_id=first_team.pk, service_providers=[service_provider]
|
||||
)
|
||||
|
||||
factories.TeamAccessFactory(user=user, team=second_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data["count"] == 2
|
||||
|
||||
team_ids = [team["id"] for team in response_data["results"]]
|
||||
assert len(team_ids) == 2
|
||||
assert set(team_ids) == {str(root_team.id), str(second_team.id)}
|
||||
|
||||
|
||||
def test_api_teams_list_with_parent_teams_other_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list teams including parent teams.
|
||||
Parent teams should not be listed if they don't have the service provider
|
||||
or if the user does not belong to the organization.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
service_providers=[service_provider],
|
||||
organization=other_organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First", parent_id=root_team.pk, organization=other_organization
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=other_organization,
|
||||
)
|
||||
|
||||
factories.TeamAccessFactory(user=user, team=second_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data["count"] == 1
|
||||
|
||||
team_ids = [team["id"] for team in response_data["results"]]
|
||||
assert len(team_ids) == 1
|
||||
assert set(team_ids) == {str(second_team.id)}
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.status import HTTP_404_NOT_FOUND
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.factories import TeamAccessFactory, UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a team."""
|
||||
team = factories.TeamFactory()
|
||||
response = APIClient().get(f"/resource-server/v1.0/teams/{team.id}/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_unrelated(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a team to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team = factories.TeamFactory(service_providers=[service_provider])
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a team to which they
|
||||
are related whatever the role even if the request is authenticated via
|
||||
a resource server.
|
||||
"""
|
||||
service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[user], service_providers=[service_provider])
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 1,
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"numchild": 0,
|
||||
"path": team.path,
|
||||
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_other_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve a team
|
||||
if the request is authenticated via a different resource server.
|
||||
"""
|
||||
user = UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
team = factories.TeamFactory(
|
||||
users=[user], service_providers=[other_service_provider]
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related_parent_same_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a parent team
|
||||
of a team to which they are related, only if they belong to the
|
||||
same organization.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
organization=organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First",
|
||||
parent_id=root_team.pk,
|
||||
organization=organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=organization,
|
||||
)
|
||||
TeamAccessFactory(user=user, team=second_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{first_team.pk}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 2,
|
||||
"id": str(first_team.pk),
|
||||
"name": first_team.name,
|
||||
"numchild": 1,
|
||||
"path": first_team.path,
|
||||
"updated_at": first_team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related_parent_other_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a parent team
|
||||
of a team to which they are related, only if they belong to the
|
||||
same organization.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
organization=other_organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First",
|
||||
parent_id=root_team.pk,
|
||||
organization=other_organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=other_organization,
|
||||
)
|
||||
TeamAccessFactory(user=user, team=second_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{first_team.pk}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related_child_same_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should NOT be allowed to retrieve a child team
|
||||
of a team to which they are related, even if they belong to the
|
||||
same organization.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
organization=organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First",
|
||||
parent_id=root_team.pk,
|
||||
organization=organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=organization,
|
||||
)
|
||||
TeamAccessFactory(user=user, team=first_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{second_team.pk}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: update
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.resource_server import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a team."""
|
||||
team = factories.TeamFactory()
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
response = APIClient().put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_unrelated(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be allowed to update a team to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
team = factories.TeamFactory(service_providers=[service_provider])
|
||||
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated(
|
||||
client,
|
||||
force_login_via_resource_server,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to update a team to which they
|
||||
are related whatever the role even if the request is authenticated via
|
||||
a resource server.
|
||||
"""
|
||||
service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(
|
||||
name="Old name",
|
||||
users=[(user, "owner")],
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
data=serializers.TeamSerializer(instance=team).data
|
||||
| {
|
||||
"name": "New name",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.name == "New name"
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_other_resource_server(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to update a team for which they are directly
|
||||
owner, if the request is authenticated via a different service provider.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
team = factories.TeamFactory(
|
||||
name="Old name",
|
||||
users=[(user, "owner")],
|
||||
service_providers=[other_service_provider],
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
data=serializers.TeamSerializer(instance=team).data
|
||||
| {
|
||||
"name": "New name",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.name == "Old name"
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_members(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Users who are members of a team but not administrators should
|
||||
not be allowed to update it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, "member")], service_providers=[service_provider]
|
||||
)
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_teams_update_authenticated_administrators(
|
||||
client, force_login_via_resource_server, role
|
||||
):
|
||||
"""Administrators or owners of a team should be allowed to update it."""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, role)],
|
||||
service_providers=[service_provider],
|
||||
name="old name",
|
||||
)
|
||||
initial_created_at = team.created_at
|
||||
initial_updated_at = team.updated_at
|
||||
initial_pk = team.pk
|
||||
|
||||
# generate new random values
|
||||
new_values = serializers.TeamSerializer(instance=factories.TeamFactory.build()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.pk == initial_pk
|
||||
assert team.name == new_values["name"]
|
||||
assert team.created_at == initial_created_at
|
||||
assert team.updated_at > initial_updated_at
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_teams_update_administrator_or_owner_of_another(
|
||||
client, force_login_via_resource_server, role
|
||||
):
|
||||
"""
|
||||
Being administrator or owner of a team should not grant authorization to update
|
||||
another team.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
factories.TeamFactory(users=[(user, role)], service_providers=[service_provider])
|
||||
|
||||
team = factories.TeamFactory(name="Old name", service_providers=[service_provider])
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["administrator", "member", "owner"])
|
||||
def test_api_teams_update_parent_team(client, force_login_via_resource_server, role):
|
||||
"""
|
||||
Belonging to a team should NOT grant authorization to update
|
||||
another parent team.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
organization=organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First",
|
||||
parent_id=root_team.pk,
|
||||
organization=organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=organization,
|
||||
)
|
||||
factories.TeamAccessFactory(user=user, team=second_team, role=role)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.patch(
|
||||
f"/resource-server/v1.0/teams/{first_team.pk}/",
|
||||
{
|
||||
"name": "New name",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
first_team.refresh_from_db()
|
||||
assert first_team.name == "First"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["administrator", "member", "owner"])
|
||||
def test_api_teams_update_child_team(client, force_login_via_resource_server, role):
|
||||
"""
|
||||
Belonging to a team should NOT grant authorization to update
|
||||
another child team.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
organization=organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First",
|
||||
parent_id=root_team.pk,
|
||||
organization=organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=organization,
|
||||
)
|
||||
factories.TeamAccessFactory(user=user, team=first_team, role=role)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.patch(
|
||||
f"/resource-server/v1.0/teams/{second_team.pk}/",
|
||||
{
|
||||
"name": "New name",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
second_team.refresh_from_db()
|
||||
assert second_team.name == "Second"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user