Compare commits
	
		
			98 Commits
		
	
	
		
			version/0.
			...
			version/0.
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 895e7d7393 | |||
| 3beca0574d | |||
| 990f5f0a43 | |||
| 97ce143efe | |||
| cbbe174fd8 | |||
| da3c640343 | |||
| 4b39c71de0 | |||
| 818f417fd8 | |||
| f1ccef7f6a | |||
| 6187436518 | |||
| 9559ee7cb9 | |||
| 72e9c4e6fa | |||
| 97b8a025b3 | |||
| ea9687c30b | |||
| 0a5e14a352 | |||
| 0325847c22 | |||
| 491dcc1159 | |||
| 6292049c74 | |||
| 1e97af772f | |||
| 5c622cd4d2 | |||
| c4de808c4e | |||
| 8c604d225b | |||
| c7daadfb18 | |||
| 683968c96e | |||
| c94added99 | |||
| 61c00e5b39 | |||
| 566ebae065 | |||
| 9b62a6403b | |||
| 8c465b2026 | |||
| 6b7da71aa8 | |||
| e95bbfab9a | |||
| e401575894 | |||
| 6428801270 | |||
| 3e13c13619 | |||
| 92f79eb30e | |||
| e7472de4bf | |||
| 494950ac65 | |||
| 4d51295db2 | |||
| 3bbded3555 | |||
| b3262e2a82 | |||
| 40614a65fc | |||
| 3cf558d594 | |||
| 812cc0d2f1 | |||
| e21ed92848 | |||
| 5184c4b7ef | |||
| 2c07859b68 | |||
| ae6304c05e | |||
| 501683e3cb | |||
| cc8afa8706 | |||
| 17a9e02bc0 | |||
| 6a669992a8 | |||
| 7ea5c22b6c | |||
| b11d6a5891 | |||
| 49830367a7 | |||
| e69ca5a229 | |||
| a57d21f5e8 | |||
| c7026407c6 | |||
| 69eecd6b60 | |||
| 810f10edfe | |||
| 1c57128f11 | |||
| 82eade3eb1 | |||
| 56a9dcc88d | |||
| fe70d80189 | |||
| e97e22c58a | |||
| bb4e39aab6 | |||
| a8744f443c | |||
| 7fe9b8f0b4 | |||
| 696aa7e5f6 | |||
| e1d82aee1d | |||
| 151374f565 | |||
| bebeff9f7f | |||
| 8b99afa34d | |||
| b317852e8a | |||
| 24ae35c35a | |||
| 8e6bb48227 | |||
| 7a4e8af1ae | |||
| 0161205c82 | |||
| ca0ba85023 | |||
| c2ebaa7f64 | |||
| 23cccebb96 | |||
| 3f5d30e6fe | |||
| ca735349f9 | |||
| 25ce8c6dc7 | |||
| 081ac0bcdb | |||
| 8a07b349ee | |||
| b3468bc265 | |||
| 4edfad869f | |||
| 404f5d7912 | |||
| 8bea99a953 | |||
| 0b0ba33dce | |||
| e3627b2cd9 | |||
| 37fac3ae00 | |||
| 17a90adf3e | |||
| 7c3590f8ef | |||
| 7471415e7f | |||
| 9339d496f9 | |||
| e72000eb06 | |||
| ec5ff7c14d | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 0.10.0-rc1 | current_version = 0.10.3-stable | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) | ||||||
| @ -15,10 +15,10 @@ values = | |||||||
| 	beta | 	beta | ||||||
| 	stable | 	stable | ||||||
|  |  | ||||||
| [bumpversion:file:README.md] |  | ||||||
|  |  | ||||||
| [bumpversion:file:docs/installation/docker-compose.md] | [bumpversion:file:docs/installation/docker-compose.md] | ||||||
|  |  | ||||||
|  | [bumpversion:file:docs/installation/kubernetes.md] | ||||||
|  |  | ||||||
| [bumpversion:file:docker-compose.yml] | [bumpversion:file:docker-compose.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:helm/values.yaml] | [bumpversion:file:helm/values.yaml] | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| [run] | [run] | ||||||
| source = passbook | source = passbook | ||||||
|  | relative_files = true | ||||||
| omit = | omit = | ||||||
|     */asgi.py |     */asgi.py | ||||||
|     manage.py |     manage.py | ||||||
|  | |||||||
							
								
								
									
										34
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										34
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,6 +1,8 @@ | |||||||
| name: passbook-release | name: passbook-on-release | ||||||
|  |  | ||||||
| on: | on: | ||||||
|   release |   release: | ||||||
|  |     types: [published, created] | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   # Build |   # Build | ||||||
| @ -16,17 +18,26 @@ jobs: | |||||||
|       - name: Building Docker Image |       - name: Building Docker Image | ||||||
|         run: docker build |         run: docker build | ||||||
|           --no-cache |           --no-cache | ||||||
|           -t beryju/passbook:0.10.0-rc1 |           -t beryju/passbook:0.10.3-stable | ||||||
|           -t beryju/passbook:latest |           -t beryju/passbook:latest | ||||||
|           -f Dockerfile . |           -f Dockerfile . | ||||||
|       - name: Push Docker Container to Registry (versioned) |       - name: Push Docker Container to Registry (versioned) | ||||||
|         run: docker push beryju/passbook:0.10.0-rc1 |         run: docker push beryju/passbook:0.10.3-stable | ||||||
|       - name: Push Docker Container to Registry (latest) |       - name: Push Docker Container to Registry (latest) | ||||||
|         run: docker push beryju/passbook:latest |         run: docker push beryju/passbook:latest | ||||||
|   build-proxy: |   build-proxy: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v1 |       - uses: actions/checkout@v1 | ||||||
|  |       - uses: actions/setup-go@v2 | ||||||
|  |         with: | ||||||
|  |           go-version: "^1.15" | ||||||
|  |       - name: prepare go api client | ||||||
|  |         run: | | ||||||
|  |           cd proxy | ||||||
|  |           go get -u github.com/go-swagger/go-swagger/cmd/swagger | ||||||
|  |           swagger generate client -f ../swagger.yaml -A passbook -t pkg/ | ||||||
|  |           go build -v . | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
|         env: |         env: | ||||||
|           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} |           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} | ||||||
| @ -37,11 +48,11 @@ jobs: | |||||||
|           cd proxy |           cd proxy | ||||||
|           docker build \ |           docker build \ | ||||||
|           --no-cache \ |           --no-cache \ | ||||||
|           -t beryju/passbook-proxy:0.10.0-rc1 \ |           -t beryju/passbook-proxy:0.10.3-stable \ | ||||||
|           -t beryju/passbook-proxy:latest \ |           -t beryju/passbook-proxy:latest \ | ||||||
|           -f Dockerfile . |           -f Dockerfile . | ||||||
|       - name: Push Docker Container to Registry (versioned) |       - name: Push Docker Container to Registry (versioned) | ||||||
|         run: docker push beryju/passbook-proxy:0.10.0-rc1 |         run: docker push beryju/passbook-proxy:0.10.3-stable | ||||||
|       - name: Push Docker Container to Registry (latest) |       - name: Push Docker Container to Registry (latest) | ||||||
|         run: docker push beryju/passbook-proxy:latest |         run: docker push beryju/passbook-proxy:latest | ||||||
|   build-static: |   build-static: | ||||||
| @ -66,11 +77,11 @@ jobs: | |||||||
|         run: docker build |         run: docker build | ||||||
|           --no-cache |           --no-cache | ||||||
|           --network=$(docker network ls | grep github | awk '{print $1}') |           --network=$(docker network ls | grep github | awk '{print $1}') | ||||||
|           -t beryju/passbook-static:0.10.0-rc1 |           -t beryju/passbook-static:0.10.3-stable | ||||||
|           -t beryju/passbook-static:latest |           -t beryju/passbook-static:latest | ||||||
|           -f static.Dockerfile . |           -f static.Dockerfile . | ||||||
|       - name: Push Docker Container to Registry (versioned) |       - name: Push Docker Container to Registry (versioned) | ||||||
|         run: docker push beryju/passbook-static:0.10.0-rc1 |         run: docker push beryju/passbook-static:0.10.3-stable | ||||||
|       - name: Push Docker Container to Registry (latest) |       - name: Push Docker Container to Registry (latest) | ||||||
|         run: docker push beryju/passbook-static:latest |         run: docker push beryju/passbook-static:latest | ||||||
|   test-release: |   test-release: | ||||||
| @ -82,10 +93,13 @@ jobs: | |||||||
|       - uses: actions/checkout@v1 |       - uses: actions/checkout@v1 | ||||||
|       - name: Run test suite in final docker images |       - name: Run test suite in final docker images | ||||||
|         run: | |         run: | | ||||||
|  |           sudo apt-get install -y pwgen | ||||||
|  |           echo "PG_PASS=$(pwgen 40 1)" >> .env | ||||||
|  |           echo "PASSBOOK_SECRET_KEY=$(pwgen 50 1)" >> .env | ||||||
|           docker-compose pull -q |           docker-compose pull -q | ||||||
|           docker-compose up --no-start |           docker-compose up --no-start | ||||||
|           docker-compose start postgresql redis |           docker-compose start postgresql redis | ||||||
|           docker-compose run -u root server bash -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test" |           docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook" | ||||||
|   sentry-release: |   sentry-release: | ||||||
|     needs: |     needs: | ||||||
|       - test-release |       - test-release | ||||||
| @ -100,5 +114,5 @@ jobs: | |||||||
|           SENTRY_PROJECT: passbook |           SENTRY_PROJECT: passbook | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           tagName: 0.10.0-rc1 |           tagName: 0.10.3-stable | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,10 +1,10 @@ | |||||||
|  | name: passbook-on-tag | ||||||
|  |  | ||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     tags: |     tags: | ||||||
|     - 'version/*' |     - 'version/*' | ||||||
|  |  | ||||||
| name: passbook-version-tag |  | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   build: |   build: | ||||||
|     name: Create Release from Tag |     name: Create Release from Tag | ||||||
| @ -13,6 +13,10 @@ jobs: | |||||||
|       - uses: actions/checkout@master |       - uses: actions/checkout@master | ||||||
|       - name: Pre-release test |       - name: Pre-release test | ||||||
|         run: | |         run: | | ||||||
|  |           sudo apt-get install -y pwgen | ||||||
|  |           echo "PASSBOOK_TAG=latest" >> .env | ||||||
|  |           echo "PG_PASS=$(pwgen 40 1)" >> .env | ||||||
|  |           echo "PASSBOOK_SECRET_KEY=$(pwgen 50 1)" >> .env | ||||||
|           docker-compose pull -q |           docker-compose pull -q | ||||||
|           docker build \ |           docker build \ | ||||||
|             --no-cache \ |             --no-cache \ | ||||||
| @ -20,7 +24,7 @@ jobs: | |||||||
|             -f Dockerfile . |             -f Dockerfile . | ||||||
|           docker-compose up --no-start |           docker-compose up --no-start | ||||||
|           docker-compose start postgresql redis |           docker-compose start postgresql redis | ||||||
|           docker-compose run -u root server bash -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test" |           docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook" | ||||||
|       - name: Install Helm |       - name: Install Helm | ||||||
|         run: | |         run: | | ||||||
|           apt update && apt install -y curl |           apt update && apt install -y curl | ||||||
| @ -30,7 +34,7 @@ jobs: | |||||||
|           helm dependency update helm/ |           helm dependency update helm/ | ||||||
|           helm package helm/ |           helm package helm/ | ||||||
|           mv passbook-*.tgz passbook-chart.tgz |           mv passbook-*.tgz passbook-chart.tgz | ||||||
|       - name: Extract verison number |       - name: Extract version number | ||||||
|         id: get_version |         id: get_version | ||||||
|         uses: actions/github-script@0.2.0 |         uses: actions/github-script@0.2.0 | ||||||
|         with: |         with: | ||||||
| @ -45,7 +49,7 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           tag_name: ${{ github.ref }} |           tag_name: ${{ github.ref }} | ||||||
|           release_name: Release ${{ steps.get_version.outputs.result }} |           release_name: Release ${{ steps.get_version.outputs.result }} | ||||||
|           draft: false |           draft: true | ||||||
|           prerelease: false |           prerelease: false | ||||||
|       - name: Upload packaged Helm Chart |       - name: Upload packaged Helm Chart | ||||||
|         id: upload-release-asset |         id: upload-release-asset | ||||||
|  | |||||||
							
								
								
									
										8
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								Makefile
									
									
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| all: lint-fix lint coverage gen | all: lint-fix lint coverage gen | ||||||
|  |  | ||||||
| coverage: | coverage: | ||||||
| 	coverage run --concurrency=multiprocessing manage.py test passbook --failfast | 	coverage run --concurrency=multiprocessing manage.py test --failfast -v 3 | ||||||
| 	coverage combine | 	coverage combine | ||||||
| 	coverage html | 	coverage html | ||||||
| 	coverage report | 	coverage report | ||||||
| @ -18,3 +18,9 @@ lint: | |||||||
|  |  | ||||||
| gen: coverage | gen: coverage | ||||||
| 	./manage.py generate_swagger -o swagger.yaml -f yaml | 	./manage.py generate_swagger -o swagger.yaml -f yaml | ||||||
|  |  | ||||||
|  | local-stack: | ||||||
|  | 	export PASSBOOK_TAG=testing | ||||||
|  | 	docker build -t beryju/passbook:testng . | ||||||
|  | 	docker-compose up -d | ||||||
|  | 	docker-compose run --rm server migrate | ||||||
|  | |||||||
							
								
								
									
										3
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								Pipfile
									
									
									
									
									
								
							| @ -59,5 +59,6 @@ docker = "*" | |||||||
| pylint = "*" | pylint = "*" | ||||||
| pylint-django = "*" | pylint-django = "*" | ||||||
| selenium = "*" | selenium = "*" | ||||||
| unittest-xml-reporting = "*" |  | ||||||
| prospector = "*" | prospector = "*" | ||||||
|  | pytest = "*" | ||||||
|  | pytest-django = "*" | ||||||
|  | |||||||
							
								
								
									
										244
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										244
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|     "_meta": { |     "_meta": { | ||||||
|         "hash": { |         "hash": { | ||||||
|             "sha256": "a798bbd0b97857cac136c1743b8d6ad8bf8c3d95e2760c71d324bb2a7f47f678" |             "sha256": "80570636236962f4b934a884817292de9f7bb48520aa964afc2959b0f795fb57" | ||||||
|         }, |         }, | ||||||
|         "pipfile-spec": 6, |         "pipfile-spec": 6, | ||||||
|         "requires": { |         "requires": { | ||||||
| @ -74,18 +74,17 @@ | |||||||
|         }, |         }, | ||||||
|         "boto3": { |         "boto3": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2ab73b0c400ab8c7df84bee7564ef8a0813021da28dd7a05fcbffb77a8ae9de9", |                 "sha256:25c716b7c01d4664027afc6a6418a06459e311a610c7fd39a030a1ced1b72ce4" | ||||||
|                 "sha256:bb2222fa02fcd09b39e581e532d4f013ea850742d8cd46e9c10a21028b6d2ef5" |  | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.14.56" |             "version": "==1.14.63" | ||||||
|         }, |         }, | ||||||
|         "botocore": { |         "botocore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:37cc3f1013c00dc0f061582198d6b785dadf147bd99307d41c5c0e47debca65c", |                 "sha256:40f13f6c9c29c307a9dc5982739e537ddce55b29787b90c3447b507e3283bcd6", | ||||||
|                 "sha256:acd2df778a5e12b2a16ac040ce6e91a6c6f2d7ac67bd4f966472ce5c68b5b62d" |                 "sha256:aa88eafc6295132f4bc606f1df32b3248e0fa611724c0a216aceda767948ac75" | ||||||
|             ], |             ], | ||||||
|             "version": "==1.17.58" |             "version": "==1.17.63" | ||||||
|         }, |         }, | ||||||
|         "cachetools": { |         "cachetools": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -111,36 +110,44 @@ | |||||||
|         }, |         }, | ||||||
|         "cffi": { |         "cffi": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", |                 "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", | ||||||
|                 "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", |                 "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", | ||||||
|                 "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", |                 "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", | ||||||
|                 "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", |                 "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", | ||||||
|                 "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", |                 "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", | ||||||
|                 "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", |                 "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", | ||||||
|                 "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", |                 "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", | ||||||
|                 "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", |                 "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", | ||||||
|                 "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", |                 "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", | ||||||
|                 "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", |                 "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", | ||||||
|                 "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", |                 "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", | ||||||
|                 "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", |                 "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", | ||||||
|                 "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", |                 "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", | ||||||
|                 "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", |                 "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", | ||||||
|                 "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", |                 "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", | ||||||
|                 "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", |                 "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", | ||||||
|                 "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", |                 "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", | ||||||
|                 "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", |                 "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", | ||||||
|                 "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", |                 "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", | ||||||
|                 "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", |                 "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", | ||||||
|                 "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", |                 "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", | ||||||
|                 "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", |                 "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", | ||||||
|                 "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", |                 "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", | ||||||
|                 "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", |                 "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", | ||||||
|                 "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", |                 "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", | ||||||
|                 "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", |                 "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", | ||||||
|                 "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", |                 "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", | ||||||
|                 "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" |                 "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", | ||||||
|  |                 "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", | ||||||
|  |                 "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", | ||||||
|  |                 "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", | ||||||
|  |                 "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", | ||||||
|  |                 "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", | ||||||
|  |                 "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", | ||||||
|  |                 "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", | ||||||
|  |                 "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" | ||||||
|             ], |             ], | ||||||
|             "version": "==1.14.2" |             "version": "==1.14.3" | ||||||
|         }, |         }, | ||||||
|         "channels": { |         "channels": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -327,11 +334,11 @@ | |||||||
|         }, |         }, | ||||||
|         "django-storages": { |         "django-storages": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:1e37da57678e6cf1e9914f84099a305323e4e1f261afe54fdb703cae7aa6fbc3", |                 "sha256:12de8fb2605b9b57bfaf54b075280d7cbb3b3ee1ca4bc9b9add147af87fe3a2c", | ||||||
|                 "sha256:36ed8dab33d761954498189592ce005920095fcbc02dab4184eb51393c370991" |                 "sha256:652275ab7844538c462b62810276c0244866f345878256a9e0e86f5b1283ae18" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.10" |             "version": "==1.10.1" | ||||||
|         }, |         }, | ||||||
|         "djangorestframework": { |         "djangorestframework": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -387,10 +394,10 @@ | |||||||
|         }, |         }, | ||||||
|         "google-auth": { |         "google-auth": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:bcbd9f970e7144fe933908aa286d7a12c44b7deb6d78a76871f0377a29d09789", |                 "sha256:7084c50c03f7a8a5696ef4500e65df0c525a0f6909f3c70b9ee65900a230c755", | ||||||
|                 "sha256:f4d5093f13b1b1c0a434ab1dc851cd26a983f86a4d75c95239974e33ed406a87" |                 "sha256:dcf86c5adc3a8a7659be190b12bb8912ae019cfd9ee2a571ea881e289fafbe39" | ||||||
|             ], |             ], | ||||||
|             "version": "==1.21.1" |             "version": "==1.21.2" | ||||||
|         }, |         }, | ||||||
|         "gunicorn": { |         "gunicorn": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -835,9 +842,9 @@ | |||||||
|         }, |         }, | ||||||
|         "pyrsistent": { |         "pyrsistent": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:27515d2d5db0629c7dadf6fbe76973eb56f098c1b01d36de42eb69220d2c19e4" |                 "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.17.2" |             "version": "==0.17.3" | ||||||
|         }, |         }, | ||||||
|         "python-dateutil": { |         "python-dateutil": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -952,11 +959,11 @@ | |||||||
|         }, |         }, | ||||||
|         "sentry-sdk": { |         "sentry-sdk": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:97bff68e57402ad39674e6fe2545df0d5eea41c3d51e280c170761705c8c20ff", |                 "sha256:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24", | ||||||
|                 "sha256:a16caf9ce892623081cbb9a95f6c1f892778bb123909b0ed7afdfb52ce7a58a1" |                 "sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==0.17.4" |             "version": "==0.17.6" | ||||||
|         }, |         }, | ||||||
|         "service-identity": { |         "service-identity": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1269,43 +1276,43 @@ | |||||||
|         }, |         }, | ||||||
|         "coverage": { |         "coverage": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb", |                 "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", | ||||||
|                 "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3", |                 "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", | ||||||
|                 "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716", |                 "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", | ||||||
|                 "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034", |                 "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", | ||||||
|                 "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3", |                 "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", | ||||||
|                 "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8", |                 "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", | ||||||
|                 "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0", |                 "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", | ||||||
|                 "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f", |                 "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", | ||||||
|                 "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4", |                 "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", | ||||||
|                 "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962", |                 "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", | ||||||
|                 "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d", |                 "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", | ||||||
|                 "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b", |                 "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", | ||||||
|                 "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4", |                 "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", | ||||||
|                 "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3", |                 "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", | ||||||
|                 "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258", |                 "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", | ||||||
|                 "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59", |                 "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", | ||||||
|                 "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01", |                 "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", | ||||||
|                 "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd", |                 "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", | ||||||
|                 "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b", |                 "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", | ||||||
|                 "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d", |                 "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", | ||||||
|                 "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89", |                 "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", | ||||||
|                 "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd", |                 "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", | ||||||
|                 "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b", |                 "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", | ||||||
|                 "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d", |                 "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", | ||||||
|                 "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46", |                 "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", | ||||||
|                 "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546", |                 "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", | ||||||
|                 "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082", |                 "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", | ||||||
|                 "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b", |                 "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", | ||||||
|                 "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4", |                 "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", | ||||||
|                 "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8", |                 "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", | ||||||
|                 "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811", |                 "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", | ||||||
|                 "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd", |                 "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", | ||||||
|                 "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651", |                 "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", | ||||||
|                 "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0" |                 "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==5.2.1" |             "version": "==5.3" | ||||||
|         }, |         }, | ||||||
|         "django": { |         "django": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1373,6 +1380,13 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==2.10" |             "version": "==2.10" | ||||||
|         }, |         }, | ||||||
|  |         "iniconfig": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", | ||||||
|  |                 "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69" | ||||||
|  |             ], | ||||||
|  |             "version": "==1.0.1" | ||||||
|  |         }, | ||||||
|         "isort": { |         "isort": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", |                 "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", | ||||||
| @ -1413,6 +1427,21 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==0.6.1" |             "version": "==0.6.1" | ||||||
|         }, |         }, | ||||||
|  |         "more-itertools": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20", | ||||||
|  |                 "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c" | ||||||
|  |             ], | ||||||
|  |             "version": "==8.5.0" | ||||||
|  |         }, | ||||||
|  |         "packaging": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", | ||||||
|  |                 "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==20.4" | ||||||
|  |         }, | ||||||
|         "pathspec": { |         "pathspec": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", |                 "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", | ||||||
| @ -1434,6 +1463,13 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==0.10.0" |             "version": "==0.10.0" | ||||||
|         }, |         }, | ||||||
|  |         "pluggy": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", | ||||||
|  |                 "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" | ||||||
|  |             ], | ||||||
|  |             "version": "==0.13.1" | ||||||
|  |         }, | ||||||
|         "prospector": { |         "prospector": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:43e5e187c027336b0e4c4aa6a82d66d3b923b5ec5b51968126132e32f9d14a2f" |                 "sha256:43e5e187c027336b0e4c4aa6a82d66d3b923b5ec5b51968126132e32f9d14a2f" | ||||||
| @ -1441,6 +1477,13 @@ | |||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.3.0" |             "version": "==1.3.0" | ||||||
|         }, |         }, | ||||||
|  |         "py": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", | ||||||
|  |                 "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" | ||||||
|  |             ], | ||||||
|  |             "version": "==1.9.0" | ||||||
|  |         }, | ||||||
|         "pycodestyle": { |         "pycodestyle": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", |                 "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", | ||||||
| @ -1497,6 +1540,29 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==0.6" |             "version": "==0.6" | ||||||
|         }, |         }, | ||||||
|  |         "pyparsing": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", | ||||||
|  |                 "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" | ||||||
|  |             ], | ||||||
|  |             "version": "==2.4.7" | ||||||
|  |         }, | ||||||
|  |         "pytest": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:0e37f61339c4578776e090c3b8f6b16ce4db333889d65d0efb305243ec544b40", | ||||||
|  |                 "sha256:c8f57c2a30983f469bf03e68cdfa74dc474ce56b8f280ddcb080dfd91df01043" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==6.0.2" | ||||||
|  |         }, | ||||||
|  |         "pytest-django": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:4de6dbd077ed8606616958f77655fed0d5e3ee45159475671c7fa67596c6dba6", | ||||||
|  |                 "sha256:c33e3d3da14d8409b125d825d4e74da17bb252191bf6fc3da6856e27a8b73ea4" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==3.10.0" | ||||||
|  |         }, | ||||||
|         "pytz": { |         "pytz": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", |                 "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", | ||||||
| @ -1604,10 +1670,10 @@ | |||||||
|         }, |         }, | ||||||
|         "stevedore": { |         "stevedore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:a34086819e2c7a7f86d5635363632829dab8014e5fd7be2454c7cba84ac7514e", |                 "sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62", | ||||||
|                 "sha256:ddc09a744dc224c84ec8e8efcb70595042d21c97c76df60daee64c9ad53bc7ee" |                 "sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0" | ||||||
|             ], |             ], | ||||||
|             "version": "==3.2.1" |             "version": "==3.2.2" | ||||||
|         }, |         }, | ||||||
|         "toml": { |         "toml": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1642,14 +1708,6 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==1.4.1" |             "version": "==1.4.1" | ||||||
|         }, |         }, | ||||||
|         "unittest-xml-reporting": { |  | ||||||
|             "hashes": [ |  | ||||||
|                 "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca", |  | ||||||
|                 "sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695" |  | ||||||
|             ], |  | ||||||
|             "index": "pypi", |  | ||||||
|             "version": "==3.0.4" |  | ||||||
|         }, |  | ||||||
|         "urllib3": { |         "urllib3": { | ||||||
|             "extras": [ |             "extras": [ | ||||||
|                 "secure" |                 "secure" | ||||||
|  | |||||||
							
								
								
									
										17
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								README.md
									
									
									
									
									
								
							| @ -1,4 +1,4 @@ | |||||||
| <img src="passbook/static/static/passbook/logo.svg" height="50" alt="passbook logo"><img src="passbook/static/static/passbook/brand_inverted.svg" height="50" alt="passbook"> | <img src="docs/images/logo.svg" height="50" alt="passbook logo"><img src="docs/images/brand_inverted.svg" height="50" alt="passbook"> | ||||||
|  |  | ||||||
| [](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=1) | [](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=1) | ||||||
|  |  | ||||||
| @ -13,20 +13,7 @@ passbook is an open-source Identity Provider focused on flexibility and versatil | |||||||
|  |  | ||||||
| ## Installation | ## Installation | ||||||
|  |  | ||||||
| For small/test setups it is recommended to use docker-compose. | For small/test setups it is recommended to use docker-compose, see the [documentation](https://passbook.beryju.org/installation/docker-compose/) | ||||||
|  |  | ||||||
| ``` |  | ||||||
| wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml |  | ||||||
| # Optionally enable Error-reporting |  | ||||||
| # export PASSBOOK_ERROR_REPORTING=true |  | ||||||
| # Optionally deploy a different version |  | ||||||
| # export PASSBOOK_TAG=0.10.0-rc1 |  | ||||||
| # If this is a productive installation, set a different PostgreSQL Password |  | ||||||
| # export PG_PASS=$(pwgen 40 1) |  | ||||||
| docker-compose pull |  | ||||||
| docker-compose up -d |  | ||||||
| docker-compose run --rm server migrate |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| For bigger setups, there is a Helm Chart in the `helm/` directory. This is documented [here](https://passbook.beryju.org//installation/kubernetes/) | For bigger setups, there is a Helm Chart in the `helm/` directory. This is documented [here](https://passbook.beryju.org//installation/kubernetes/) | ||||||
|  |  | ||||||
|  | |||||||
| @ -139,7 +139,7 @@ stages: | |||||||
|             displayName: Run full test suite |             displayName: Run full test suite | ||||||
|             inputs: |             inputs: | ||||||
|               script: | |               script: | | ||||||
|                 pipenv run coverage run ./manage.py test passbook |                 pipenv run coverage run ./manage.py test passbook -v 3 | ||||||
|                 mkdir output-unittest |                 mkdir output-unittest | ||||||
|                 mv unittest.xml output-unittest/unittest.xml |                 mv unittest.xml output-unittest/unittest.xml | ||||||
|                 mv .coverage output-unittest/coverage |                 mv .coverage output-unittest/coverage | ||||||
| @ -150,7 +150,7 @@ stages: | |||||||
|               publishLocation: 'pipeline' |               publishLocation: 'pipeline' | ||||||
|       - job: coverage_e2e |       - job: coverage_e2e | ||||||
|         pool: |         pool: | ||||||
|           vmImage: 'ubuntu-latest' |           name: coventry | ||||||
|         steps: |         steps: | ||||||
|           - task: UsePythonVersion@0 |           - task: UsePythonVersion@0 | ||||||
|             inputs: |             inputs: | ||||||
| @ -181,7 +181,14 @@ stages: | |||||||
|           - task: CmdLine@2 |           - task: CmdLine@2 | ||||||
|             displayName: Run full test suite |             displayName: Run full test suite | ||||||
|             inputs: |             inputs: | ||||||
|               script: pipenv run coverage run ./manage.py test e2e |               script: | | ||||||
|  |                 pipenv run coverage run ./manage.py test e2e -v 3 | ||||||
|  |           - task: CmdLine@2 | ||||||
|  |             condition: always() | ||||||
|  |             displayName: Cleanup | ||||||
|  |             inputs: | ||||||
|  |               script: | | ||||||
|  |                 docker stop $(docker ps -aq) | ||||||
|           - task: CmdLine@2 |           - task: CmdLine@2 | ||||||
|             displayName: Prepare unittests and coverage for upload |             displayName: Prepare unittests and coverage for upload | ||||||
|             inputs: |             inputs: | ||||||
| @ -225,11 +232,9 @@ stages: | |||||||
|               script: | |               script: | | ||||||
|                 sudo pip install -U wheel pipenv |                 sudo pip install -U wheel pipenv | ||||||
|                 pipenv install --dev |                 pipenv install --dev | ||||||
|                 find . |  | ||||||
|                 pipenv run coverage combine coverage-e2e/coverage coverage-unittest/coverage |                 pipenv run coverage combine coverage-e2e/coverage coverage-unittest/coverage | ||||||
|                 pipenv run coverage xml |                 pipenv run coverage xml | ||||||
|                 pipenv run coverage html |                 pipenv run coverage html | ||||||
|                 find . |  | ||||||
|           - task: PublishCodeCoverageResults@1 |           - task: PublishCodeCoverageResults@1 | ||||||
|             inputs: |             inputs: | ||||||
|               codeCoverageTool: 'Cobertura' |               codeCoverageTool: 'Cobertura' | ||||||
|  | |||||||
| @ -14,6 +14,8 @@ services: | |||||||
|       - POSTGRES_DB=passbook |       - POSTGRES_DB=passbook | ||||||
|     labels: |     labels: | ||||||
|       - traefik.enable=false |       - traefik.enable=false | ||||||
|  |     env_file: | ||||||
|  |       - .env | ||||||
|   redis: |   redis: | ||||||
|     image: redis |     image: redis | ||||||
|     networks: |     networks: | ||||||
| @ -21,13 +23,12 @@ services: | |||||||
|     labels: |     labels: | ||||||
|       - traefik.enable=false |       - traefik.enable=false | ||||||
|   server: |   server: | ||||||
|     image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-rc1} |     image: beryju/passbook:${PASSBOOK_TAG:-0.10.3-stable} | ||||||
|     command: server |     command: server | ||||||
|     environment: |     environment: | ||||||
|       PASSBOOK_REDIS__HOST: redis |       PASSBOOK_REDIS__HOST: redis | ||||||
|       PASSBOOK_ERROR_REPORTING: ${PASSBOOK_ERROR_REPORTING:-false} |  | ||||||
|       PASSBOOK_POSTGRESQL__HOST: postgresql |       PASSBOOK_POSTGRESQL__HOST: postgresql | ||||||
|       PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS:-thisisnotagoodpassword} |       PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS} | ||||||
|       PASSBOOK_LOG_LEVEL: debug |       PASSBOOK_LOG_LEVEL: debug | ||||||
|     ports: |     ports: | ||||||
|       - 8000 |       - 8000 | ||||||
| @ -37,8 +38,10 @@ services: | |||||||
|       - traefik.port=8000 |       - traefik.port=8000 | ||||||
|       - traefik.docker.network=internal |       - traefik.docker.network=internal | ||||||
|       - traefik.frontend.rule=PathPrefix:/ |       - traefik.frontend.rule=PathPrefix:/ | ||||||
|  |     env_file: | ||||||
|  |       - .env | ||||||
|   worker: |   worker: | ||||||
|     image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-rc1} |     image: beryju/passbook:${PASSBOOK_TAG:-0.10.3-stable} | ||||||
|     command: worker |     command: worker | ||||||
|     networks: |     networks: | ||||||
|       - internal |       - internal | ||||||
| @ -46,12 +49,13 @@ services: | |||||||
|       - traefik.enable=false |       - traefik.enable=false | ||||||
|     environment: |     environment: | ||||||
|       PASSBOOK_REDIS__HOST: redis |       PASSBOOK_REDIS__HOST: redis | ||||||
|       PASSBOOK_ERROR_REPORTING: ${PASSBOOK_ERROR_REPORTING:-false} |  | ||||||
|       PASSBOOK_POSTGRESQL__HOST: postgresql |       PASSBOOK_POSTGRESQL__HOST: postgresql | ||||||
|       PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS:-thisisnotagoodpassword} |       PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS} | ||||||
|       PASSBOOK_LOG_LEVEL: debug |       PASSBOOK_LOG_LEVEL: debug | ||||||
|  |     env_file: | ||||||
|  |       - .env | ||||||
|   static: |   static: | ||||||
|     image: beryju/passbook-static:0.10.0-rc1 |     image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.3-stable} | ||||||
|     networks: |     networks: | ||||||
|       - internal |       - internal | ||||||
|     labels: |     labels: | ||||||
|  | |||||||
| @ -11,14 +11,21 @@ This installation method is for test-setups and small-scale productive setups. | |||||||
|  |  | ||||||
| Download the latest `docker-compose.yml` from [here](https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml). Place it in a directory of your choice. | Download the latest `docker-compose.yml` from [here](https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml). Place it in a directory of your choice. | ||||||
|  |  | ||||||
|  | To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING=true >> .env` | ||||||
|  |  | ||||||
|  | To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.3-stable >> .env` | ||||||
|  |  | ||||||
|  | If this is a fresh passbook install run the following commands to generate a password: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | sudo apt-get install -y pwgen | ||||||
|  | echo "PG_PASS=$(pwgen 40 1)" >> .env | ||||||
|  | echo "PASSBOOK_SECRET_KEY=$(pwgen 50 1)" >> .env | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Afterwards, run these commands to finish | ||||||
|  |  | ||||||
| ``` | ``` | ||||||
| wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml |  | ||||||
| # Optionally enable Error-reporting |  | ||||||
| # export PASSBOOK_ERROR_REPORTING=true |  | ||||||
| # Optionally deploy a different version |  | ||||||
| # export PASSBOOK_TAG=0.10.0-rc1 |  | ||||||
| # If this is a productive installation, set a different PostgreSQL Password |  | ||||||
| # export PG_PASS=$(pwgen 40 1) |  | ||||||
| docker-compose pull | docker-compose pull | ||||||
| docker-compose up -d | docker-compose up -d | ||||||
| docker-compose run --rm server migrate | docker-compose run --rm server migrate | ||||||
|  | |||||||
| @ -11,7 +11,7 @@ This installation automatically applies database migrations on startup. After th | |||||||
| image: | image: | ||||||
|   name: beryju/passbook |   name: beryju/passbook | ||||||
|   name_static: beryju/passbook-static |   name_static: beryju/passbook-static | ||||||
|   tag: 0.9.0-stable |   tag: 0.10.3-stable | ||||||
|  |  | ||||||
| nameOverride: "" | nameOverride: "" | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										77
									
								
								docs/integrations/services/vmware-vcenter/index.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								docs/integrations/services/vmware-vcenter/index.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,77 @@ | |||||||
|  | # VMware vCenter Integration | ||||||
|  |  | ||||||
|  | ## What is vCenter | ||||||
|  |  | ||||||
|  | From https://en.wikipedia.org/wiki/VCenter | ||||||
|  |  | ||||||
|  | !!! note "" | ||||||
|  |  | ||||||
|  |     vCenter Server is the centralized management utility for VMware, and is used to manage virtual machines, multiple ESXi hosts, and all dependent components from a single centralized location. VMware vMotion and svMotion require the use of vCenter and ESXi hosts. | ||||||
|  |  | ||||||
|  | !!! warning | ||||||
|  |  | ||||||
|  |     This requires passbook 0.10.3 or newer. | ||||||
|  |  | ||||||
|  | !!! warning | ||||||
|  |  | ||||||
|  |     This requires VMware vCenter 7.0.0 or newer. | ||||||
|  |  | ||||||
|  | ## Preparation | ||||||
|  |  | ||||||
|  | The following placeholders will be used: | ||||||
|  |  | ||||||
|  |  - `vcenter.company` is the FQDN of the vCenter server. | ||||||
|  |  - `passbook.company` is the FQDN of the passbook install. | ||||||
|  |  | ||||||
|  | Since vCenter only allows OpenID-Connect in combination with Active Directory, it is recommended to have passbook sync with the same Active Directory. | ||||||
|  |  | ||||||
|  | ### Step 1 | ||||||
|  |  | ||||||
|  | Under *Property Mappings*, create a *Scope Mapping*. Give it a name like "OIDC-Scope-VMware-vCenter". Set the scope name to `openid` and the expression to the following | ||||||
|  |  | ||||||
|  | ```python | ||||||
|  | return { | ||||||
|  |   "domain": "<your active directory domain>", | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Step 2 | ||||||
|  |  | ||||||
|  | !!! note | ||||||
|  |     If your Active Directory Schema is the same as your Email address schema, skip to Step 3. | ||||||
|  |  | ||||||
|  | Under *Sources*, click *Edit* and ensure that "Autogenerated Active Directory Mapping: userPrincipalName -> attributes.upn" has been added to your source. | ||||||
|  |  | ||||||
|  | ### Step 3 | ||||||
|  |  | ||||||
|  | Under *Providers*, create an OAuth2/OpenID Provider with these settings: | ||||||
|  |  | ||||||
|  |  - Client Type: Confidential | ||||||
|  |  - Response Type: code | ||||||
|  |  - JWT Algorithm: RS256 | ||||||
|  |  - Redirect URI: `https://vcenter.company/ui/login/oauth2/authcode` | ||||||
|  |  - Post Logout Redirect URIs: `https://vcenter.company/ui/login` | ||||||
|  |  - Sub Mode: If your Email address Schema matches your UPN, select "Based on the User's Email...", otherwise select "Based on the User's UPN...". | ||||||
|  |  - Scopes: Select the Scope Mapping you've created in Step 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ### Step 4 | ||||||
|  |  | ||||||
|  | Create an application which uses this provider. Optionally apply access restrictions to the application. | ||||||
|  |  | ||||||
|  | ## vCenter Setup | ||||||
|  |  | ||||||
|  | Login as local Administrator account (most likely ends with vsphere.local). Using the Menu in the Navigation bar, navigate to *Administration -> Single Sing-on -> Configuration*. | ||||||
|  |  | ||||||
|  | Click on *Change Identity Provider* in the top-right corner. | ||||||
|  |  | ||||||
|  | In the wizard, select "Microsoft ADFS" and click Next. | ||||||
|  |  | ||||||
|  | Fill in the Client Identifier and Shared Secret from the Provider in passbook. For the OpenID Address, click on *View Setup URLs* in passbook, and copy the OpenID Configuration URL. | ||||||
|  |  | ||||||
|  | On the next page, fill in your Active Directory Connection Details. These should be similar to what you have set in passbook. | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | If your vCenter was already setup with LDAP beforehand, your Role assignments will continue to work. | ||||||
							
								
								
									
										
											BIN
										
									
								
								docs/integrations/services/vmware-vcenter/passbook_setup.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/integrations/services/vmware-vcenter/passbook_setup.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 173 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/integrations/services/vmware-vcenter/vcenter_post_setup.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/integrations/services/vmware-vcenter/vcenter_post_setup.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 89 KiB | 
							
								
								
									
										20
									
								
								docs/outposts/deploy-docker-compose.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								docs/outposts/deploy-docker-compose.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | # Outpost deployment in docker-compose | ||||||
|  |  | ||||||
|  | To deploy an outpost with docker-compose, use  this snippet in your docker-compose file. | ||||||
|  |  | ||||||
|  | You can also run the outpost in a separate docker-compose project, you just have to ensure that the outpost container can reach your application container. | ||||||
|  |  | ||||||
|  | ```yaml | ||||||
|  | version: 3.5 | ||||||
|  |  | ||||||
|  | services: | ||||||
|  |   passbook_proxy: | ||||||
|  |     image: beryju/passbook-proxy:0.10.0-stable | ||||||
|  |     ports: | ||||||
|  |       - 4180:4180 | ||||||
|  |       - 4443:4443 | ||||||
|  |     environment: | ||||||
|  |       PASSBOOK_HOST: https://your-passbook.tld | ||||||
|  |       PASSBOOK_INSECURE: 'true' | ||||||
|  |       PASSBOOK_TOKEN: token-generated-by-passbook | ||||||
|  | ``` | ||||||
							
								
								
									
										99
									
								
								docs/outposts/deploy-kubernetes.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								docs/outposts/deploy-kubernetes.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,99 @@ | |||||||
|  | # Outpost deployment on Kubernetes | ||||||
|  |  | ||||||
|  | Use the following manifest, replacing all values surrounded with `__`. | ||||||
|  |  | ||||||
|  | Afterwards, configure the proxy provider to connect to `<service name>.<namespace>.svc.cluster.local`, and update your Ingress to connect to the `passbook-outpost` service. | ||||||
|  |  | ||||||
|  | ```yaml | ||||||
|  | apiVersion: v1 | ||||||
|  | kind: Secret | ||||||
|  | metadata: | ||||||
|  |   labels: | ||||||
|  |     app.kubernetes.io/instance: test | ||||||
|  |     app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |     app.kubernetes.io/name: passbook-proxy | ||||||
|  |     app.kubernetes.io/version: 0.10.0 | ||||||
|  |   name: passbook-outpost-api | ||||||
|  | stringData: | ||||||
|  |   passbook_host: '__PASSBOOK_URL__' | ||||||
|  |   passbook_host_insecure: 'true' | ||||||
|  |   token: '__PASSBOOK_TOKEN__' | ||||||
|  | type: Opaque | ||||||
|  | --- | ||||||
|  | apiVersion: v1 | ||||||
|  | kind: Service | ||||||
|  | metadata: | ||||||
|  |   labels: | ||||||
|  |     app.kubernetes.io/instance: test | ||||||
|  |     app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |     app.kubernetes.io/name: passbook-proxy | ||||||
|  |     app.kubernetes.io/version: 0.10.0 | ||||||
|  |   name: passbook-outpost | ||||||
|  | spec: | ||||||
|  |   ports: | ||||||
|  |   - name: http | ||||||
|  |     port: 4180 | ||||||
|  |     protocol: TCP | ||||||
|  |     targetPort: http | ||||||
|  |   - name: https | ||||||
|  |     port: 4443 | ||||||
|  |     protocol: TCP | ||||||
|  |     targetPort: https | ||||||
|  |   selector: | ||||||
|  |     app.kubernetes.io/instance: test | ||||||
|  |     app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |     app.kubernetes.io/name: passbook-proxy | ||||||
|  |     app.kubernetes.io/version: 0.10.0 | ||||||
|  |   type: ClusterIP | ||||||
|  | --- | ||||||
|  | apiVersion: apps/v1 | ||||||
|  | kind: Deployment | ||||||
|  | metadata: | ||||||
|  |   labels: | ||||||
|  |     app.kubernetes.io/instance: test | ||||||
|  |     app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |     app.kubernetes.io/name: passbook-proxy | ||||||
|  |     app.kubernetes.io/version: 0.10.0 | ||||||
|  |   name: passbook-outpost | ||||||
|  | spec: | ||||||
|  |   selector: | ||||||
|  |     matchLabels: | ||||||
|  |       app.kubernetes.io/instance: test | ||||||
|  |       app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |       app.kubernetes.io/name: passbook-proxy | ||||||
|  |       app.kubernetes.io/version: 0.10.0 | ||||||
|  |   template: | ||||||
|  |     metadata: | ||||||
|  |       labels: | ||||||
|  |         app.kubernetes.io/instance: test | ||||||
|  |         app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |         app.kubernetes.io/name: passbook-proxy | ||||||
|  |         app.kubernetes.io/version: 0.10.0 | ||||||
|  |     spec: | ||||||
|  |       containers: | ||||||
|  |       - env: | ||||||
|  |         - name: PASSBOOK_HOST | ||||||
|  |           valueFrom: | ||||||
|  |             secretKeyRef: | ||||||
|  |               key: passbook_host | ||||||
|  |               name: passbook-outpost-api | ||||||
|  |         - name: PASSBOOK_TOKEN | ||||||
|  |           valueFrom: | ||||||
|  |             secretKeyRef: | ||||||
|  |               key: token | ||||||
|  |               name: passbook-outpost-api | ||||||
|  |         - name: PASSBOOK_INSECURE | ||||||
|  |           valueFrom: | ||||||
|  |             secretKeyRef: | ||||||
|  |               key: passbook_host_insecure | ||||||
|  |               name: passbook-outpost-api | ||||||
|  |         image: beryju/passbook-proxy:0.10.0-stable | ||||||
|  |         name: proxy | ||||||
|  |         ports: | ||||||
|  |         - containerPort: 4180 | ||||||
|  |           name: http | ||||||
|  |           protocol: TCP | ||||||
|  |         - containerPort: 4443 | ||||||
|  |           name: https | ||||||
|  |           protocol: TCP | ||||||
|  | ``` | ||||||
| @ -6,21 +6,9 @@ An outpost is a single deployment of a passbook component, which can be deployed | |||||||
|  |  | ||||||
| Upon creation, a service account and a token is generated. The service account only has permissions to read the outpost and provider configuration. This token is used by the Outpost to connect to passbook. | Upon creation, a service account and a token is generated. The service account only has permissions to read the outpost and provider configuration. This token is used by the Outpost to connect to passbook. | ||||||
|  |  | ||||||
| To deploy an outpost, you can for example use this docker-compose snippet: | To deploy an outpost, see: <a name="deploy"> | ||||||
|  |  | ||||||
| ```yaml | - [Kubernetes](deploy-kubernetes.md) | ||||||
| version: 3.5 | - [docker-compose](deploy-docker-compose.md) | ||||||
|  |  | ||||||
| services: | In future versions, this snippet will be automatically generated. You will also be able to deploy an outpost directly into a kubernetes cluster. | ||||||
|   passbook_proxy: |  | ||||||
|     image: beryju/passbook-proxy:0.10.0-stable |  | ||||||
|     ports: |  | ||||||
|       - 4180:4180 |  | ||||||
|       - 4443:4443 |  | ||||||
|     environment: |  | ||||||
|       PASSBOOK_HOST: https://your-passbook.tld |  | ||||||
|       PASSBOOK_INSECURE: 'true' |  | ||||||
|       PASSBOOK_TOKEN: token-generated-by-passbook |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| In future versions, this snippet will be automatically generated. You will also be able to deploy an outpost directly into a kubernetes cluster.w |  | ||||||
|  | |||||||
							
								
								
									
										11
									
								
								docs/troubleshooting/access.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								docs/troubleshooting/access.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | |||||||
|  | # Troubleshooting access problems | ||||||
|  |  | ||||||
|  | ## I get an access denied error when trying to access an application. | ||||||
|  |  | ||||||
|  | If your user is a superuser, or has the attribute `passbook_user_debug` set to true: | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Afterwards, try to access the application again. You will now see a message explaining which policy denied you access: | ||||||
|  |  | ||||||
|  |  | ||||||
							
								
								
									
										
											BIN
										
									
								
								docs/troubleshooting/access_denied_message.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/troubleshooting/access_denied_message.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 98 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/troubleshooting/passbook_user_debug.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/troubleshooting/passbook_user_debug.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 13 KiB | 
| @ -2,7 +2,7 @@ version: '3.7' | |||||||
|  |  | ||||||
| services: | services: | ||||||
|   chrome: |   chrome: | ||||||
|     image: selenium/standalone-chrome-debug:3.141.59-20200525 |     image: selenium/standalone-chrome-debug:3.141.59-20200719 | ||||||
|     volumes: |     volumes: | ||||||
|       - /dev/shm:/dev/shm |       - /dev/shm:/dev/shm | ||||||
|     network_mode: host |     network_mode: host | ||||||
|  | |||||||
| @ -1,13 +1,12 @@ | |||||||
| """Test Enroll flow""" | """Test Enroll flow""" | ||||||
| from time import sleep | from sys import platform | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from django.test import override_settings | from django.test import override_settings | ||||||
| from docker import DockerClient, from_env |  | ||||||
| from docker.models.containers import Container |  | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.support import expected_conditions as ec | from selenium.webdriver.support import expected_conditions as ec | ||||||
| from structlog import get_logger |  | ||||||
|  |  | ||||||
| from e2e.utils import USER, SeleniumTestCase | from e2e.utils import USER, SeleniumTestCase | ||||||
| from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding | from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding | ||||||
| @ -18,41 +17,23 @@ from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage | |||||||
| from passbook.stages.user_login.models import UserLoginStage | from passbook.stages.user_login.models import UserLoginStage | ||||||
| from passbook.stages.user_write.models import UserWriteStage | from passbook.stages.user_write.models import UserWriteStage | ||||||
|  |  | ||||||
| LOGGER = get_logger() |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestFlowsEnroll(SeleniumTestCase): | class TestFlowsEnroll(SeleniumTestCase): | ||||||
|     """Test Enroll flow""" |     """Test Enroll flow""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|         self.container = self.setup_client() |         return { | ||||||
|         super().setUp() |             "image": "mailhog/mailhog:v1.0.1", | ||||||
|  |             "detach": True, | ||||||
|     def setup_client(self) -> Container: |             "network_mode": "host", | ||||||
|         """Setup test IdP container""" |             "auto_remove": True, | ||||||
|         client: DockerClient = from_env() |             "healthcheck": Healthcheck( | ||||||
|         container = client.containers.run( |  | ||||||
|             image="mailhog/mailhog:v1.0.1", |  | ||||||
|             detach=True, |  | ||||||
|             network_mode="host", |  | ||||||
|             auto_remove=True, |  | ||||||
|             healthcheck=Healthcheck( |  | ||||||
|                 test=["CMD", "wget", "--spider", "http://localhost:8025"], |                 test=["CMD", "wget", "--spider", "http://localhost:8025"], | ||||||
|                 interval=5 * 100 * 1000000, |                 interval=5 * 100 * 1000000, | ||||||
|                 start_period=1 * 100 * 1000000, |                 start_period=1 * 100 * 1000000, | ||||||
|             ), |             ), | ||||||
|         ) |         } | ||||||
|         while True: |  | ||||||
|             container.reload() |  | ||||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") |  | ||||||
|             if status == "healthy": |  | ||||||
|                 return container |  | ||||||
|             LOGGER.info("Container failed healthcheck") |  | ||||||
|             sleep(1) |  | ||||||
|  |  | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
|  |  | ||||||
|     def test_enroll_2_step(self): |     def test_enroll_2_step(self): | ||||||
|         """Test 2-step enroll flow""" |         """Test 2-step enroll flow""" | ||||||
| @ -220,21 +201,25 @@ class TestFlowsEnroll(SeleniumTestCase): | |||||||
|         self.driver.find_element(By.ID, "id_name").send_keys("some name") |         self.driver.find_element(By.ID, "id_name").send_keys("some name") | ||||||
|         self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz") |         self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz") | ||||||
|         self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() |         self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() | ||||||
|         sleep(3) |         # Wait for the success message so we know the email is sent | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CSS_SELECTOR, ".pf-c-form > p")) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         # Open Mailhog |         # Open Mailhog | ||||||
|         self.driver.get("http://localhost:8025") |         self.driver.get("http://localhost:8025") | ||||||
|  |  | ||||||
|         # Click on first message |         # Click on first message | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CLASS_NAME, "msglist-message")) | ||||||
|  |         ) | ||||||
|         self.driver.find_element(By.CLASS_NAME, "msglist-message").click() |         self.driver.find_element(By.CLASS_NAME, "msglist-message").click() | ||||||
|         sleep(3) |  | ||||||
|         self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane")) |         self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane")) | ||||||
|         self.driver.find_element(By.ID, "confirm").click() |         self.driver.find_element(By.ID, "confirm").click() | ||||||
|         self.driver.close() |         self.driver.close() | ||||||
|         self.driver.switch_to.window(self.driver.window_handles[0]) |         self.driver.switch_to.window(self.driver.window_handles[0]) | ||||||
|  |  | ||||||
|         # We're now logged in |         # We're now logged in | ||||||
|         sleep(3) |  | ||||||
|         self.wait.until( |         self.wait.until( | ||||||
|             ec.presence_of_element_located( |             ec.presence_of_element_located( | ||||||
|                 (By.XPATH, "//a[contains(@href, '/-/user/')]") |                 (By.XPATH, "//a[contains(@href, '/-/user/')]") | ||||||
|  | |||||||
| @ -1,10 +1,14 @@ | |||||||
| """test default login flow""" | """test default login flow""" | ||||||
|  | from sys import platform | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
|  |  | ||||||
| from e2e.utils import USER, SeleniumTestCase | from e2e.utils import USER, SeleniumTestCase | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestFlowsLogin(SeleniumTestCase): | class TestFlowsLogin(SeleniumTestCase): | ||||||
|     """test default login flow""" |     """test default login flow""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """test stage setup flows (password change)""" | """test stage setup flows (password change)""" | ||||||
| import string | from sys import platform | ||||||
| from random import SystemRandom | from unittest.case import skipUnless | ||||||
| from time import sleep |  | ||||||
|  |  | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
| @ -9,9 +8,11 @@ from selenium.webdriver.common.keys import Keys | |||||||
| from e2e.utils import USER, SeleniumTestCase | from e2e.utils import USER, SeleniumTestCase | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| from passbook.flows.models import Flow, FlowDesignation | from passbook.flows.models import Flow, FlowDesignation | ||||||
|  | from passbook.providers.oauth2.generators import generate_client_secret | ||||||
| from passbook.stages.password.models import PasswordStage | from passbook.stages.password.models import PasswordStage | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestFlowsStageSetup(SeleniumTestCase): | class TestFlowsStageSetup(SeleniumTestCase): | ||||||
|     """test stage setup flows""" |     """test stage setup flows""" | ||||||
|  |  | ||||||
| @ -27,10 +28,7 @@ class TestFlowsStageSetup(SeleniumTestCase): | |||||||
|         stage.change_flow = flow |         stage.change_flow = flow | ||||||
|         stage.save() |         stage.save() | ||||||
|  |  | ||||||
|         new_password = "".join( |         new_password = generate_client_secret() | ||||||
|             SystemRandom().choice(string.ascii_uppercase + string.digits) |  | ||||||
|             for _ in range(8) |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         self.driver.get( |         self.driver.get( | ||||||
|             f"{self.live_server_url}/flows/default-authentication-flow/?next=%2F" |             f"{self.live_server_url}/flows/default-authentication-flow/?next=%2F" | ||||||
| @ -48,7 +46,7 @@ class TestFlowsStageSetup(SeleniumTestCase): | |||||||
|         self.driver.find_element(By.ID, "id_password_repeat").send_keys(new_password) |         self.driver.find_element(By.ID, "id_password_repeat").send_keys(new_password) | ||||||
|         self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() |         self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() | ||||||
|  |  | ||||||
|         sleep(2) |         self.wait_for_url(self.url("passbook_core:user-settings")) | ||||||
|         # Because USER() is cached, we need to get the user manually here |         # Because USER() is cached, we need to get the user manually here | ||||||
|         user = User.objects.get(username=USER().username) |         user = User.objects.get(username=USER().username) | ||||||
|         self.assertTrue(user.check_password(new_password)) |         self.assertTrue(user.check_password(new_password)) | ||||||
|  | |||||||
| @ -1,12 +1,12 @@ | |||||||
| """test OAuth Provider flow""" | """test OAuth Provider flow""" | ||||||
| from time import sleep | from sys import platform | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from docker import DockerClient, from_env |  | ||||||
| from docker.models.containers import Container |  | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
| from structlog import get_logger | from selenium.webdriver.support import expected_conditions as ec | ||||||
|  |  | ||||||
| from e2e.utils import USER, SeleniumTestCase | from e2e.utils import USER, SeleniumTestCase | ||||||
| from passbook.core.models import Application | from passbook.core.models import Application | ||||||
| @ -19,32 +19,29 @@ from passbook.providers.oauth2.generators import ( | |||||||
| ) | ) | ||||||
| from passbook.providers.oauth2.models import ClientTypes, OAuth2Provider, ResponseTypes | from passbook.providers.oauth2.models import ClientTypes, OAuth2Provider, ResponseTypes | ||||||
|  |  | ||||||
| LOGGER = get_logger() |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestProviderOAuth2Github(SeleniumTestCase): | class TestProviderOAuth2Github(SeleniumTestCase): | ||||||
|     """test OAuth Provider flow""" |     """test OAuth Provider flow""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.client_id = generate_client_id() |         self.client_id = generate_client_id() | ||||||
|         self.client_secret = generate_client_secret() |         self.client_secret = generate_client_secret() | ||||||
|         self.container = self.setup_client() |  | ||||||
|         super().setUp() |         super().setUp() | ||||||
|  |  | ||||||
|     def setup_client(self) -> Container: |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|         """Setup client grafana container which we test OAuth against""" |         """Setup client grafana container which we test OAuth against""" | ||||||
|         client: DockerClient = from_env() |         return { | ||||||
|         container = client.containers.run( |             "image": "grafana/grafana:7.1.0", | ||||||
|             image="grafana/grafana:7.1.0", |             "detach": True, | ||||||
|             detach=True, |             "network_mode": "host", | ||||||
|             network_mode="host", |             "auto_remove": True, | ||||||
|             auto_remove=True, |             "healthcheck": Healthcheck( | ||||||
|             healthcheck=Healthcheck( |  | ||||||
|                 test=["CMD", "wget", "--spider", "http://localhost:3000"], |                 test=["CMD", "wget", "--spider", "http://localhost:3000"], | ||||||
|                 interval=5 * 100 * 1000000, |                 interval=5 * 100 * 1000000, | ||||||
|                 start_period=1 * 100 * 1000000, |                 start_period=1 * 100 * 1000000, | ||||||
|             ), |             ), | ||||||
|             environment={ |             "environment": { | ||||||
|                 "GF_AUTH_GITHUB_ENABLED": "true", |                 "GF_AUTH_GITHUB_ENABLED": "true", | ||||||
|                 "GF_AUTH_GITHUB_ALLOW_SIGN_UP": "true", |                 "GF_AUTH_GITHUB_ALLOW_SIGN_UP": "true", | ||||||
|                 "GF_AUTH_GITHUB_CLIENT_ID": self.client_id, |                 "GF_AUTH_GITHUB_CLIENT_ID": self.client_id, | ||||||
| @ -61,22 +58,10 @@ class TestProviderOAuth2Github(SeleniumTestCase): | |||||||
|                 ), |                 ), | ||||||
|                 "GF_LOG_LEVEL": "debug", |                 "GF_LOG_LEVEL": "debug", | ||||||
|             }, |             }, | ||||||
|         ) |         } | ||||||
|         while True: |  | ||||||
|             container.reload() |  | ||||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") |  | ||||||
|             if status == "healthy": |  | ||||||
|                 return container |  | ||||||
|             LOGGER.info("Container failed healthcheck") |  | ||||||
|             sleep(1) |  | ||||||
|  |  | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
|  |  | ||||||
|     def test_authorization_consent_implied(self): |     def test_authorization_consent_implied(self): | ||||||
|         """test OAuth Provider flow (default authorization flow with implied consent)""" |         """test OAuth Provider flow (default authorization flow with implied consent)""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         authorization_flow = Flow.objects.get( |         authorization_flow = Flow.objects.get( | ||||||
|             slug="default-provider-authorization-implicit-consent" |             slug="default-provider-authorization-implicit-consent" | ||||||
| @ -129,7 +114,6 @@ class TestProviderOAuth2Github(SeleniumTestCase): | |||||||
|  |  | ||||||
|     def test_authorization_consent_explicit(self): |     def test_authorization_consent_explicit(self): | ||||||
|         """test OAuth Provider flow (default authorization flow with explicit consent)""" |         """test OAuth Provider flow (default authorization flow with explicit consent)""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         authorization_flow = Flow.objects.get( |         authorization_flow = Flow.objects.get( | ||||||
|             slug="default-provider-authorization-explicit-consent" |             slug="default-provider-authorization-explicit-consent" | ||||||
| @ -167,8 +151,13 @@ class TestProviderOAuth2Github(SeleniumTestCase): | |||||||
|                 By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]" |                 By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]" | ||||||
|             ).text, |             ).text, | ||||||
|         ) |         ) | ||||||
|         sleep(1) |         self.driver.find_element( | ||||||
|         self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() |             By.CSS_SELECTOR, | ||||||
|  |             ( | ||||||
|  |                 "form[action='/flows/b/default-provider-authorization-explicit-consent/'] " | ||||||
|  |                 "[type=submit]" | ||||||
|  |             ), | ||||||
|  |         ).click() | ||||||
|  |  | ||||||
|         self.wait_for_url("http://localhost:3000/?orgId=1") |         self.wait_for_url("http://localhost:3000/?orgId=1") | ||||||
|         self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() |         self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() | ||||||
| @ -197,7 +186,6 @@ class TestProviderOAuth2Github(SeleniumTestCase): | |||||||
|  |  | ||||||
|     def test_denied(self): |     def test_denied(self): | ||||||
|         """test OAuth Provider flow (default authorization flow, denied)""" |         """test OAuth Provider flow (default authorization flow, denied)""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         authorization_flow = Flow.objects.get( |         authorization_flow = Flow.objects.get( | ||||||
|             slug="default-provider-authorization-explicit-consent" |             slug="default-provider-authorization-explicit-consent" | ||||||
| @ -227,7 +215,10 @@ class TestProviderOAuth2Github(SeleniumTestCase): | |||||||
|         self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) |         self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) | ||||||
|         self.driver.find_element(By.ID, "id_password").send_keys(USER().username) |         self.driver.find_element(By.ID, "id_password").send_keys(USER().username) | ||||||
|         self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) |         self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) | ||||||
|         self.wait_for_url(self.url("passbook_flows:denied")) |  | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1")) | ||||||
|  |         ) | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, |             self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, | ||||||
|             "Permission denied", |             "Permission denied", | ||||||
|  | |||||||
| @ -1,8 +1,9 @@ | |||||||
| """test OAuth2 OpenID Provider flow""" | """test OAuth2 OpenID Provider flow""" | ||||||
|  | from sys import platform | ||||||
| from time import sleep | from time import sleep | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from docker import DockerClient, from_env |  | ||||||
| from docker.models.containers import Container |  | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
| @ -34,29 +35,27 @@ from passbook.providers.oauth2.models import ( | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestProviderOAuth2OIDC(SeleniumTestCase): | class TestProviderOAuth2OIDC(SeleniumTestCase): | ||||||
|     """test OAuth with OpenID Provider flow""" |     """test OAuth with OpenID Provider flow""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.client_id = generate_client_id() |         self.client_id = generate_client_id() | ||||||
|         self.client_secret = generate_client_secret() |         self.client_secret = generate_client_secret() | ||||||
|         self.container = self.setup_client() |  | ||||||
|         super().setUp() |         super().setUp() | ||||||
|  |  | ||||||
|     def setup_client(self) -> Container: |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|         """Setup client grafana container which we test OIDC against""" |         return { | ||||||
|         client: DockerClient = from_env() |             "image": "grafana/grafana:7.1.0", | ||||||
|         container = client.containers.run( |             "detach": True, | ||||||
|             image="grafana/grafana:7.1.0", |             "network_mode": "host", | ||||||
|             detach=True, |             "auto_remove": True, | ||||||
|             network_mode="host", |             "healthcheck": Healthcheck( | ||||||
|             auto_remove=True, |  | ||||||
|             healthcheck=Healthcheck( |  | ||||||
|                 test=["CMD", "wget", "--spider", "http://localhost:3000"], |                 test=["CMD", "wget", "--spider", "http://localhost:3000"], | ||||||
|                 interval=5 * 100 * 1000000, |                 interval=5 * 100 * 1000000, | ||||||
|                 start_period=1 * 100 * 1000000, |                 start_period=1 * 100 * 1000000, | ||||||
|             ), |             ), | ||||||
|             environment={ |             "environment": { | ||||||
|                 "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", |                 "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", | ||||||
|                 "GF_AUTH_GENERIC_OAUTH_CLIENT_ID": self.client_id, |                 "GF_AUTH_GENERIC_OAUTH_CLIENT_ID": self.client_id, | ||||||
|                 "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret, |                 "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret, | ||||||
| @ -72,18 +71,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): | |||||||
|                 ), |                 ), | ||||||
|                 "GF_LOG_LEVEL": "debug", |                 "GF_LOG_LEVEL": "debug", | ||||||
|             }, |             }, | ||||||
|         ) |         } | ||||||
|         while True: |  | ||||||
|             container.reload() |  | ||||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") |  | ||||||
|             if status == "healthy": |  | ||||||
|                 return container |  | ||||||
|             LOGGER.info("Container failed healthcheck") |  | ||||||
|             sleep(1) |  | ||||||
|  |  | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
|  |  | ||||||
|     def test_redirect_uri_error(self): |     def test_redirect_uri_error(self): | ||||||
|         """test OpenID Provider flow (invalid redirect URI, check error message)""" |         """test OpenID Provider flow (invalid redirect URI, check error message)""" | ||||||
| @ -297,7 +285,10 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): | |||||||
|         self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) |         self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) | ||||||
|         self.driver.find_element(By.ID, "id_password").send_keys(USER().username) |         self.driver.find_element(By.ID, "id_password").send_keys(USER().username) | ||||||
|         self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) |         self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) | ||||||
|         self.wait_for_url(self.url("passbook_flows:denied")) |  | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1")) | ||||||
|  |         ) | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, |             self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, | ||||||
|             "Permission denied", |             "Permission denied", | ||||||
|  | |||||||
							
								
								
									
										96
									
								
								e2e/test_provider_proxy.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								e2e/test_provider_proxy.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,96 @@ | |||||||
|  | """Proxy and Outpost e2e tests""" | ||||||
|  | from sys import platform | ||||||
|  | from time import sleep | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
|  | from docker.client import DockerClient, from_env | ||||||
|  | from docker.models.containers import Container | ||||||
|  | from selenium.webdriver.common.by import By | ||||||
|  | from selenium.webdriver.common.keys import Keys | ||||||
|  |  | ||||||
|  | from e2e.utils import USER, SeleniumTestCase | ||||||
|  | from passbook.core.models import Application | ||||||
|  | from passbook.flows.models import Flow | ||||||
|  | from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType | ||||||
|  | from passbook.providers.proxy.models import ProxyProvider | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
|  | class TestProviderProxy(SeleniumTestCase): | ||||||
|  |     """Proxy and Outpost e2e tests""" | ||||||
|  |  | ||||||
|  |     proxy_container: Container | ||||||
|  |  | ||||||
|  |     def tearDown(self) -> None: | ||||||
|  |         super().tearDown() | ||||||
|  |         self.proxy_container.kill() | ||||||
|  |  | ||||||
|  |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|  |         return { | ||||||
|  |             "image": "traefik/whoami:latest", | ||||||
|  |             "detach": True, | ||||||
|  |             "network_mode": "host", | ||||||
|  |             "auto_remove": True, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |     def start_proxy(self, outpost: Outpost) -> Container: | ||||||
|  |         """Start proxy container based on outpost created""" | ||||||
|  |         client: DockerClient = from_env() | ||||||
|  |         container = client.containers.run( | ||||||
|  |             image="beryju/passbook-proxy:latest", | ||||||
|  |             detach=True, | ||||||
|  |             network_mode="host", | ||||||
|  |             auto_remove=True, | ||||||
|  |             environment={ | ||||||
|  |                 "PASSBOOK_HOST": self.live_server_url, | ||||||
|  |                 "PASSBOOK_TOKEN": outpost.token.token_uuid.hex, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         return container | ||||||
|  |  | ||||||
|  |     def test_proxy_simple(self): | ||||||
|  |         """Test simple outpost setup with single provider""" | ||||||
|  |         proxy: ProxyProvider = ProxyProvider.objects.create( | ||||||
|  |             name="proxy_provider", | ||||||
|  |             authorization_flow=Flow.objects.get( | ||||||
|  |                 slug="default-provider-authorization-implicit-consent" | ||||||
|  |             ), | ||||||
|  |             internal_host="http://localhost:80", | ||||||
|  |             external_host="http://localhost:4180", | ||||||
|  |         ) | ||||||
|  |         # Ensure OAuth2 Params are set | ||||||
|  |         proxy.set_oauth_defaults() | ||||||
|  |         proxy.save() | ||||||
|  |         # we need to create an application to actually access the proxy | ||||||
|  |         Application.objects.create(name="proxy", slug="proxy", provider=proxy) | ||||||
|  |         outpost: Outpost = Outpost.objects.create( | ||||||
|  |             name="proxy_outpost", | ||||||
|  |             type=OutpostType.PROXY, | ||||||
|  |             deployment_type=OutpostDeploymentType.CUSTOM, | ||||||
|  |         ) | ||||||
|  |         outpost.providers.add(proxy) | ||||||
|  |         outpost.save() | ||||||
|  |  | ||||||
|  |         self.proxy_container = self.start_proxy(outpost) | ||||||
|  |  | ||||||
|  |         # Wait until outpost healthcheck succeeds | ||||||
|  |         healthcheck_retries = 0 | ||||||
|  |         while healthcheck_retries < 50: | ||||||
|  |             if outpost.health: | ||||||
|  |                 break | ||||||
|  |             healthcheck_retries += 1 | ||||||
|  |             sleep(0.5) | ||||||
|  |  | ||||||
|  |         self.driver.get("http://localhost:4180") | ||||||
|  |  | ||||||
|  |         self.driver.find_element(By.ID, "id_uid_field").click() | ||||||
|  |         self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) | ||||||
|  |         self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) | ||||||
|  |         self.driver.find_element(By.ID, "id_password").send_keys(USER().username) | ||||||
|  |         self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) | ||||||
|  |  | ||||||
|  |         sleep(1) | ||||||
|  |  | ||||||
|  |         full_body_text = self.driver.find_element(By.CSS_SELECTOR, "pre").text | ||||||
|  |         self.assertIn("X-Forwarded-Preferred-Username: pbadmin", full_body_text) | ||||||
| @ -1,11 +1,14 @@ | |||||||
| """test SAML Provider flow""" | """test SAML Provider flow""" | ||||||
|  | from sys import platform | ||||||
| from time import sleep | from time import sleep | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from docker import DockerClient, from_env | from docker import DockerClient, from_env | ||||||
| from docker.models.containers import Container | from docker.models.containers import Container | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
|  | from selenium.webdriver.support import expected_conditions as ec | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
| from e2e.utils import USER, SeleniumTestCase | from e2e.utils import USER, SeleniumTestCase | ||||||
| @ -23,6 +26,7 @@ from passbook.providers.saml.models import ( | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestProviderSAML(SeleniumTestCase): | class TestProviderSAML(SeleniumTestCase): | ||||||
|     """test SAML Provider flow""" |     """test SAML Provider flow""" | ||||||
|  |  | ||||||
| @ -60,10 +64,6 @@ class TestProviderSAML(SeleniumTestCase): | |||||||
|             LOGGER.info("Container failed healthcheck") |             LOGGER.info("Container failed healthcheck") | ||||||
|             sleep(1) |             sleep(1) | ||||||
|  |  | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
|  |  | ||||||
|     def test_sp_initiated_implicit(self): |     def test_sp_initiated_implicit(self): | ||||||
|         """test SAML Provider flow SP-initiated flow (implicit consent)""" |         """test SAML Provider flow SP-initiated flow (implicit consent)""" | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
| @ -207,7 +207,10 @@ class TestProviderSAML(SeleniumTestCase): | |||||||
|         self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) |         self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) | ||||||
|         self.driver.find_element(By.ID, "id_password").send_keys(USER().username) |         self.driver.find_element(By.ID, "id_password").send_keys(USER().username) | ||||||
|         self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) |         self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) | ||||||
|         self.wait_for_url(self.url("passbook_flows:denied")) |  | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1")) | ||||||
|  |         ) | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, |             self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, | ||||||
|             "Permission denied", |             "Permission denied", | ||||||
|  | |||||||
| @ -1,8 +1,11 @@ | |||||||
| """test OAuth Source""" | """test OAuth Source""" | ||||||
| from os.path import abspath | from os.path import abspath | ||||||
|  | from sys import platform | ||||||
| from time import sleep | from time import sleep | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
| 
 | 
 | ||||||
| from docker import DockerClient, from_env | from django.test import override_settings | ||||||
| from docker.models.containers import Container | from docker.models.containers import Container | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| @ -21,6 +24,7 @@ CONFIG_PATH = "/tmp/dex.yml" | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestSourceOAuth(SeleniumTestCase): | class TestSourceOAuth(SeleniumTestCase): | ||||||
|     """test OAuth Source flow""" |     """test OAuth Source flow""" | ||||||
| 
 | 
 | ||||||
| @ -28,7 +32,7 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
| 
 | 
 | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.client_secret = generate_client_secret() |         self.client_secret = generate_client_secret() | ||||||
|         self.container = self.setup_client() |         self.prepare_dex_config() | ||||||
|         super().setUp() |         super().setUp() | ||||||
| 
 | 
 | ||||||
|     def prepare_dex_config(self): |     def prepare_dex_config(self): | ||||||
| @ -66,34 +70,23 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
|         with open(CONFIG_PATH, "w+") as _file: |         with open(CONFIG_PATH, "w+") as _file: | ||||||
|             safe_dump(config, _file) |             safe_dump(config, _file) | ||||||
| 
 | 
 | ||||||
|     def setup_client(self) -> Container: |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|         """Setup test Dex container""" |         return { | ||||||
|         self.prepare_dex_config() |             "image": "quay.io/dexidp/dex:v2.24.0", | ||||||
|         client: DockerClient = from_env() |             "detach": True, | ||||||
|         container = client.containers.run( |             "network_mode": "host", | ||||||
|             image="quay.io/dexidp/dex:v2.24.0", |             "auto_remove": True, | ||||||
|             detach=True, |             "command": "serve /config.yml", | ||||||
|             network_mode="host", |             "healthcheck": Healthcheck( | ||||||
|             auto_remove=True, |  | ||||||
|             command="serve /config.yml", |  | ||||||
|             healthcheck=Healthcheck( |  | ||||||
|                 test=["CMD", "wget", "--spider", "http://localhost:5556/dex/healthz"], |                 test=["CMD", "wget", "--spider", "http://localhost:5556/dex/healthz"], | ||||||
|                 interval=5 * 100 * 1000000, |                 interval=5 * 100 * 1000000, | ||||||
|                 start_period=1 * 100 * 1000000, |                 start_period=1 * 100 * 1000000, | ||||||
|             ), |             ), | ||||||
|             volumes={abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro"}}, |             "volumes": {abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro"}}, | ||||||
|         ) |         } | ||||||
|         while True: |  | ||||||
|             container.reload() |  | ||||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") |  | ||||||
|             if status == "healthy": |  | ||||||
|                 return container |  | ||||||
|             LOGGER.info("Container failed healthcheck") |  | ||||||
|             sleep(1) |  | ||||||
| 
 | 
 | ||||||
|     def create_objects(self): |     def create_objects(self): | ||||||
|         """Create required objects""" |         """Create required objects""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         authentication_flow = Flow.objects.get(slug="default-source-authentication") |         authentication_flow = Flow.objects.get(slug="default-source-authentication") | ||||||
|         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") |         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") | ||||||
| @ -111,10 +104,6 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
|             consumer_secret=self.client_secret, |             consumer_secret=self.client_secret, | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
| 
 |  | ||||||
|     def test_oauth_enroll(self): |     def test_oauth_enroll(self): | ||||||
|         """test OAuth Source With With OIDC""" |         """test OAuth Source With With OIDC""" | ||||||
|         self.create_objects() |         self.create_objects() | ||||||
| @ -141,6 +130,7 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
|         ) |         ) | ||||||
|         self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() |         self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() | ||||||
| 
 | 
 | ||||||
|  |         self.wait.until(ec.presence_of_element_located((By.NAME, "username"))) | ||||||
|         # At this point we've been redirected back |         # At this point we've been redirected back | ||||||
|         # and we're asked for the username |         # and we're asked for the username | ||||||
|         self.driver.find_element(By.NAME, "username").click() |         self.driver.find_element(By.NAME, "username").click() | ||||||
| @ -167,6 +157,42 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
|             "admin@example.com", |             "admin@example.com", | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |     @override_settings(SESSION_COOKIE_SAMESITE="strict") | ||||||
|  |     def test_oauth_samesite_strict(self): | ||||||
|  |         """test OAuth Source With SameSite set to strict | ||||||
|  |         (=will fail because session is not carried over)""" | ||||||
|  |         self.create_objects() | ||||||
|  |         self.driver.get(self.live_server_url) | ||||||
|  | 
 | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located( | ||||||
|  |                 (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.driver.find_element( | ||||||
|  |             By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" | ||||||
|  |         ).click() | ||||||
|  | 
 | ||||||
|  |         # Now we should be at the IDP, wait for the login field | ||||||
|  |         self.wait.until(ec.presence_of_element_located((By.ID, "login"))) | ||||||
|  |         self.driver.find_element(By.ID, "login").send_keys("admin@example.com") | ||||||
|  |         self.driver.find_element(By.ID, "password").send_keys("password") | ||||||
|  |         self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) | ||||||
|  | 
 | ||||||
|  |         # Wait until we're logged in | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CSS_SELECTOR, "button[type=submit]")) | ||||||
|  |         ) | ||||||
|  |         self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() | ||||||
|  | 
 | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CSS_SELECTOR, ".pf-c-alert__title")) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual( | ||||||
|  |             self.driver.find_element(By.CSS_SELECTOR, ".pf-c-alert__title").text, | ||||||
|  |             "Authentication Failed.", | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|     def test_oauth_enroll_auth(self): |     def test_oauth_enroll_auth(self): | ||||||
|         """test OAuth Source With With OIDC (enroll and authenticate again)""" |         """test OAuth Source With With OIDC (enroll and authenticate again)""" | ||||||
|         self.test_oauth_enroll() |         self.test_oauth_enroll() | ||||||
| @ -178,10 +204,11 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
|                 (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") |                 (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|  |         sleep(1) | ||||||
|         self.driver.find_element( |         self.driver.find_element( | ||||||
|             By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" |             By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" | ||||||
|         ).click() |         ).click() | ||||||
| 
 |         sleep(1) | ||||||
|         # Now we should be at the IDP, wait for the login field |         # Now we should be at the IDP, wait for the login field | ||||||
|         self.wait.until(ec.presence_of_element_located((By.ID, "login"))) |         self.wait.until(ec.presence_of_element_located((By.ID, "login"))) | ||||||
|         self.driver.find_element(By.ID, "login").send_keys("admin@example.com") |         self.driver.find_element(By.ID, "login").send_keys("admin@example.com") | ||||||
| @ -1,8 +1,9 @@ | |||||||
| """test SAML Source""" | """test SAML Source""" | ||||||
|  | from sys import platform | ||||||
| from time import sleep | from time import sleep | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from docker import DockerClient, from_env |  | ||||||
| from docker.models.containers import Container |  | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
| @ -68,48 +69,31 @@ Sm75WXsflOxuTn08LbgGc4s= | |||||||
| -----END PRIVATE KEY-----""" | -----END PRIVATE KEY-----""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestSourceSAML(SeleniumTestCase): | class TestSourceSAML(SeleniumTestCase): | ||||||
|     """test SAML Source flow""" |     """test SAML Source flow""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|         self.container = self.setup_client() |         return { | ||||||
|         super().setUp() |             "image": "kristophjunge/test-saml-idp:1.15", | ||||||
|  |             "detach": True, | ||||||
|     def setup_client(self) -> Container: |             "network_mode": "host", | ||||||
|         """Setup test IdP container""" |             "auto_remove": True, | ||||||
|         client: DockerClient = from_env() |             "healthcheck": Healthcheck( | ||||||
|         container = client.containers.run( |  | ||||||
|             image="kristophjunge/test-saml-idp:1.15", |  | ||||||
|             detach=True, |  | ||||||
|             network_mode="host", |  | ||||||
|             auto_remove=True, |  | ||||||
|             healthcheck=Healthcheck( |  | ||||||
|                 test=["CMD", "curl", "http://localhost:8080"], |                 test=["CMD", "curl", "http://localhost:8080"], | ||||||
|                 interval=5 * 100 * 1000000, |                 interval=5 * 100 * 1000000, | ||||||
|                 start_period=1 * 100 * 1000000, |                 start_period=1 * 100 * 1000000, | ||||||
|             ), |             ), | ||||||
|             environment={ |             "environment": { | ||||||
|                 "SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id", |                 "SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id", | ||||||
|                 "SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": ( |                 "SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": ( | ||||||
|                     f"{self.live_server_url}/source/saml/saml-idp-test/acs/" |                     f"{self.live_server_url}/source/saml/saml-idp-test/acs/" | ||||||
|                 ), |                 ), | ||||||
|             }, |             }, | ||||||
|         ) |         } | ||||||
|         while True: |  | ||||||
|             container.reload() |  | ||||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") |  | ||||||
|             if status == "healthy": |  | ||||||
|                 return container |  | ||||||
|             LOGGER.info("Container failed healthcheck") |  | ||||||
|             sleep(1) |  | ||||||
|  |  | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
|  |  | ||||||
|     def test_idp_redirect(self): |     def test_idp_redirect(self): | ||||||
|         """test SAML Source With redirect binding""" |         """test SAML Source With redirect binding""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         authentication_flow = Flow.objects.get(slug="default-source-authentication") |         authentication_flow = Flow.objects.get(slug="default-source-authentication") | ||||||
|         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") |         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") | ||||||
| @ -161,7 +145,6 @@ class TestSourceSAML(SeleniumTestCase): | |||||||
|  |  | ||||||
|     def test_idp_post(self): |     def test_idp_post(self): | ||||||
|         """test SAML Source With post binding""" |         """test SAML Source With post binding""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         authentication_flow = Flow.objects.get(slug="default-source-authentication") |         authentication_flow = Flow.objects.get(slug="default-source-authentication") | ||||||
|         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") |         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") | ||||||
| @ -215,7 +198,6 @@ class TestSourceSAML(SeleniumTestCase): | |||||||
|  |  | ||||||
|     def test_idp_post_auto(self): |     def test_idp_post_auto(self): | ||||||
|         """test SAML Source With post binding (auto redirect)""" |         """test SAML Source With post binding (auto redirect)""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         authentication_flow = Flow.objects.get(slug="default-source-authentication") |         authentication_flow = Flow.objects.get(slug="default-source-authentication") | ||||||
|         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") |         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") | ||||||
|  | |||||||
							
								
								
									
										33
									
								
								e2e/utils.py
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								e2e/utils.py
									
									
									
									
									
								
							| @ -4,13 +4,16 @@ from glob import glob | |||||||
| from importlib.util import module_from_spec, spec_from_file_location | from importlib.util import module_from_spec, spec_from_file_location | ||||||
| from inspect import getmembers, isfunction | from inspect import getmembers, isfunction | ||||||
| from os import environ, makedirs | from os import environ, makedirs | ||||||
| from time import time | from time import sleep, time | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  |  | ||||||
| from django.apps import apps | from django.apps import apps | ||||||
| from django.contrib.staticfiles.testing import StaticLiveServerTestCase | from django.contrib.staticfiles.testing import StaticLiveServerTestCase | ||||||
| from django.db import connection, transaction | from django.db import connection, transaction | ||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
|  | from docker import DockerClient, from_env | ||||||
|  | from docker.models.containers import Container | ||||||
| from selenium import webdriver | from selenium import webdriver | ||||||
| from selenium.webdriver.common.desired_capabilities import DesiredCapabilities | from selenium.webdriver.common.desired_capabilities import DesiredCapabilities | ||||||
| from selenium.webdriver.remote.webdriver import WebDriver | from selenium.webdriver.remote.webdriver import WebDriver | ||||||
| @ -30,15 +33,37 @@ def USER() -> User:  # noqa | |||||||
| class SeleniumTestCase(StaticLiveServerTestCase): | class SeleniumTestCase(StaticLiveServerTestCase): | ||||||
|     """StaticLiveServerTestCase which automatically creates a Webdriver instance""" |     """StaticLiveServerTestCase which automatically creates a Webdriver instance""" | ||||||
|  |  | ||||||
|  |     container: Optional[Container] = None | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         makedirs("selenium_screenshots/", exist_ok=True) |         makedirs("selenium_screenshots/", exist_ok=True) | ||||||
|         self.driver = self._get_driver() |         self.driver = self._get_driver() | ||||||
|         self.driver.maximize_window() |         self.driver.maximize_window() | ||||||
|         self.driver.implicitly_wait(30) |         self.driver.implicitly_wait(10) | ||||||
|         self.wait = WebDriverWait(self.driver, 50) |         self.wait = WebDriverWait(self.driver, 30) | ||||||
|         self.apply_default_data() |         self.apply_default_data() | ||||||
|         self.logger = get_logger() |         self.logger = get_logger() | ||||||
|  |         if specs := self.get_container_specs(): | ||||||
|  |             self.container = self._start_container(specs) | ||||||
|  |  | ||||||
|  |     def _start_container(self, specs: Dict[str, Any]) -> Container: | ||||||
|  |         client: DockerClient = from_env() | ||||||
|  |         container = client.containers.run(**specs) | ||||||
|  |         if "healthcheck" not in specs: | ||||||
|  |             return container | ||||||
|  |         while True: | ||||||
|  |             container.reload() | ||||||
|  |             status = container.attrs.get("State", {}).get("Health", {}).get("Status") | ||||||
|  |             if status == "healthy": | ||||||
|  |                 return container | ||||||
|  |             self.logger.info("Container failed healthcheck") | ||||||
|  |             sleep(1) | ||||||
|  |  | ||||||
|  |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|  |         """Optionally get container specs which will launched on setup, wait for the container to | ||||||
|  |         be healthy, and deleted again on tearDown""" | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     def _get_driver(self) -> WebDriver: |     def _get_driver(self) -> WebDriver: | ||||||
|         return webdriver.Remote( |         return webdriver.Remote( | ||||||
| @ -57,6 +82,8 @@ class SeleniumTestCase(StaticLiveServerTestCase): | |||||||
|             self.logger.warning( |             self.logger.warning( | ||||||
|                 line["message"], source=line["source"], level=line["level"] |                 line["message"], source=line["source"], level=line["level"] | ||||||
|             ) |             ) | ||||||
|  |         if self.container: | ||||||
|  |             self.container.kill() | ||||||
|         self.driver.quit() |         self.driver.quit() | ||||||
|         super().tearDown() |         super().tearDown() | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,15 +1,15 @@ | |||||||
| apiVersion: v2 | apiVersion: v2 | ||||||
| appVersion: "0.10.0-rc1" | appVersion: "0.10.3-stable" | ||||||
| description: A Helm chart for passbook. | description: A Helm chart for passbook. | ||||||
| name: passbook | name: passbook | ||||||
| version: "0.10.0-rc1" | version: "0.10.3-stable" | ||||||
| icon: https://github.com/BeryJu/passbook/blob/master/passbook/static/static/passbook/logo.svg | icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg | ||||||
| dependencies: | dependencies: | ||||||
|   - name: postgresql |   - name: postgresql | ||||||
|     version: 9.3.2 |     version: 9.4.1 | ||||||
|     repository: https://charts.bitnami.com/bitnami |     repository: https://charts.bitnami.com/bitnami | ||||||
|     condition: install.postgresql |     condition: install.postgresql | ||||||
|   - name: redis |   - name: redis | ||||||
|     version: 10.7.16 |     version: 10.9.0 | ||||||
|     repository: https://charts.bitnami.com/bitnami |     repository: https://charts.bitnami.com/bitnami | ||||||
|     condition: install.redis |     condition: install.redis | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ metadata: | |||||||
|     app.kubernetes.io/managed-by: {{ .Release.Service }} |     app.kubernetes.io/managed-by: {{ .Release.Service }} | ||||||
|     k8s.passbook.beryju.org/component: web |     k8s.passbook.beryju.org/component: web | ||||||
| spec: | spec: | ||||||
|   replicas: {{ serverReplicas }} |   replicas: {{ .Values.serverReplicas }} | ||||||
|   selector: |   selector: | ||||||
|     matchLabels: |     matchLabels: | ||||||
|       app.kubernetes.io/name: {{ include "passbook.name" . }} |       app.kubernetes.io/name: {{ include "passbook.name" . }} | ||||||
| @ -22,10 +22,29 @@ spec: | |||||||
|         app.kubernetes.io/instance: {{ .Release.Name }} |         app.kubernetes.io/instance: {{ .Release.Name }} | ||||||
|         k8s.passbook.beryju.org/component: web |         k8s.passbook.beryju.org/component: web | ||||||
|     spec: |     spec: | ||||||
|  |       affinity: | ||||||
|  |         podAntiAffinity: | ||||||
|  |           preferredDuringSchedulingIgnoredDuringExecution: | ||||||
|  |           - weight: 1 | ||||||
|  |             podAffinityTerm: | ||||||
|  |               labelSelector: | ||||||
|  |                 matchExpressions: | ||||||
|  |                 - key: app.kubernetes.io/name | ||||||
|  |                   operator: In | ||||||
|  |                   values: | ||||||
|  |                   - {{ include "passbook.name" . }} | ||||||
|  |                 - key: app.kubernetes.io/instance | ||||||
|  |                   operator: In | ||||||
|  |                   values: | ||||||
|  |                   - {{ .Release.Name }} | ||||||
|  |                 - key: k8s.passbook.beryju.org/component | ||||||
|  |                   operator: In | ||||||
|  |                   values: | ||||||
|  |                   - web | ||||||
|  |               topologyKey: "kubernetes.io/hostname" | ||||||
|       initContainers: |       initContainers: | ||||||
|         - name: passbook-database-migrations |         - name: passbook-database-migrations | ||||||
|           image: "{{ .Values.image.name }}:{{ .Values.image.tag }}" |           image: "{{ .Values.image.name }}:{{ .Values.image.tag }}" | ||||||
|           imagePullPolicy: Always |  | ||||||
|           args: [migrate] |           args: [migrate] | ||||||
|           envFrom: |           envFrom: | ||||||
|             - configMapRef: |             - configMapRef: | ||||||
| @ -50,7 +69,6 @@ spec: | |||||||
|       containers: |       containers: | ||||||
|         - name: {{ .Chart.Name }} |         - name: {{ .Chart.Name }} | ||||||
|           image: "{{ .Values.image.name }}:{{ .Values.image.tag }}" |           image: "{{ .Values.image.name }}:{{ .Values.image.tag }}" | ||||||
|           imagePullPolicy: Always |  | ||||||
|           args: [server] |           args: [server] | ||||||
|           envFrom: |           envFrom: | ||||||
|             - configMapRef: |             - configMapRef: | ||||||
| @ -93,7 +111,7 @@ spec: | |||||||
|           resources: |           resources: | ||||||
|             requests: |             requests: | ||||||
|               cpu: 100m |               cpu: 100m | ||||||
|               memory: 200M |               memory: 300M | ||||||
|             limits: |             limits: | ||||||
|               cpu: 300m |               cpu: 300m | ||||||
|               memory: 350M |               memory: 500M | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ metadata: | |||||||
|     app.kubernetes.io/managed-by: {{ .Release.Service }} |     app.kubernetes.io/managed-by: {{ .Release.Service }} | ||||||
|     k8s.passbook.beryju.org/component: worker |     k8s.passbook.beryju.org/component: worker | ||||||
| spec: | spec: | ||||||
|   replicas: {{ workerReplicas }} |   replicas: {{ .Values.workerReplicas }} | ||||||
|   selector: |   selector: | ||||||
|     matchLabels: |     matchLabels: | ||||||
|       app.kubernetes.io/name: {{ include "passbook.name" . }} |       app.kubernetes.io/name: {{ include "passbook.name" . }} | ||||||
| @ -22,6 +22,26 @@ spec: | |||||||
|         app.kubernetes.io/instance: {{ .Release.Name }} |         app.kubernetes.io/instance: {{ .Release.Name }} | ||||||
|         k8s.passbook.beryju.org/component: worker |         k8s.passbook.beryju.org/component: worker | ||||||
|     spec: |     spec: | ||||||
|  |       affinity: | ||||||
|  |         podAntiAffinity: | ||||||
|  |           preferredDuringSchedulingIgnoredDuringExecution: | ||||||
|  |           - weight: 1 | ||||||
|  |             podAffinityTerm: | ||||||
|  |               labelSelector: | ||||||
|  |                 matchExpressions: | ||||||
|  |                 - key: app.kubernetes.io/name | ||||||
|  |                   operator: In | ||||||
|  |                   values: | ||||||
|  |                   - {{ include "passbook.name" . }} | ||||||
|  |                 - key: app.kubernetes.io/instance | ||||||
|  |                   operator: In | ||||||
|  |                   values: | ||||||
|  |                   - {{ .Release.Name }} | ||||||
|  |                 - key: k8s.passbook.beryju.org/component | ||||||
|  |                   operator: In | ||||||
|  |                   values: | ||||||
|  |                   - worker | ||||||
|  |               topologyKey: "kubernetes.io/hostname" | ||||||
|       containers: |       containers: | ||||||
|         - name: {{ .Chart.Name }} |         - name: {{ .Chart.Name }} | ||||||
|           image: "{{ .Values.image.name }}:{{ .Values.image.tag }}" |           image: "{{ .Values.image.name }}:{{ .Values.image.tag }}" | ||||||
| @ -50,7 +70,7 @@ spec: | |||||||
|           resources: |           resources: | ||||||
|             requests: |             requests: | ||||||
|               cpu: 150m |               cpu: 150m | ||||||
|               memory: 300M |               memory: 400M | ||||||
|             limits: |             limits: | ||||||
|               cpu: 300m |               cpu: 300m | ||||||
|               memory: 500M |               memory: 600M | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ | |||||||
| image: | image: | ||||||
|   name: beryju/passbook |   name: beryju/passbook | ||||||
|   name_static: beryju/passbook-static |   name_static: beryju/passbook-static | ||||||
|   tag: 0.10.0-rc1 |   tag: 0.10.3-stable | ||||||
|  |  | ||||||
| nameOverride: "" | nameOverride: "" | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ printf '{"event": "Bootstrap completed", "level": "info", "logger": "bootstrap", | |||||||
| if [[ "$1" == "server" ]]; then | if [[ "$1" == "server" ]]; then | ||||||
|     gunicorn -c /lifecycle/gunicorn.conf.py passbook.root.asgi:application |     gunicorn -c /lifecycle/gunicorn.conf.py passbook.root.asgi:application | ||||||
| elif [[ "$1" == "worker" ]]; then | elif [[ "$1" == "worker" ]]; then | ||||||
|     celery worker --autoscale=10,3 -E -B -A=passbook.root.celery -s=/tmp/celerybeat-schedule |     celery worker --autoscale=10,3 -E -B -A=passbook.root.celery -s=/tmp/celerybeat-schedule -Q passbook,passbook_scheduled | ||||||
| elif [[ "$1" == "migrate" ]]; then | elif [[ "$1" == "migrate" ]]; then | ||||||
|     # Run system migrations first, run normal migrations after |     # Run system migrations first, run normal migrations after | ||||||
|     python -m lifecycle.migrate |     python -m lifecycle.migrate | ||||||
|  | |||||||
| @ -1,9 +1,10 @@ | |||||||
| """Gunicorn config""" | """Gunicorn config""" | ||||||
|  | from multiprocessing import cpu_count | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
| import structlog | import structlog | ||||||
|  |  | ||||||
| bind = "0.0.0.0:8000" | bind = "0.0.0.0:8000" | ||||||
| workers = 2 |  | ||||||
| threads = 4 |  | ||||||
|  |  | ||||||
| user = "passbook" | user = "passbook" | ||||||
| group = "passbook" | group = "passbook" | ||||||
| @ -40,3 +41,11 @@ logconfig_dict = { | |||||||
|         "gunicorn": {"handlers": ["console"], "level": "INFO", "propagate": False}, |         "gunicorn": {"handlers": ["console"], "level": "INFO", "propagate": False}, | ||||||
|     }, |     }, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | # if we're running in kubernetes, use fixed workers because we can scale with more pods | ||||||
|  | # otherwise (assume docker-compose), use as much as we can | ||||||
|  | if Path("/var/run/secrets/kubernetes.io").exists(): | ||||||
|  |     workers = 2 | ||||||
|  | else: | ||||||
|  |     worker = cpu_count() * 2 + 1 | ||||||
|  | threads = 4 | ||||||
|  | |||||||
| @ -30,7 +30,10 @@ nav: | |||||||
|     - OAuth2: providers/oauth2.md |     - OAuth2: providers/oauth2.md | ||||||
|     - SAML: providers/saml.md |     - SAML: providers/saml.md | ||||||
|     - Proxy: providers/proxy.md |     - Proxy: providers/proxy.md | ||||||
|   - Outposts: outposts/outposts.md |   - Outposts: | ||||||
|  |     - Overview: outposts/outposts.md | ||||||
|  |     - Deploy on docker-compose: outposts/deploy-docker-compose.md | ||||||
|  |     - Deploy on Kubernetes: outposts/deploy-kubernetes.md | ||||||
|   - Expressions: |   - Expressions: | ||||||
|     - Overview: expressions/index.md |     - Overview: expressions/index.md | ||||||
|     - Reference: |     - Reference: | ||||||
| @ -49,9 +52,12 @@ nav: | |||||||
|         - Harbor: integrations/services/harbor/index.md |         - Harbor: integrations/services/harbor/index.md | ||||||
|         - Sentry: integrations/services/sentry/index.md |         - Sentry: integrations/services/sentry/index.md | ||||||
|         - Ansible Tower/AWX: integrations/services/tower-awx/index.md |         - Ansible Tower/AWX: integrations/services/tower-awx/index.md | ||||||
|  |         - VMware vCenter: integrations/services/vmware-vcenter/index.md | ||||||
|   - Upgrading: |   - Upgrading: | ||||||
|     - to 0.9: upgrading/to-0.9.md |     - to 0.9: upgrading/to-0.9.md | ||||||
|     - to 0.10: upgrading/to-0.10.md |     - to 0.10: upgrading/to-0.10.md | ||||||
|  |   - Troubleshooting: | ||||||
|  |     - Access problems: troubleshooting/access.md | ||||||
|  |  | ||||||
| repo_name: "BeryJu/passbook" | repo_name: "BeryJu/passbook" | ||||||
| repo_url: https://github.com/BeryJu/passbook | repo_url: https://github.com/BeryJu/passbook | ||||||
|  | |||||||
| @ -1,2 +1,2 @@ | |||||||
| """passbook""" | """passbook""" | ||||||
| __version__ = "0.10.0-rc1" | __version__ = "0.10.3-stable" | ||||||
|  | |||||||
| @ -15,11 +15,8 @@ class CodeMirrorWidget(forms.Textarea): | |||||||
|         self.mode = mode |         self.mode = mode | ||||||
|  |  | ||||||
|     def render(self, *args, **kwargs): |     def render(self, *args, **kwargs): | ||||||
|         if "attrs" not in kwargs: |         attrs = kwargs.setdefault("attrs", {}) | ||||||
|             kwargs["attrs"] = {} |         attrs.setdefault("class", "") | ||||||
|         attrs = kwargs["attrs"] |  | ||||||
|         if "class" not in attrs: |  | ||||||
|             attrs["class"] = "" |  | ||||||
|         attrs["class"] += " codemirror" |         attrs["class"] += " codemirror" | ||||||
|         attrs["data-cm-mode"] = self.mode |         attrs["data-cm-mode"] = self.mode | ||||||
|         return super().render(*args, **kwargs) |         return super().render(*args, **kwargs) | ||||||
|  | |||||||
| @ -12,7 +12,7 @@ class UserForm(forms.ModelForm): | |||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = User |         model = User | ||||||
|         fields = ["username", "name", "email", "is_staff", "is_active", "attributes"] |         fields = ["username", "name", "email", "is_active", "attributes"] | ||||||
|         widgets = { |         widgets = { | ||||||
|             "name": forms.TextInput, |             "name": forms.TextInput, | ||||||
|             "attributes": CodeMirrorWidget, |             "attributes": CodeMirrorWidget, | ||||||
|  | |||||||
| @ -50,7 +50,7 @@ | |||||||
|                     </td> |                     </td> | ||||||
|                     <td role="cell"> |                     <td role="cell"> | ||||||
|                         <span> |                         <span> | ||||||
|                             {{ group.user_set.all|length }} |                             {{ group.users.all|length }} | ||||||
|                         </span> |                         </span> | ||||||
|                     </td> |                     </td> | ||||||
|                     <td> |                     <td> | ||||||
|  | |||||||
| @ -69,6 +69,7 @@ | |||||||
|                     <td> |                     <td> | ||||||
|                         <a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:outpost-update' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a> |                         <a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:outpost-update' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a> | ||||||
|                         <a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:outpost-delete' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a> |                         <a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:outpost-delete' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a> | ||||||
|  |                         <a href="https://passbook.beryju.org/outposts/outposts/#deploy">{% trans 'Deploy' %}</a> | ||||||
|                     </td> |                     </td> | ||||||
|                 </tr> |                 </tr> | ||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|  | |||||||
| @ -55,7 +55,7 @@ | |||||||
|                     <th role="columnheader"> |                     <th role="columnheader"> | ||||||
|                         <div> |                         <div> | ||||||
|                             <div>{{ policy.name }}</div> |                             <div>{{ policy.name }}</div> | ||||||
|                             {% if not policy.bindings.exists %} |                             {% if not policy.bindings.exists and not policy.promptstage_set.exists %} | ||||||
|                             <i class="pf-icon pf-icon-warning-triangle"></i> |                             <i class="pf-icon pf-icon-warning-triangle"></i> | ||||||
|                             <small>{% trans 'Warning: Policy is not assigned.' %}</small> |                             <small>{% trans 'Warning: Policy is not assigned.' %}</small> | ||||||
|                             {% else %} |                             {% else %} | ||||||
|  | |||||||
| @ -8,7 +8,7 @@ from django.test import Client, TestCase | |||||||
| from django.urls.exceptions import NoReverseMatch | from django.urls.exceptions import NoReverseMatch | ||||||
|  |  | ||||||
| from passbook.admin.urls import urlpatterns | from passbook.admin.urls import urlpatterns | ||||||
| from passbook.core.models import User | from passbook.core.models import Group, User | ||||||
| from passbook.lib.utils.reflection import get_apps | from passbook.lib.utils.reflection import get_apps | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -16,7 +16,9 @@ class TestAdmin(TestCase): | |||||||
|     """Generic admin tests""" |     """Generic admin tests""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.user = User.objects.create_superuser(username="test") |         self.user = User.objects.create_user(username="test") | ||||||
|  |         self.user.pb_groups.add(Group.objects.filter(is_superuser=True).first()) | ||||||
|  |         self.user.save() | ||||||
|         self.client = Client() |         self.client = Client() | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import ListView, UpdateView | from django.views.generic import ListView, UpdateView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import ListView, UpdateView | from django.views.generic import ListView, UpdateView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ from django.contrib.auth.mixins import ( | |||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.http import HttpRequest, HttpResponse, JsonResponse | from django.http import HttpRequest, HttpResponse, JsonResponse | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import DetailView, FormView, ListView, UpdateView | from django.views.generic import DetailView, FormView, ListView, UpdateView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import ListView, UpdateView | from django.views.generic import ListView, UpdateView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import ListView, UpdateView | from django.views.generic import ListView, UpdateView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,8 +1,10 @@ | |||||||
| """passbook administration overview""" | """passbook administration overview""" | ||||||
|  | from typing import Union | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.shortcuts import redirect, reverse | from django.shortcuts import redirect, reverse | ||||||
| from django.views.generic import TemplateView | from django.views.generic import TemplateView | ||||||
| from packaging.version import Version, parse | from packaging.version import LegacyVersion, Version, parse | ||||||
| from requests import RequestException, get | from requests import RequestException, get | ||||||
|  |  | ||||||
| from passbook import __version__ | from passbook import __version__ | ||||||
| @ -16,7 +18,7 @@ from passbook.stages.invitation.models import Invitation | |||||||
| VERSION_CACHE_KEY = "passbook_latest_version" | VERSION_CACHE_KEY = "passbook_latest_version" | ||||||
|  |  | ||||||
|  |  | ||||||
| def latest_version() -> Version: | def latest_version() -> Union[LegacyVersion, Version]: | ||||||
|     """Get latest release from GitHub, cached""" |     """Get latest release from GitHub, cached""" | ||||||
|     if not cache.get(VERSION_CACHE_KEY): |     if not cache.get(VERSION_CACHE_KEY): | ||||||
|         try: |         try: | ||||||
| @ -45,7 +47,7 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView): | |||||||
|     def get_context_data(self, **kwargs): |     def get_context_data(self, **kwargs): | ||||||
|         kwargs["application_count"] = len(Application.objects.all()) |         kwargs["application_count"] = len(Application.objects.all()) | ||||||
|         kwargs["policy_count"] = len(Policy.objects.all()) |         kwargs["policy_count"] = len(Policy.objects.all()) | ||||||
|         kwargs["user_count"] = len(User.objects.all()) |         kwargs["user_count"] = len(User.objects.all()) - 1  # Remove anonymous user | ||||||
|         kwargs["provider_count"] = len(Provider.objects.all()) |         kwargs["provider_count"] = len(Provider.objects.all()) | ||||||
|         kwargs["source_count"] = len(Source.objects.all()) |         kwargs["source_count"] = len(Source.objects.all()) | ||||||
|         kwargs["stage_count"] = len(Stage.objects.all()) |         kwargs["stage_count"] = len(Stage.objects.all()) | ||||||
| @ -58,7 +60,7 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView): | |||||||
|             application=None |             application=None | ||||||
|         ) |         ) | ||||||
|         kwargs["policies_without_binding"] = len( |         kwargs["policies_without_binding"] = len( | ||||||
|             Policy.objects.filter(bindings__isnull=True) |             Policy.objects.filter(bindings__isnull=True, promptstage__isnull=True) | ||||||
|         ) |         ) | ||||||
|         kwargs["cached_policies"] = len(cache.keys("policy_*")) |         kwargs["cached_policies"] = len(cache.keys("policy_*")) | ||||||
|         kwargs["cached_flows"] = len(cache.keys("flow_*")) |         kwargs["cached_flows"] = len(cache.keys("flow_*")) | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ from django.contrib.messages.views import SuccessMessageMixin | |||||||
| from django.db.models import QuerySet | from django.db.models import QuerySet | ||||||
| from django.http import HttpResponse | from django.http import HttpResponse | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import FormView | from django.views.generic import FormView | ||||||
| from django.views.generic.detail import DetailView | from django.views.generic.detail import DetailView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ from django.contrib.auth.mixins import ( | |||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.db.models import QuerySet | from django.db.models import QuerySet | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import ListView, UpdateView | from django.views.generic import ListView, UpdateView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
| from passbook.admin.views.utils import ( | from passbook.admin.views.utils import ( | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
| from passbook.admin.views.utils import ( | from passbook.admin.views.utils import ( | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
| from passbook.admin.views.utils import ( | from passbook.admin.views.utils import ( | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
| from passbook.admin.views.utils import ( | from passbook.admin.views.utils import ( | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import ListView, UpdateView | from django.views.generic import ListView, UpdateView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ from django.contrib.auth.mixins import ( | |||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.http import HttpResponseRedirect | from django.http import HttpResponseRedirect | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import ListView | from django.views.generic import ListView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import ListView, UpdateView | from django.views.generic import ListView, UpdateView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """passbook Token administration""" | """passbook Token administration""" | ||||||
| from django.contrib.auth.mixins import LoginRequiredMixin | from django.contrib.auth.mixins import LoginRequiredMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import ListView | from django.views.generic import ListView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  |  | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ from django.http import HttpRequest, HttpResponse | |||||||
| from django.shortcuts import redirect | from django.shortcuts import redirect | ||||||
| from django.urls import reverse, reverse_lazy | from django.urls import reverse, reverse_lazy | ||||||
| from django.utils.http import urlencode | from django.utils.http import urlencode | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import DetailView, ListView, UpdateView | from django.views.generic import DetailView, ListView, UpdateView | ||||||
| from guardian.mixins import ( | from guardian.mixins import ( | ||||||
|     PermissionListMixin, |     PermissionListMixin, | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """api v2 urls""" | """api v2 urls""" | ||||||
| from django.conf.urls import url | from django.urls import path, re_path | ||||||
| from django.urls import path |  | ||||||
| from drf_yasg import openapi | from drf_yasg import openapi | ||||||
| from drf_yasg.views import get_schema_view | from drf_yasg.views import get_schema_view | ||||||
| from rest_framework import routers | from rest_framework import routers | ||||||
| @ -119,7 +118,7 @@ SchemaView = get_schema_view( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     url( |     re_path( | ||||||
|         r"^swagger(?P<format>\.json|\.yaml)$", |         r"^swagger(?P<format>\.json|\.yaml)$", | ||||||
|         SchemaView.without_ui(cache_timeout=0), |         SchemaView.without_ui(cache_timeout=0), | ||||||
|         name="schema-json", |         name="schema-json", | ||||||
|  | |||||||
| @ -11,7 +11,7 @@ class GroupSerializer(ModelSerializer): | |||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = Group |         model = Group | ||||||
|         fields = ["pk", "name", "parent", "user_set", "attributes"] |         fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class GroupViewSet(ModelViewSet): | class GroupViewSet(ModelViewSet): | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """User API Views""" | """User API Views""" | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import BooleanField, ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| @ -8,10 +8,12 @@ from passbook.core.models import User | |||||||
| class UserSerializer(ModelSerializer): | class UserSerializer(ModelSerializer): | ||||||
|     """User Serializer""" |     """User Serializer""" | ||||||
|  |  | ||||||
|  |     is_superuser = BooleanField(read_only=True) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = User |         model = User | ||||||
|         fields = ["pk", "username", "name", "email"] |         fields = ["pk", "username", "name", "is_superuser", "email"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserViewSet(ModelViewSet): | class UserViewSet(ModelViewSet): | ||||||
|  | |||||||
| @ -29,7 +29,16 @@ class ApplicationForm(forms.ModelForm): | |||||||
|         ] |         ] | ||||||
|         widgets = { |         widgets = { | ||||||
|             "name": forms.TextInput(), |             "name": forms.TextInput(), | ||||||
|             "meta_launch_url": forms.TextInput(), |             "meta_launch_url": forms.TextInput( | ||||||
|  |                 attrs={ | ||||||
|  |                     "placeholder": _( | ||||||
|  |                         ( | ||||||
|  |                             "If left empty, passbook will try to extract the launch URL " | ||||||
|  |                             "based on the selected provider." | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|             "meta_icon_url": forms.TextInput(), |             "meta_icon_url": forms.TextInput(), | ||||||
|             "meta_publisher": forms.TextInput(), |             "meta_publisher": forms.TextInput(), | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -18,21 +18,19 @@ class GroupForm(forms.ModelForm): | |||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|         super().__init__(*args, **kwargs) |         super().__init__(*args, **kwargs) | ||||||
|         if self.instance.pk: |         if self.instance.pk: | ||||||
|             self.initial["members"] = self.instance.user_set.values_list( |             self.initial["members"] = self.instance.users.values_list("pk", flat=True) | ||||||
|                 "pk", flat=True |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def save(self, *args, **kwargs): |     def save(self, *args, **kwargs): | ||||||
|         instance = super().save(*args, **kwargs) |         instance = super().save(*args, **kwargs) | ||||||
|         if instance.pk: |         if instance.pk: | ||||||
|             instance.user_set.clear() |             instance.users.clear() | ||||||
|             instance.user_set.add(*self.cleaned_data["members"]) |             instance.users.add(*self.cleaned_data["members"]) | ||||||
|         return instance |         return instance | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = Group |         model = Group | ||||||
|         fields = ["name", "parent", "members", "attributes"] |         fields = ["name", "is_superuser", "parent", "members", "attributes"] | ||||||
|         widgets = { |         widgets = { | ||||||
|             "name": forms.TextInput(), |             "name": forms.TextInput(), | ||||||
|             "attributes": CodeMirrorWidget, |             "attributes": CodeMirrorWidget, | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| # Generated by Django 3.0.6 on 2020-05-23 16:40 | # Generated by Django 3.0.6 on 2020-05-23 16:40 | ||||||
|  |  | ||||||
| from django.apps.registry import Apps | from django.apps.registry import Apps | ||||||
| from django.db import migrations | from django.db import migrations, models | ||||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor | from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -15,8 +15,6 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|         username="pbadmin", email="root@localhost", name="passbook Default Admin" |         username="pbadmin", email="root@localhost", name="passbook Default Admin" | ||||||
|     ) |     ) | ||||||
|     pbadmin.set_password("pbadmin")  # noqa # nosec |     pbadmin.set_password("pbadmin")  # noqa # nosec | ||||||
|     pbadmin.is_superuser = True |  | ||||||
|     pbadmin.is_staff = True |  | ||||||
|     pbadmin.save() |     pbadmin.save() | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -27,5 +25,15 @@ class Migration(migrations.Migration): | |||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     operations = [ |     operations = [ | ||||||
|  |         migrations.RemoveField(model_name="user", name="is_superuser",), | ||||||
|  |         migrations.RemoveField(model_name="user", name="is_staff",), | ||||||
|         migrations.RunPython(create_default_user), |         migrations.RunPython(create_default_user), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="user", | ||||||
|  |             name="is_superuser", | ||||||
|  |             field=models.BooleanField(default=False), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="user", name="is_staff", field=models.BooleanField(default=False) | ||||||
|  |         ), | ||||||
|     ] |     ] | ||||||
|  | |||||||
							
								
								
									
										49
									
								
								passbook/core/migrations/0009_group_is_superuser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								passbook/core/migrations/0009_group_is_superuser.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,49 @@ | |||||||
|  | # Generated by Django 3.1.1 on 2020-09-15 19:53 | ||||||
|  | from django.apps.registry import Apps | ||||||
|  | from django.db import migrations, models | ||||||
|  | from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||||
|  |  | ||||||
|  | import passbook.core.models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def create_default_admin_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  |     db_alias = schema_editor.connection.alias | ||||||
|  |     Group = apps.get_model("passbook_core", "Group") | ||||||
|  |     User = apps.get_model("passbook_core", "User") | ||||||
|  |  | ||||||
|  |     # Creates a default admin group | ||||||
|  |     group, _ = Group.objects.using(db_alias).get_or_create( | ||||||
|  |         is_superuser=True, defaults={"name": "passbook Admins",} | ||||||
|  |     ) | ||||||
|  |     group.users.set(User.objects.filter(username="pbadmin")) | ||||||
|  |     group.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("passbook_core", "0008_auto_20200824_1532"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.RemoveField(model_name="user", name="is_superuser",), | ||||||
|  |         migrations.RemoveField(model_name="user", name="is_staff",), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="user", | ||||||
|  |             name="pb_groups", | ||||||
|  |             field=models.ManyToManyField( | ||||||
|  |                 related_name="users", to="passbook_core.Group" | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="group", | ||||||
|  |             name="is_superuser", | ||||||
|  |             field=models.BooleanField( | ||||||
|  |                 default=False, help_text="Users added to this group will be superusers." | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.RunPython(create_default_admin_group), | ||||||
|  |         migrations.AlterModelManagers( | ||||||
|  |             name="user", managers=[("objects", passbook.core.models.UserManager()),], | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -4,6 +4,7 @@ from typing import Any, Optional, Type | |||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from django.contrib.auth.models import AbstractUser | from django.contrib.auth.models import AbstractUser | ||||||
|  | from django.contrib.auth.models import UserManager as DjangoUserManager | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.db.models import Q, QuerySet | from django.db.models import Q, QuerySet | ||||||
| from django.forms import ModelForm | from django.forms import ModelForm | ||||||
| @ -22,6 +23,7 @@ from passbook.lib.models import CreatedUpdatedModel | |||||||
| from passbook.policies.models import PolicyBindingModel | from passbook.policies.models import PolicyBindingModel | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | PASSBOOK_USER_DEBUG = "passbook_user_debug" | ||||||
|  |  | ||||||
|  |  | ||||||
| def default_token_duration(): | def default_token_duration(): | ||||||
| @ -33,7 +35,12 @@ class Group(models.Model): | |||||||
|     """Custom Group model which supports a basic hierarchy""" |     """Custom Group model which supports a basic hierarchy""" | ||||||
|  |  | ||||||
|     group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) |     group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||||
|  |  | ||||||
|     name = models.CharField(_("name"), max_length=80) |     name = models.CharField(_("name"), max_length=80) | ||||||
|  |     is_superuser = models.BooleanField( | ||||||
|  |         default=False, help_text=_("Users added to this group will be superusers.") | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     parent = models.ForeignKey( |     parent = models.ForeignKey( | ||||||
|         "Group", |         "Group", | ||||||
|         blank=True, |         blank=True, | ||||||
| @ -51,6 +58,14 @@ class Group(models.Model): | |||||||
|         unique_together = (("name", "parent",),) |         unique_together = (("name", "parent",),) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class UserManager(DjangoUserManager): | ||||||
|  |     """Custom user manager that doesn't assign is_superuser and is_staff""" | ||||||
|  |  | ||||||
|  |     def create_user(self, username, email=None, password=None, **extra_fields): | ||||||
|  |         """Custom user manager that doesn't assign is_superuser and is_staff""" | ||||||
|  |         return self._create_user(username, email, password, **extra_fields) | ||||||
|  |  | ||||||
|  |  | ||||||
| class User(GuardianUserMixin, AbstractUser): | class User(GuardianUserMixin, AbstractUser): | ||||||
|     """Custom User model to allow easier adding o f user-based settings""" |     """Custom User model to allow easier adding o f user-based settings""" | ||||||
|  |  | ||||||
| @ -58,11 +73,23 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|     name = models.TextField(help_text=_("User's display name.")) |     name = models.TextField(help_text=_("User's display name.")) | ||||||
|  |  | ||||||
|     sources = models.ManyToManyField("Source", through="UserSourceConnection") |     sources = models.ManyToManyField("Source", through="UserSourceConnection") | ||||||
|     pb_groups = models.ManyToManyField("Group") |     pb_groups = models.ManyToManyField("Group", related_name="users") | ||||||
|     password_change_date = models.DateTimeField(auto_now_add=True) |     password_change_date = models.DateTimeField(auto_now_add=True) | ||||||
|  |  | ||||||
|     attributes = models.JSONField(default=dict, blank=True) |     attributes = models.JSONField(default=dict, blank=True) | ||||||
|  |  | ||||||
|  |     objects = UserManager() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_superuser(self) -> bool: | ||||||
|  |         """Get supseruser status based on membership in a group with superuser status""" | ||||||
|  |         return self.pb_groups.filter(is_superuser=True).exists() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_staff(self) -> bool: | ||||||
|  |         """superuser == staff user""" | ||||||
|  |         return self.is_superuser | ||||||
|  |  | ||||||
|     def set_password(self, password): |     def set_password(self, password): | ||||||
|         if self.pk: |         if self.pk: | ||||||
|             password_changed.send(sender=self, user=self, password=password) |             password_changed.send(sender=self, user=self, password=password) | ||||||
| @ -92,6 +119,12 @@ class Provider(models.Model): | |||||||
|  |  | ||||||
|     objects = InheritanceManager() |     objects = InheritanceManager() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def launch_url(self) -> Optional[str]: | ||||||
|  |         """URL to this provider and initiate authorization for the user. | ||||||
|  |         Can return None for providers that are not URL-based""" | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     def form(self) -> Type[ModelForm]: |     def form(self) -> Type[ModelForm]: | ||||||
|         """Return Form class used to edit this object""" |         """Return Form class used to edit this object""" | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
| @ -119,6 +152,14 @@ class Application(PolicyBindingModel): | |||||||
|     meta_description = models.TextField(default="", blank=True) |     meta_description = models.TextField(default="", blank=True) | ||||||
|     meta_publisher = models.TextField(default="", blank=True) |     meta_publisher = models.TextField(default="", blank=True) | ||||||
|  |  | ||||||
|  |     def get_launch_url(self) -> Optional[str]: | ||||||
|  |         """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" | ||||||
|  |         if self.meta_launch_url: | ||||||
|  |             return self.meta_launch_url | ||||||
|  |         if self.provider: | ||||||
|  |             return self.provider.launch_url | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     def get_provider(self) -> Optional[Provider]: |     def get_provider(self) -> Optional[Provider]: | ||||||
|         """Get casted provider instance""" |         """Get casted provider instance""" | ||||||
|         if not self.provider: |         if not self.provider: | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| """passbook core tasks""" | """passbook core tasks""" | ||||||
|  | from django.utils.timezone import now | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
| from passbook.core.models import ExpiringModel | from passbook.core.models import ExpiringModel | ||||||
| @ -12,5 +13,10 @@ def clean_expired_models(): | |||||||
|     """Remove expired objects""" |     """Remove expired objects""" | ||||||
|     for cls in ExpiringModel.__subclasses__(): |     for cls in ExpiringModel.__subclasses__(): | ||||||
|         cls: ExpiringModel |         cls: ExpiringModel | ||||||
|         amount, _ = cls.filter_not_expired().delete() |         amount, _ = ( | ||||||
|  |             cls.objects.all() | ||||||
|  |             .exclude(expiring=False) | ||||||
|  |             .exclude(expiring=True, expires__gt=now()) | ||||||
|  |             .delete() | ||||||
|  |         ) | ||||||
|         LOGGER.debug("Deleted expired models", model=cls, amount=amount) |         LOGGER.debug("Deleted expired models", model=cls, amount=amount) | ||||||
|  | |||||||
| @ -20,8 +20,12 @@ | |||||||
|                 </button> |                 </button> | ||||||
|             </div> |             </div> | ||||||
|             <a class="pf-c-page__header-brand-link"> |             <a class="pf-c-page__header-brand-link"> | ||||||
|                 <img class="pf-c-brand" src="{% static 'passbook/logo.png' %}" alt="" /> |                 <div class="pf-c-brand pb-brand"> | ||||||
|                 <img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" alt="passbook" /> |                     <img src="{{ config.passbook.branding.logo }}" alt="passbook icon"> | ||||||
|  |                     {% if config.passbook.branding.title_show %} | ||||||
|  |                     <small><small>{{ config.passbook.branding.title }}</small></small> | ||||||
|  |                     {% endif %} | ||||||
|  |                 </div> | ||||||
|             </a> |             </a> | ||||||
|         </div> |         </div> | ||||||
|         <div class="pf-c-page__header-nav"> |         <div class="pf-c-page__header-nav"> | ||||||
|  | |||||||
| @ -6,15 +6,17 @@ | |||||||
|  |  | ||||||
| <html lang="en"> | <html lang="en"> | ||||||
|     <head> |     <head> | ||||||
|  |         <link rel="preload" href="{% static 'passbook/fonts/DINEngschriftStd.woff2' %}" as="font" type="font/woff2" crossorigin> | ||||||
|  |         <link rel="preload" href="{% static 'passbook/fonts/DINEngschriftStd.woff' %}" as="font" type="font/woff" crossorigin> | ||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
|         <title>{% block title %}{% trans title|default:"passbook" %}{% endblock %}</title> |         <title>{% block title %}{% trans title|default:config.passbook.branding.title %}{% endblock %}</title> | ||||||
|         <link rel="icon" type="image/png" href="{% static 'passbook/logo.png' %}"> |         <link rel="icon" type="image/png" href="{% static 'passbook/logo.png' %}"> | ||||||
|         <link rel="shortcut icon" type="image/png" href="{% static 'passbook/logo.png' %}"> |         <link rel="shortcut icon" type="image/png" href="{% static 'passbook/logo.png' %}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly.css' %}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly-addons.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly-addons.css' %}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css' %}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'passbook/pf.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'passbook/passbook.css' %}"> | ||||||
|         {% block head %} |         {% block head %} | ||||||
|         {% endblock %} |         {% endblock %} | ||||||
|     </head> |     </head> | ||||||
| @ -35,6 +37,6 @@ | |||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         {% block scripts %} |         {% block scripts %} | ||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         <script src="{% static 'passbook/pf.js' %}"></script> |         <script src="{% static 'passbook/passbook.js' %}"></script> | ||||||
|     </body> |     </body> | ||||||
| </html> | </html> | ||||||
|  | |||||||
| @ -22,8 +22,12 @@ | |||||||
| <div class="pf-c-login"> | <div class="pf-c-login"> | ||||||
|     <div class="pf-c-login__container"> |     <div class="pf-c-login__container"> | ||||||
|         <header class="pf-c-login__header"> |         <header class="pf-c-login__header"> | ||||||
|             <img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;" alt="passbook icon" /> |             <div class="pf-c-brand pb-brand"> | ||||||
|             <img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;" alt="passbook branding" /> |                 <img src="{{ config.passbook.branding.logo }}" alt="passbook icon" /> | ||||||
|  |                 {% if config.passbook.branding.title_show %} | ||||||
|  |                 <p>{{ config.passbook.branding.title }}</p> | ||||||
|  |                 {% endif %} | ||||||
|  |             </div> | ||||||
|         </header> |         </header> | ||||||
|         {% block main_container %} |         {% block main_container %} | ||||||
|         <main class="pf-c-login__main"> |         <main class="pf-c-login__main"> | ||||||
| @ -47,6 +51,13 @@ | |||||||
|                     <a href="{{ link.href }}">{{ link.name }}</a> |                     <a href="{{ link.href }}">{{ link.name }}</a> | ||||||
|                 </li> |                 </li> | ||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|  |                 {% if config.passbook.branding.title != "passbook" %} | ||||||
|  |                 <li> | ||||||
|  |                     <a href="https://github.com/beryju/passbook"> | ||||||
|  |                         {% trans 'Powered by passbook' %} | ||||||
|  |                     </a> | ||||||
|  |                 </li> | ||||||
|  |                 {% endif %} | ||||||
|             </ul> |             </ul> | ||||||
|         </footer> |         </footer> | ||||||
|     </div> |     </div> | ||||||
|  | |||||||
| @ -1,29 +0,0 @@ | |||||||
| {% extends 'login/base_full.html' %} |  | ||||||
|  |  | ||||||
| {% load static %} |  | ||||||
| {% load i18n %} |  | ||||||
| {% load passbook_utils %} |  | ||||||
|  |  | ||||||
| {% block card_title %} |  | ||||||
| {% trans 'Permission denied' %} |  | ||||||
| {% endblock %} |  | ||||||
|  |  | ||||||
| {% block title %} |  | ||||||
| {% trans 'Permission denied' %} |  | ||||||
| {% endblock %} |  | ||||||
|  |  | ||||||
| {% block card %} |  | ||||||
|     <form method="POST" class="pf-c-form"> |  | ||||||
|         {% csrf_token %} |  | ||||||
|         {% include 'partials/form.html' %} |  | ||||||
|         <div class="pf-c-form__group"> |  | ||||||
|             <p> |  | ||||||
|                 <i class="pf-icon pf-icon-error-circle-o"></i> |  | ||||||
|                 {% trans 'Access denied' %} |  | ||||||
|             </p> |  | ||||||
|         </div> |  | ||||||
|         {% if 'back' in request.GET %} |  | ||||||
|         <a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a> |  | ||||||
|         {% endif %} |  | ||||||
|     </form> |  | ||||||
| {% endblock %} |  | ||||||
| @ -24,7 +24,7 @@ | |||||||
|     {% if applications %} |     {% if applications %} | ||||||
|     <div class="pf-l-gallery pf-m-gutter"> |     <div class="pf-l-gallery pf-m-gutter"> | ||||||
|         {% for app in applications %} |         {% for app in applications %} | ||||||
|         <a href="{{ app.meta_launch_url }}" class="pf-c-card pf-m-hoverable pf-m-compact"> |         <a href="{{ app.get_launch_url }}" class="pf-c-card pf-m-hoverable pf-m-compact"> | ||||||
|             <div class="pf-c-card__header"> |             <div class="pf-c-card__header"> | ||||||
|                 {% if not app.meta_icon_url %} |                 {% if not app.meta_icon_url %} | ||||||
|                 <i class="pf-icon pf-icon-arrow"></i> |                 <i class="pf-icon pf-icon-arrow"></i> | ||||||
|  | |||||||
| @ -13,7 +13,7 @@ class TestOverviewViews(TestCase): | |||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         self.user = User.objects.create_superuser( |         self.user = User.objects.create_user( | ||||||
|             username="unittest user", |             username="unittest user", | ||||||
|             email="unittest@example.com", |             email="unittest@example.com", | ||||||
|             password="".join( |             password="".join( | ||||||
|  | |||||||
| @ -13,7 +13,7 @@ class TestUserViews(TestCase): | |||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         self.user = User.objects.create_superuser( |         self.user = User.objects.create_user( | ||||||
|             username="unittest user", |             username="unittest user", | ||||||
|             email="unittest@example.com", |             email="unittest@example.com", | ||||||
|             password="".join( |             password="".join( | ||||||
|  | |||||||
| @ -1,30 +0,0 @@ | |||||||
| """passbook util view tests""" |  | ||||||
| import string |  | ||||||
| from random import SystemRandom |  | ||||||
|  |  | ||||||
| from django.test import RequestFactory, TestCase |  | ||||||
|  |  | ||||||
| from passbook.core.models import User |  | ||||||
| from passbook.core.views.utils import PermissionDeniedView |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestUtilViews(TestCase): |  | ||||||
|     """Test Utility Views""" |  | ||||||
|  |  | ||||||
|     def setUp(self): |  | ||||||
|         self.user = User.objects.create_superuser( |  | ||||||
|             username="unittest user", |  | ||||||
|             email="unittest@example.com", |  | ||||||
|             password="".join( |  | ||||||
|                 SystemRandom().choice(string.ascii_uppercase + string.digits) |  | ||||||
|                 for _ in range(8) |  | ||||||
|             ), |  | ||||||
|         ) |  | ||||||
|         self.factory = RequestFactory() |  | ||||||
|  |  | ||||||
|     def test_permission_denied_view(self): |  | ||||||
|         """Test PermissionDeniedView""" |  | ||||||
|         request = self.factory.get("something") |  | ||||||
|         request.user = self.user |  | ||||||
|         response = PermissionDeniedView.as_view()(request) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
| @ -1,14 +0,0 @@ | |||||||
| """passbook core utils view""" |  | ||||||
| from django.utils.translation import ugettext as _ |  | ||||||
| from django.views.generic import TemplateView |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class PermissionDeniedView(TemplateView): |  | ||||||
|     """Generic Permission denied view""" |  | ||||||
|  |  | ||||||
|     template_name = "login/denied.html" |  | ||||||
|     title = _("Permission denied.") |  | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs): |  | ||||||
|         kwargs["title"] = self.title |  | ||||||
|         return super().get_context_data(**kwargs) |  | ||||||
| @ -2,8 +2,8 @@ | |||||||
|  |  | ||||||
|  |  | ||||||
| class FlowNonApplicableException(BaseException): | class FlowNonApplicableException(BaseException): | ||||||
|     """Exception raised when a Flow does not apply to a user.""" |     """Flow does not apply to current user (denied by policy).""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class EmptyFlowException(BaseException): | class EmptyFlowException(BaseException): | ||||||
|     """Exception raised when a Flow Plan is empty""" |     """Flow has no stages.""" | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ from django.core.management.base import BaseCommand, no_translations | |||||||
| from passbook.flows.transfer.importer import FlowImporter | from passbook.flows.transfer.importer import FlowImporter | ||||||
|  |  | ||||||
|  |  | ||||||
| class Command(BaseCommand): | class Command(BaseCommand):  # pragma: no cover | ||||||
|     """Apply flow from commandline""" |     """Apply flow from commandline""" | ||||||
|  |  | ||||||
|     @no_translations |     @no_translations | ||||||
|  | |||||||
| @ -52,7 +52,7 @@ def create_default_source_enrollment_flow( | |||||||
|  |  | ||||||
|     # PromptStage to ask user for their username |     # PromptStage to ask user for their username | ||||||
|     prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( |     prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( | ||||||
|         name="default-source-enrollment-username-prompt", |         name="Welcome to passbook! Please select a username.", | ||||||
|     ) |     ) | ||||||
|     prompt, _ = Prompt.objects.using(db_alias).update_or_create( |     prompt, _ = Prompt.objects.using(db_alias).update_or_create( | ||||||
|         field_key="username", |         field_key="username", | ||||||
|  | |||||||
| @ -115,11 +115,12 @@ const updateFormAction = (form) => { | |||||||
|     for (let index = 0; index < form.elements.length; index++) { |     for (let index = 0; index < form.elements.length; index++) { | ||||||
|         const element = form.elements[index]; |         const element = form.elements[index]; | ||||||
|         if (element.value === form.action) { |         if (element.value === form.action) { | ||||||
|             console.log("Found Form action URL in form elements, not changing form action."); |             console.log("pb-flow: Found Form action URL in form elements, not changing form action."); | ||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     form.action = flowBodyUrl; |     form.action = flowBodyUrl; | ||||||
|  |     console.log(`pb-flow: updated form.action ${flowBodyUrl}`); | ||||||
|     return true; |     return true; | ||||||
| }; | }; | ||||||
| const checkAutosubmit = (form) => { | const checkAutosubmit = (form) => { | ||||||
| @ -129,11 +130,11 @@ const checkAutosubmit = (form) => { | |||||||
| }; | }; | ||||||
| const setFormSubmitHandlers = () => { | const setFormSubmitHandlers = () => { | ||||||
|     document.querySelectorAll("#flow-body form").forEach(form => { |     document.querySelectorAll("#flow-body form").forEach(form => { | ||||||
|         console.log(`Checking for autosubmit attribute ${form}`); |         console.log(`pb-flow: Checking for autosubmit attribute ${form}`); | ||||||
|         checkAutosubmit(form); |         checkAutosubmit(form); | ||||||
|         console.log(`Setting action for form ${form}`); |         console.log(`pb-flow: Setting action for form ${form}`); | ||||||
|         updateFormAction(form); |         updateFormAction(form); | ||||||
|         console.log(`Adding handler for form ${form}`); |         console.log(`pb-flow: Adding handler for form ${form}`); | ||||||
|         form.addEventListener('submit', (e) => { |         form.addEventListener('submit', (e) => { | ||||||
|             e.preventDefault(); |             e.preventDefault(); | ||||||
|             let formData = new FormData(form); |             let formData = new FormData(form); | ||||||
| @ -145,6 +146,7 @@ const setFormSubmitHandlers = () => { | |||||||
|                 updateCard(data); |                 updateCard(data); | ||||||
|             }); |             }); | ||||||
|         }); |         }); | ||||||
|  |         form.classList.add("pb-flow-wrapped"); | ||||||
|     }); |     }); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,9 +1,10 @@ | |||||||
| """flow views tests""" | """flow views tests""" | ||||||
| from unittest.mock import MagicMock, PropertyMock, patch | from unittest.mock import MagicMock, PropertyMock, patch | ||||||
|  |  | ||||||
|  | from django.http import HttpRequest, HttpResponse | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from passbook.flows.markers import ReevaluateMarker, StageMarker | from passbook.flows.markers import ReevaluateMarker, StageMarker | ||||||
| @ -12,6 +13,7 @@ from passbook.flows.planner import FlowPlan | |||||||
| from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN | from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN | ||||||
| from passbook.lib.config import CONFIG | from passbook.lib.config import CONFIG | ||||||
| from passbook.policies.dummy.models import DummyPolicy | from passbook.policies.dummy.models import DummyPolicy | ||||||
|  | from passbook.policies.http import AccessDeniedResponse | ||||||
| from passbook.policies.models import PolicyBinding | from passbook.policies.models import PolicyBinding | ||||||
| from passbook.policies.types import PolicyResult | from passbook.policies.types import PolicyResult | ||||||
| from passbook.stages.dummy.models import DummyStage | from passbook.stages.dummy.models import DummyStage | ||||||
| @ -20,6 +22,15 @@ POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) | |||||||
| POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) | POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def to_stage_response(request: HttpRequest, source: HttpResponse): | ||||||
|  |     """Mock for to_stage_response that returns the original response, so we can check | ||||||
|  |     inheritance and member attributes""" | ||||||
|  |     return source | ||||||
|  |  | ||||||
|  |  | ||||||
|  | TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response) | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestFlowExecutor(TestCase): | class TestFlowExecutor(TestCase): | ||||||
|     """Test views logic""" |     """Test views logic""" | ||||||
|  |  | ||||||
| @ -48,9 +59,12 @@ class TestFlowExecutor(TestCase): | |||||||
|                     "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug} |                     "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug} | ||||||
|                 ), |                 ), | ||||||
|             ) |             ) | ||||||
|             self.assertEqual(response.status_code, 400) |             self.assertEqual(response.status_code, 200) | ||||||
|             self.assertEqual(cancel_mock.call_count, 1) |             self.assertEqual(cancel_mock.call_count, 2) | ||||||
|  |  | ||||||
|  |     @patch( | ||||||
|  |         "passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK, | ||||||
|  |     ) | ||||||
|     @patch( |     @patch( | ||||||
|         "passbook.policies.engine.PolicyEngine.result", POLICY_RETURN_FALSE, |         "passbook.policies.engine.PolicyEngine.result", POLICY_RETURN_FALSE, | ||||||
|     ) |     ) | ||||||
| @ -66,9 +80,13 @@ class TestFlowExecutor(TestCase): | |||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), |             reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 400) |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertIsInstance(response, AccessDeniedResponse) | ||||||
|         self.assertInHTML(FlowNonApplicableException.__doc__, response.rendered_content) |         self.assertInHTML(FlowNonApplicableException.__doc__, response.rendered_content) | ||||||
|  |  | ||||||
|  |     @patch( | ||||||
|  |         "passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK, | ||||||
|  |     ) | ||||||
|     def test_invalid_empty_flow(self): |     def test_invalid_empty_flow(self): | ||||||
|         """Tests that an empty flow returns the correct error message""" |         """Tests that an empty flow returns the correct error message""" | ||||||
|         flow = Flow.objects.create( |         flow = Flow.objects.create( | ||||||
| @ -81,7 +99,8 @@ class TestFlowExecutor(TestCase): | |||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), |             reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 400) |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertIsInstance(response, AccessDeniedResponse) | ||||||
|         self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content) |         self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content) | ||||||
|  |  | ||||||
|     def test_invalid_flow_redirect(self): |     def test_invalid_flow_redirect(self): | ||||||
| @ -96,8 +115,10 @@ class TestFlowExecutor(TestCase): | |||||||
|         dest = "/unique-string" |         dest = "/unique-string" | ||||||
|         url = reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}) |         url = reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}) | ||||||
|         response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}") |         response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}") | ||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertEqual(response.url, dest) |         self.assertJSONEqual( | ||||||
|  |             force_str(response.content), {"type": "redirect", "to": dest}, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_multi_stage_flow(self): |     def test_multi_stage_flow(self): | ||||||
|         """Test a full flow with multiple stages""" |         """Test a full flow with multiple stages""" | ||||||
| @ -247,7 +268,7 @@ class TestFlowExecutor(TestCase): | |||||||
|         response = self.client.post(exec_url) |         response = self.client.post(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -293,7 +314,7 @@ class TestFlowExecutor(TestCase): | |||||||
|             # First request, run the planner |             # First request, run the planner | ||||||
|             response = self.client.get(exec_url) |             response = self.client.get(exec_url) | ||||||
|             self.assertEqual(response.status_code, 200) |             self.assertEqual(response.status_code, 200) | ||||||
|             self.assertIn("dummy1", force_text(response.content)) |             self.assertIn("dummy1", force_str(response.content)) | ||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
| @ -316,13 +337,13 @@ class TestFlowExecutor(TestCase): | |||||||
|         # but it won't save it, hence we cant' check the plan |         # but it won't save it, hence we cant' check the plan | ||||||
|         response = self.client.get(exec_url) |         response = self.client.get(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertIn("dummy4", force_text(response.content)) |         self.assertIn("dummy4", force_str(response.content)) | ||||||
|  |  | ||||||
|         # fourth request, this confirms the last stage (dummy4) |         # fourth request, this confirms the last stage (dummy4) | ||||||
|         # We do this request without the patch, so the policy results in false |         # We do this request without the patch, so the policy results in false | ||||||
|         response = self.client.post(exec_url) |         response = self.client.post(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -6,12 +6,10 @@ from passbook.flows.views import ( | |||||||
|     CancelView, |     CancelView, | ||||||
|     FlowExecutorShellView, |     FlowExecutorShellView, | ||||||
|     FlowExecutorView, |     FlowExecutorView, | ||||||
|     FlowPermissionDeniedView, |  | ||||||
|     ToDefaultFlow, |     ToDefaultFlow, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     path("-/denied/", FlowPermissionDeniedView.as_view(), name="denied"), |  | ||||||
|     path( |     path( | ||||||
|         "-/default/authentication/", |         "-/default/authentication/", | ||||||
|         ToDefaultFlow.as_view(designation=FlowDesignation.AUTHENTICATION), |         ToDefaultFlow.as_view(designation=FlowDesignation.AUTHENTICATION), | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ from django.http import ( | |||||||
|     HttpResponseRedirect, |     HttpResponseRedirect, | ||||||
|     JsonResponse, |     JsonResponse, | ||||||
| ) | ) | ||||||
| from django.shortcuts import get_object_or_404, redirect, render, reverse | from django.shortcuts import get_object_or_404, redirect, reverse | ||||||
| from django.template.response import TemplateResponse | from django.template.response import TemplateResponse | ||||||
| from django.utils.decorators import method_decorator | from django.utils.decorators import method_decorator | ||||||
| from django.views.decorators.clickjacking import xframe_options_sameorigin | from django.views.decorators.clickjacking import xframe_options_sameorigin | ||||||
| @ -17,13 +17,13 @@ from django.views.generic import TemplateView, View | |||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
| from passbook.audit.models import cleanse_dict | from passbook.audit.models import cleanse_dict | ||||||
| from passbook.core.views.utils import PermissionDeniedView | from passbook.core.models import PASSBOOK_USER_DEBUG | ||||||
| from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from passbook.flows.models import Flow, FlowDesignation, Stage | from passbook.flows.models import Flow, FlowDesignation, Stage | ||||||
| from passbook.flows.planner import FlowPlan, FlowPlanner | from passbook.flows.planner import FlowPlan, FlowPlanner | ||||||
| from passbook.lib.utils.reflection import class_to_path | from passbook.lib.utils.reflection import class_to_path | ||||||
| from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs | from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs | ||||||
| from passbook.lib.views import bad_request_message | from passbook.policies.http import AccessDeniedResponse | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| # Argument used to redirect user after login | # Argument used to redirect user after login | ||||||
| @ -54,7 +54,7 @@ class FlowExecutorView(View): | |||||||
|                 LOGGER.debug("f(exec): Redirecting to next on fail") |                 LOGGER.debug("f(exec): Redirecting to next on fail") | ||||||
|                 return redirect(self.request.GET.get(NEXT_ARG_NAME)) |                 return redirect(self.request.GET.get(NEXT_ARG_NAME)) | ||||||
|         message = exc.__doc__ if exc.__doc__ else str(exc) |         message = exc.__doc__ if exc.__doc__ else str(exc) | ||||||
|         return bad_request_message(self.request, message) |         return self.stage_invalid(error_message=message) | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: |     def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: | ||||||
|         # Early check if theres an active Plan for the current session |         # Early check if theres an active Plan for the current session | ||||||
| @ -79,10 +79,10 @@ class FlowExecutorView(View): | |||||||
|                 self.plan = self._initiate_plan() |                 self.plan = self._initiate_plan() | ||||||
|             except FlowNonApplicableException as exc: |             except FlowNonApplicableException as exc: | ||||||
|                 LOGGER.warning("f(exec): Flow not applicable to current user", exc=exc) |                 LOGGER.warning("f(exec): Flow not applicable to current user", exc=exc) | ||||||
|                 return self.handle_invalid_flow(exc) |                 return to_stage_response(self.request, self.handle_invalid_flow(exc)) | ||||||
|             except EmptyFlowException as exc: |             except EmptyFlowException as exc: | ||||||
|                 LOGGER.warning("f(exec): Flow is empty", exc=exc) |                 LOGGER.warning("f(exec): Flow is empty", exc=exc) | ||||||
|                 return self.handle_invalid_flow(exc) |                 return to_stage_response(self.request, self.handle_invalid_flow(exc)) | ||||||
|         # We don't save the Plan after getting the next stage |         # We don't save the Plan after getting the next stage | ||||||
|         # as it hasn't been successfully passed yet |         # as it hasn't been successfully passed yet | ||||||
|         next_stage = self.plan.next() |         next_stage = self.plan.next() | ||||||
| @ -115,14 +115,7 @@ class FlowExecutorView(View): | |||||||
|             return to_stage_response(request, stage_response) |             return to_stage_response(request, stage_response) | ||||||
|         except Exception as exc:  # pylint: disable=broad-except |         except Exception as exc:  # pylint: disable=broad-except | ||||||
|             LOGGER.exception(exc) |             LOGGER.exception(exc) | ||||||
|             return to_stage_response( |             return to_stage_response(request, FlowErrorResponse(request, exc)) | ||||||
|                 request, |  | ||||||
|                 render( |  | ||||||
|                     request, |  | ||||||
|                     "flows/error.html", |  | ||||||
|                     {"error": exc, "tb": "".join(format_tb(exc.__traceback__))}, |  | ||||||
|                 ), |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|         """pass post request to current stage""" |         """pass post request to current stage""" | ||||||
| @ -137,14 +130,7 @@ class FlowExecutorView(View): | |||||||
|             return to_stage_response(request, stage_response) |             return to_stage_response(request, stage_response) | ||||||
|         except Exception as exc:  # pylint: disable=broad-except |         except Exception as exc:  # pylint: disable=broad-except | ||||||
|             LOGGER.exception(exc) |             LOGGER.exception(exc) | ||||||
|             return to_stage_response( |             return to_stage_response(request, FlowErrorResponse(request, exc)) | ||||||
|                 request, |  | ||||||
|                 render( |  | ||||||
|                     request, |  | ||||||
|                     "flows/error.html", |  | ||||||
|                     {"error": exc, "tb": "".join(format_tb(exc.__traceback__))}, |  | ||||||
|                 ), |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def _initiate_plan(self) -> FlowPlan: |     def _initiate_plan(self) -> FlowPlan: | ||||||
|         planner = FlowPlanner(self.flow) |         planner = FlowPlanner(self.flow) | ||||||
| @ -193,12 +179,17 @@ class FlowExecutorView(View): | |||||||
|         ) |         ) | ||||||
|         return self._flow_done() |         return self._flow_done() | ||||||
|  |  | ||||||
|     def stage_invalid(self) -> HttpResponse: |     def stage_invalid(self, error_message: Optional[str] = None) -> HttpResponse: | ||||||
|         """Callback used stage when data is correct but a policy denies access |         """Callback used stage when data is correct but a policy denies access | ||||||
|         or the user account is disabled.""" |         or the user account is disabled. | ||||||
|  |  | ||||||
|  |         Optionally, an exception can be passed, which will be shown if the current user | ||||||
|  |         is a superuser.""" | ||||||
|         LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug) |         LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug) | ||||||
|         self.cancel() |         self.cancel() | ||||||
|         return redirect_with_qs("passbook_flows:denied", self.request.GET) |         response = AccessDeniedResponse(self.request) | ||||||
|  |         response.error_message = error_message | ||||||
|  |         return response | ||||||
|  |  | ||||||
|     def cancel(self): |     def cancel(self): | ||||||
|         """Cancel current execution and return a redirect""" |         """Cancel current execution and return a redirect""" | ||||||
| @ -212,8 +203,30 @@ class FlowExecutorView(View): | |||||||
|                 del self.request.session[key] |                 del self.request.session[key] | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowPermissionDeniedView(PermissionDeniedView): | class FlowErrorResponse(TemplateResponse): | ||||||
|     """User could not be authenticated""" |     """Response class when an unhandled error occurs during a stage. Normal users | ||||||
|  |     are shown an error message, superusers are shown a full stacktrace.""" | ||||||
|  |  | ||||||
|  |     error: Exception | ||||||
|  |  | ||||||
|  |     def __init__(self, request: HttpRequest, error: Exception) -> None: | ||||||
|  |         # For some reason pyright complains about keyword argument usage here | ||||||
|  |         # pyright: reportGeneralTypeIssues=false | ||||||
|  |         super().__init__(request=request, template="flows/error.html") | ||||||
|  |         self.error = error | ||||||
|  |  | ||||||
|  |     def resolve_context( | ||||||
|  |         self, context: Optional[Dict[str, Any]] | ||||||
|  |     ) -> Optional[Dict[str, Any]]: | ||||||
|  |         if not context: | ||||||
|  |             context = {} | ||||||
|  |         context["error"] = self.error | ||||||
|  |         if self._request.user and self._request.user.is_authenticated: | ||||||
|  |             if self._request.user.is_superuser or self._request.user.attributes.get( | ||||||
|  |                 PASSBOOK_USER_DEBUG, False | ||||||
|  |             ): | ||||||
|  |                 context["tb"] = "".join(format_tb(self.error.__traceback__)) | ||||||
|  |         return context | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowExecutorShellView(TemplateView): | class FlowExecutorShellView(TemplateView): | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ redis: | |||||||
|   password: '' |   password: '' | ||||||
|   cache_db: 0 |   cache_db: 0 | ||||||
|   message_queue_db: 1 |   message_queue_db: 1 | ||||||
|  |   ws_db: 2 | ||||||
|  |  | ||||||
| debug: false | debug: false | ||||||
| log_level: info | log_level: info | ||||||
| @ -21,6 +22,10 @@ error_reporting: | |||||||
|   send_pii: false |   send_pii: false | ||||||
|  |  | ||||||
| passbook: | passbook: | ||||||
|  |   branding: | ||||||
|  |     title: passbook | ||||||
|  |     title_show: true | ||||||
|  |     logo: /static/passbook/logo.svg | ||||||
|   # Optionally add links to the footer on the login page |   # Optionally add links to the footer on the login page | ||||||
|   footer_links: |   footer_links: | ||||||
|     - name: Documentation |     - name: Documentation | ||||||
|  | |||||||
| @ -1,5 +1,9 @@ | |||||||
| """Generic models""" | """Generic models""" | ||||||
|  | import re | ||||||
|  |  | ||||||
|  | from django.core.validators import URLValidator | ||||||
| from django.db import models | from django.db import models | ||||||
|  | from django.utils.regex_helper import _lazy_re_compile | ||||||
| from model_utils.managers import InheritanceManager | from model_utils.managers import InheritanceManager | ||||||
| from rest_framework.serializers import BaseSerializer | from rest_framework.serializers import BaseSerializer | ||||||
|  |  | ||||||
| @ -48,3 +52,21 @@ class InheritanceForeignKey(models.ForeignKey): | |||||||
|     """Custom ForeignKey that uses InheritanceForwardManyToOneDescriptor""" |     """Custom ForeignKey that uses InheritanceForwardManyToOneDescriptor""" | ||||||
|  |  | ||||||
|     forward_related_accessor_class = InheritanceForwardManyToOneDescriptor |     forward_related_accessor_class = InheritanceForwardManyToOneDescriptor | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DomainlessURLValidator(URLValidator): | ||||||
|  |     """Subclass of URLValidator which doesn't check the domain | ||||||
|  |     (to allow hostnames without domain)""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs) -> None: | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         self.host_re = "(" + self.hostname_re + self.domain_re + "|localhost)" | ||||||
|  |         self.regex = _lazy_re_compile( | ||||||
|  |             r"^(?:[a-z0-9.+-]*)://"  # scheme is validated separately | ||||||
|  |             r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?"  # user:pass authentication | ||||||
|  |             r"(?:" + self.ipv4_re + "|" + self.ipv6_re + "|" + self.host_re + ")" | ||||||
|  |             r"(?::\d{2,5})?"  # port | ||||||
|  |             r"(?:[/?#][^\s]*)?"  # resource path | ||||||
|  |             r"\Z", | ||||||
|  |             re.IGNORECASE, | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| """passbook sentry integration""" | """passbook sentry integration""" | ||||||
| from billiard.exceptions import WorkerLostError | from billiard.exceptions import WorkerLostError | ||||||
| from botocore.client import ClientError | from botocore.client import ClientError | ||||||
|  | from celery.exceptions import CeleryError | ||||||
| from django.core.exceptions import DisallowedHost, ValidationError | from django.core.exceptions import DisallowedHost, ValidationError | ||||||
| from django.db import InternalError, OperationalError, ProgrammingError | from django.db import InternalError, OperationalError, ProgrammingError | ||||||
| from django_redis.exceptions import ConnectionInterrupted | from django_redis.exceptions import ConnectionInterrupted | ||||||
| @ -8,6 +9,7 @@ from redis.exceptions import ConnectionError as RedisConnectionError | |||||||
| from redis.exceptions import RedisError | from redis.exceptions import RedisError | ||||||
| from rest_framework.exceptions import APIException | from rest_framework.exceptions import APIException | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  | from websockets.exceptions import WebSocketException | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -35,6 +37,8 @@ def before_send(event, hint): | |||||||
|         OSError, |         OSError, | ||||||
|         RedisError, |         RedisError, | ||||||
|         SentryIgnoredException, |         SentryIgnoredException, | ||||||
|  |         WebSocketException, | ||||||
|  |         CeleryError, | ||||||
|     ) |     ) | ||||||
|     if "exc_info" in hint: |     if "exc_info" in hint: | ||||||
|         _, exc_value, _ = hint["exc_info"] |         _, exc_value, _ = hint["exc_info"] | ||||||
|  | |||||||
| @ -1,11 +1,5 @@ | |||||||
| """passbook lib template utilities""" | """passbook lib template utilities""" | ||||||
| from django.template import Context, Template, loader | from django.template import Context, loader | ||||||
|  |  | ||||||
|  |  | ||||||
| def render_from_string(tmpl: str, ctx: Context) -> str: |  | ||||||
|     """Render template from string to string""" |  | ||||||
|     template = Template(tmpl) |  | ||||||
|     return template.render(ctx) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def render_to_string(template_path: str, ctx: Context) -> str: | def render_to_string(template_path: str, ctx: Context) -> str: | ||||||
|  | |||||||
| @ -27,12 +27,12 @@ class CreateAssignPermView(CreateView): | |||||||
|  |  | ||||||
|  |  | ||||||
| def bad_request_message( | def bad_request_message( | ||||||
|     request: HttpRequest, message: str, title="Bad Request" |     request: HttpRequest, | ||||||
|  |     message: str, | ||||||
|  |     title="Bad Request", | ||||||
|  |     template="error/generic.html", | ||||||
| ) -> TemplateResponse: | ) -> TemplateResponse: | ||||||
|     """Return generic error page with message, with status code set to 400""" |     """Return generic error page with message, with status code set to 400""" | ||||||
|     return TemplateResponse( |     return TemplateResponse( | ||||||
|         request, |         request, template, {"message": message, "card_title": _(title)}, status=400, | ||||||
|         "error/generic.html", |  | ||||||
|         {"message": message, "card_title": _(title)}, |  | ||||||
|         status=400, |  | ||||||
|     ) |     ) | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| """Kubernetes deployment controller""" | """Kubernetes deployment controller""" | ||||||
|  | from base64 import b64encode | ||||||
| from io import StringIO | from io import StringIO | ||||||
|  |  | ||||||
| from kubernetes.client import ( | from kubernetes.client import ( | ||||||
| @ -24,6 +25,11 @@ from passbook import __version__ | |||||||
| from passbook.outposts.controllers.base import BaseController | from passbook.outposts.controllers.base import BaseController | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def b64encode_str(input_string: str) -> str: | ||||||
|  |     """base64 encode string""" | ||||||
|  |     return b64encode(input_string.encode()).decode() | ||||||
|  |  | ||||||
|  |  | ||||||
| class KubernetesController(BaseController): | class KubernetesController(BaseController): | ||||||
|     """Manage deployment of outpost in kubernetes""" |     """Manage deployment of outpost in kubernetes""" | ||||||
|  |  | ||||||
| @ -37,9 +43,9 @@ class KubernetesController(BaseController): | |||||||
|         with StringIO() as _str: |         with StringIO() as _str: | ||||||
|             dump_all( |             dump_all( | ||||||
|                 [ |                 [ | ||||||
|                     self.get_deployment_secret(), |                     self.get_deployment_secret().to_dict(), | ||||||
|                     self.get_deployment(), |                     self.get_deployment().to_dict(), | ||||||
|                     self.get_service(), |                     self.get_service().to_dict(), | ||||||
|                 ], |                 ], | ||||||
|                 stream=_str, |                 stream=_str, | ||||||
|                 default_flow_style=False, |                 default_flow_style=False, | ||||||
| @ -63,15 +69,18 @@ class KubernetesController(BaseController): | |||||||
|     def get_deployment_secret(self) -> V1Secret: |     def get_deployment_secret(self) -> V1Secret: | ||||||
|         """Get secret with token and passbook host""" |         """Get secret with token and passbook host""" | ||||||
|         return V1Secret( |         return V1Secret( | ||||||
|  |             api_version="v1", | ||||||
|  |             kind="secret", | ||||||
|  |             type="Opaque", | ||||||
|             metadata=self.get_object_meta( |             metadata=self.get_object_meta( | ||||||
|                 name=f"passbook-outpost-{self.outpost.name}-api" |                 name=f"passbook-outpost-{self.outpost.name}-api" | ||||||
|             ), |             ), | ||||||
|             data={ |             data={ | ||||||
|                 "passbook_host": self.outpost.config.passbook_host, |                 "passbook_host": b64encode_str(self.outpost.config.passbook_host), | ||||||
|                 "passbook_host_insecure": str( |                 "passbook_host_insecure": b64encode_str( | ||||||
|                     self.outpost.config.passbook_host_insecure |                     str(self.outpost.config.passbook_host_insecure) | ||||||
|                 ), |                 ), | ||||||
|                 "token": self.outpost.token.token_uuid.hex, |                 "token": b64encode_str(self.outpost.token.token_uuid.hex), | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -82,6 +91,8 @@ class KubernetesController(BaseController): | |||||||
|         for port_name, port in self.deployment_ports.items(): |         for port_name, port in self.deployment_ports.items(): | ||||||
|             ports.append(V1ServicePort(name=port_name, port=port)) |             ports.append(V1ServicePort(name=port_name, port=port)) | ||||||
|         return V1Service( |         return V1Service( | ||||||
|  |             api_version="v1", | ||||||
|  |             kind="service", | ||||||
|             metadata=meta, |             metadata=meta, | ||||||
|             spec=V1ServiceSpec(ports=ports, selector=meta.labels, type="ClusterIP"), |             spec=V1ServiceSpec(ports=ports, selector=meta.labels, type="ClusterIP"), | ||||||
|         ) |         ) | ||||||
| @ -94,6 +105,8 @@ class KubernetesController(BaseController): | |||||||
|             container_ports.append(V1ContainerPort(container_port=port, name=port_name)) |             container_ports.append(V1ContainerPort(container_port=port, name=port_name)) | ||||||
|         meta = self.get_object_meta(name=f"passbook-outpost-{self.outpost.name}") |         meta = self.get_object_meta(name=f"passbook-outpost-{self.outpost.name}") | ||||||
|         return V1Deployment( |         return V1Deployment( | ||||||
|  |             api_version="apps/v1", | ||||||
|  |             kind="deployment", | ||||||
|             metadata=meta, |             metadata=meta, | ||||||
|             spec=V1DeploymentSpec( |             spec=V1DeploymentSpec( | ||||||
|                 replicas=1, |                 replicas=1, | ||||||
|  | |||||||
| @ -1,15 +1,16 @@ | |||||||
| """Outpost models""" | """Outpost models""" | ||||||
| from dataclasses import asdict, dataclass | from dataclasses import asdict, dataclass | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from json import dumps, loads |  | ||||||
| from typing import Iterable, Optional | from typing import Iterable, Optional | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from dacite import from_dict | from dacite import from_dict | ||||||
| from django.contrib.postgres.fields import ArrayField | from django.contrib.postgres.fields import ArrayField | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db import models | from django.db import models, transaction | ||||||
|  | from django.db.models.base import Model | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
|  | from guardian.models import UserObjectPermission | ||||||
| from guardian.shortcuts import assign_perm | from guardian.shortcuts import assign_perm | ||||||
|  |  | ||||||
| from passbook.core.models import Provider, Token, TokenIntents, User | from passbook.core.models import Provider, Token, TokenIntents, User | ||||||
| @ -30,13 +31,17 @@ class OutpostConfig: | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class OutpostModel: | class OutpostModel(Model): | ||||||
|     """Base model for providers that need more objects than just themselves""" |     """Base model for providers that need more objects than just themselves""" | ||||||
|  |  | ||||||
|     def get_required_objects(self) -> Iterable[models.Model]: |     def get_required_objects(self) -> Iterable[models.Model]: | ||||||
|         """Return a list of all required objects""" |         """Return a list of all required objects""" | ||||||
|         return [self] |         return [self] | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |  | ||||||
|  |         abstract = True | ||||||
|  |  | ||||||
|  |  | ||||||
| class OutpostType(models.TextChoices): | class OutpostType(models.TextChoices): | ||||||
|     """Outpost types, currently only the reverse proxy is available""" |     """Outpost types, currently only the reverse proxy is available""" | ||||||
| @ -79,12 +84,12 @@ class Outpost(models.Model): | |||||||
|     @property |     @property | ||||||
|     def config(self) -> OutpostConfig: |     def config(self) -> OutpostConfig: | ||||||
|         """Load config as OutpostConfig object""" |         """Load config as OutpostConfig object""" | ||||||
|         return from_dict(OutpostConfig, loads(self._config)) |         return from_dict(OutpostConfig, self._config) | ||||||
|  |  | ||||||
|     @config.setter |     @config.setter | ||||||
|     def config(self, value): |     def config(self, value): | ||||||
|         """Dump config into json""" |         """Dump config into json""" | ||||||
|         self._config = dumps(asdict(value)) |         self._config = asdict(value) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def health_cache_key(self) -> str: |     def health_cache_key(self) -> str: | ||||||
| @ -100,24 +105,24 @@ class Outpost(models.Model): | |||||||
|             return datetime.fromtimestamp(value) |             return datetime.fromtimestamp(value) | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     def _create_user(self) -> User: |  | ||||||
|         """Create user and assign permissions for all required objects""" |  | ||||||
|         user: User = User.objects.create(username=f"pb-outpost-{self.uuid.hex}") |  | ||||||
|         user.set_unusable_password() |  | ||||||
|         user.save() |  | ||||||
|         for model in self.get_required_objects(): |  | ||||||
|             assign_perm( |  | ||||||
|                 f"{model._meta.app_label}.view_{model._meta.model_name}", user, model |  | ||||||
|             ) |  | ||||||
|         return user |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def user(self) -> User: |     def user(self) -> User: | ||||||
|         """Get/create user with access to all required objects""" |         """Get/create user with access to all required objects""" | ||||||
|         user = User.objects.filter(username=f"pb-outpost-{self.uuid.hex}") |         users = User.objects.filter(username=f"pb-outpost-{self.uuid.hex}") | ||||||
|         if user.exists(): |         if not users.exists(): | ||||||
|             return user.first() |             user: User = User.objects.create(username=f"pb-outpost-{self.uuid.hex}") | ||||||
|         return self._create_user() |             user.set_unusable_password() | ||||||
|  |             user.save() | ||||||
|  |         else: | ||||||
|  |             user = users.first() | ||||||
|  |         # To ensure the user only has the correct permissions, we delete all of them and re-add | ||||||
|  |         # the ones the user needs | ||||||
|  |         with transaction.atomic(): | ||||||
|  |             UserObjectPermission.objects.filter(user=user).delete() | ||||||
|  |             for model in self.get_required_objects(): | ||||||
|  |                 code_name = f"{model._meta.app_label}.view_{model._meta.model_name}" | ||||||
|  |                 assign_perm(code_name, user, model) | ||||||
|  |         return user | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def token(self) -> Token: |     def token(self) -> Token: | ||||||
|  | |||||||
| @ -1,10 +1,10 @@ | |||||||
| """Outposts Settings""" | """Outposts Settings""" | ||||||
| from celery.schedules import crontab | # from celery.schedules import crontab | ||||||
|  |  | ||||||
| CELERY_BEAT_SCHEDULE = { | # CELERY_BEAT_SCHEDULE = { | ||||||
|     "outposts_k8s": { | #     "outposts_k8s": { | ||||||
|         "task": "passbook.outposts.tasks.outpost_k8s_controller", | #         "task": "passbook.outposts.tasks.outpost_k8s_controller", | ||||||
|         "schedule": crontab(minute="*/5"),  # Run every 5 minutes | #         "schedule": crontab(minute="*/5"),  # Run every 5 minutes | ||||||
|         "options": {"queue": "passbook_scheduled"}, | #         "options": {"queue": "passbook_scheduled"}, | ||||||
|     } | #     } | ||||||
| } | # } | ||||||
|  | |||||||
| @ -1,31 +1,31 @@ | |||||||
| """passbook outpost signals""" | """passbook outpost signals""" | ||||||
| from asgiref.sync import async_to_sync |  | ||||||
| from channels.layers import get_channel_layer |  | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.db.models.signals import post_save | from django.db.models.signals import post_save | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
|  | from passbook.lib.utils.reflection import class_to_path | ||||||
| from passbook.outposts.models import Outpost, OutpostModel | from passbook.outposts.models import Outpost, OutpostModel | ||||||
|  | from passbook.outposts.tasks import outpost_send_update | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save, sender=Outpost) | @receiver(post_save, sender=Outpost) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def ensure_user_and_token(sender, instance, **_): | def ensure_user_and_token(sender, instance: Model, **_): | ||||||
|     """Ensure that token is created/updated on save""" |     """Ensure that token is created/updated on save""" | ||||||
|     _ = instance.token |     _ = instance.token | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def post_save_update(sender, instance, **_): | def post_save_update(sender, instance: Model, **_): | ||||||
|     """If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved, |     """If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved, | ||||||
|     we send a message down the relevant OutpostModels WS connection to trigger an update""" |     we send a message down the relevant OutpostModels WS connection to trigger an update""" | ||||||
|     if isinstance(instance, OutpostModel): |     if isinstance(instance, OutpostModel): | ||||||
|         LOGGER.debug("triggering outpost update from outpostmodel", instance=instance) |         LOGGER.debug("triggering outpost update from outpostmodel", instance=instance) | ||||||
|         _send_update(instance) |         outpost_send_update.delay(class_to_path(instance.__class__), instance.pk) | ||||||
|         return |         return | ||||||
|  |  | ||||||
|     for field in instance._meta.get_fields(): |     for field in instance._meta.get_fields(): | ||||||
| @ -46,13 +46,4 @@ def post_save_update(sender, instance, **_): | |||||||
|         # Because the Outpost Model has an M2M to Provider, |         # Because the Outpost Model has an M2M to Provider, | ||||||
|         # we have to iterate over the entire QS |         # we have to iterate over the entire QS | ||||||
|         for reverse in getattr(instance, field_name).all(): |         for reverse in getattr(instance, field_name).all(): | ||||||
|             _send_update(reverse) |             outpost_send_update(class_to_path(reverse.__class__), reverse.pk) | ||||||
|  |  | ||||||
|  |  | ||||||
| def _send_update(outpost_model: Model): |  | ||||||
|     """Send update trigger for each channel of an outpost model""" |  | ||||||
|     for outpost in outpost_model.outpost_set.all(): |  | ||||||
|         channel_layer = get_channel_layer() |  | ||||||
|         for channel in outpost.channels: |  | ||||||
|             LOGGER.debug("sending update", channel=channel) |  | ||||||
|             async_to_sync(channel_layer.send)(channel, {"type": "event.update"}) |  | ||||||
|  | |||||||
| @ -1,8 +1,22 @@ | |||||||
| """outpost tasks""" | """outpost tasks""" | ||||||
| from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType | from typing import Any | ||||||
|  |  | ||||||
|  | from asgiref.sync import async_to_sync | ||||||
|  | from channels.layers import get_channel_layer | ||||||
|  | from structlog import get_logger | ||||||
|  |  | ||||||
|  | from passbook.lib.utils.reflection import path_to_class | ||||||
|  | from passbook.outposts.models import ( | ||||||
|  |     Outpost, | ||||||
|  |     OutpostDeploymentType, | ||||||
|  |     OutpostModel, | ||||||
|  |     OutpostType, | ||||||
|  | ) | ||||||
| from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController | from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController | ||||||
| from passbook.root.celery import CELERY_APP | from passbook.root.celery import CELERY_APP | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True) | @CELERY_APP.task(bind=True) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| @ -20,3 +34,16 @@ def outpost_k8s_controller_single(self, outpost: str, outpost_type: str): | |||||||
|     """Launch Kubernetes manager and reconcile deployment/service/etc""" |     """Launch Kubernetes manager and reconcile deployment/service/etc""" | ||||||
|     if outpost_type == OutpostType.PROXY: |     if outpost_type == OutpostType.PROXY: | ||||||
|         ProxyKubernetesController(outpost).run() |         ProxyKubernetesController(outpost).run() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @CELERY_APP.task() | ||||||
|  | def outpost_send_update(model_class: str, model_pk: Any): | ||||||
|  |     """Send outpost update to all registered outposts, irregardless to which passbook | ||||||
|  |     instance they are connected""" | ||||||
|  |     model = path_to_class(model_class) | ||||||
|  |     outpost_model: OutpostModel = model.objects.get(pk=model_pk) | ||||||
|  |     for outpost in outpost_model.outpost_set.all(): | ||||||
|  |         channel_layer = get_channel_layer() | ||||||
|  |         for channel in outpost.channels: | ||||||
|  |             LOGGER.debug("sending update", channel=channel) | ||||||
|  |             async_to_sync(channel_layer.send)(channel, {"type": "event.update"}) | ||||||
|  | |||||||
							
								
								
									
										60
									
								
								passbook/outposts/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								passbook/outposts/tests.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,60 @@ | |||||||
|  | """outpost tests""" | ||||||
|  | from django.test import TestCase | ||||||
|  | from guardian.models import UserObjectPermission | ||||||
|  |  | ||||||
|  | from passbook.crypto.models import CertificateKeyPair | ||||||
|  | from passbook.flows.models import Flow | ||||||
|  | from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType | ||||||
|  | from passbook.providers.proxy.models import ProxyProvider | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OutpostTests(TestCase): | ||||||
|  |     """Outpost Tests""" | ||||||
|  |  | ||||||
|  |     def test_service_account_permissions(self): | ||||||
|  |         """Test that the service account has correct permissions""" | ||||||
|  |         provider: ProxyProvider = ProxyProvider.objects.create( | ||||||
|  |             name="test", | ||||||
|  |             internal_host="http://localhost", | ||||||
|  |             external_host="http://localhost", | ||||||
|  |             authorization_flow=Flow.objects.first(), | ||||||
|  |         ) | ||||||
|  |         outpost: Outpost = Outpost.objects.create( | ||||||
|  |             name="test", | ||||||
|  |             type=OutpostType.PROXY, | ||||||
|  |             deployment_type=OutpostDeploymentType.CUSTOM, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Before we add a provider, the user should only have access to the outpost | ||||||
|  |         permissions = UserObjectPermission.objects.filter(user=outpost.user) | ||||||
|  |         self.assertEqual(len(permissions), 1) | ||||||
|  |         self.assertEqual(permissions[0].object_pk, str(outpost.pk)) | ||||||
|  |  | ||||||
|  |         # We add a provider, user should only have access to outpost and provider | ||||||
|  |         outpost.providers.add(provider) | ||||||
|  |         outpost.save() | ||||||
|  |         permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by( | ||||||
|  |             "content_type__model" | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(len(permissions), 2) | ||||||
|  |         self.assertEqual(permissions[0].object_pk, str(outpost.pk)) | ||||||
|  |         self.assertEqual(permissions[1].object_pk, str(provider.pk)) | ||||||
|  |  | ||||||
|  |         # Provider requires a certificate-key-pair, user should have permissions for it | ||||||
|  |         keypair = CertificateKeyPair.objects.first() | ||||||
|  |         provider.certificate = keypair | ||||||
|  |         provider.save() | ||||||
|  |         permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by( | ||||||
|  |             "content_type__model" | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(len(permissions), 3) | ||||||
|  |         self.assertEqual(permissions[0].object_pk, str(keypair.pk)) | ||||||
|  |         self.assertEqual(permissions[1].object_pk, str(outpost.pk)) | ||||||
|  |         self.assertEqual(permissions[2].object_pk, str(provider.pk)) | ||||||
|  |  | ||||||
|  |         # Remove provider from outpost, user should only have access to outpost | ||||||
|  |         outpost.providers.remove(provider) | ||||||
|  |         outpost.save() | ||||||
|  |         permissions = UserObjectPermission.objects.filter(user=outpost.user) | ||||||
|  |         self.assertEqual(len(permissions), 1) | ||||||
|  |         self.assertEqual(permissions[0].object_pk, str(outpost.pk)) | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	