Compare commits
	
		
			147 Commits
		
	
	
		
			version/0.
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| cf76652a4c | |||
| c525ecc334 | |||
| 49d40d4337 | |||
| 94182f88a4 | |||
| 1c25f4f09b | |||
| 6495d6c50a | |||
| b81f3e4a38 | |||
| aad3b43ac3 | |||
| 60f52f102a | |||
| f3ccb5341d | |||
| cb73210447 | |||
| 81efc9a673 | |||
| 72c6c0da9b | |||
| 8fef839965 | |||
| 87b830ff9a | |||
| 8acb9dde5f | |||
| 36e8b1004c | |||
| f959212692 | |||
| 2d2a404028 | |||
| 394ad6ade5 | |||
| 4baf9e4a22 | |||
| d020599e09 | |||
| 4f28a89e63 | |||
| f8b4b92e8d | |||
| 33f208657c | |||
| c1fbfc63ab | |||
| 192dbe05c4 | |||
| 0b41cb84f0 | |||
| d637bd0bf9 | |||
| a2bddc6d91 | |||
| 2e42da11ea | |||
| f297d1256d | |||
| 5e1e5afb24 | |||
| da59e7c4a7 | |||
| 8684d106d5 | |||
| 2579e168c3 | |||
| 7f5caf901d | |||
| 1c686e19b5 | |||
| 3cc92f6c97 | |||
| 8f5b33a3a2 | |||
| 4447345345 | |||
| 42c6401ba7 | |||
| eef111bcfd | |||
| 6192b2787f | |||
| c7d28f8ca9 | |||
| 1342266368 | |||
| 7ff679b1a3 | |||
| 8beddcddb0 | |||
| 9fe8554f28 | |||
| 812fe72e60 | |||
| d0e4533cdd | |||
| b1b5d94ddc | |||
| 59722e0bbe | |||
| 9c5bb3998c | |||
| c180c4b1a2 | |||
| 308896719d | |||
| 95c1473dd2 | |||
| b14c5039ed | |||
| b6948334f2 | |||
| 29e08e7477 | |||
| 36bc1dc020 | |||
| 61d1407804 | |||
| 47ddf0d7f2 | |||
| cb36a3c8c7 | |||
| cac94792fa | |||
| 6f56c37d2f | |||
| 8369fa16ae | |||
| f30bdbecd6 | |||
| c727c845df | |||
| b2b737e59e | |||
| e2b930afe3 | |||
| 36c0b924bc | |||
| 1ccf6dcf6f | |||
| f8a426f0e8 | |||
| f8756d0fc9 | |||
| fd6d99f4f9 | |||
| 04379f2c90 | |||
| ba1195cf70 | |||
| b0bd9212c7 | |||
| 209179e012 | |||
| df16f635fa | |||
| 14ccf47a2b | |||
| 2aac024477 | |||
| 4743e72e18 | |||
| cab2942c4e | |||
| 9fb5ce2a1a | |||
| 0eab4489c5 | |||
| 3aae030b23 | |||
| e7060cb90a | |||
| 6c0b9e3525 | |||
| 82bb179bc2 | |||
| 774eb0388b | |||
| 6ed78830a0 | |||
| 6fe323f1a7 | |||
| 85c2db018e | |||
| bc9e7e8b93 | |||
| 08c58ce3fb | |||
| c3bc986473 | |||
| 2e69efe699 | |||
| 4daa373dcf | |||
| a85b8a65c0 | |||
| d8dc1f8bb5 | |||
| 0f4d5bc3b0 | |||
| 6eed549577 | |||
| be54ba4fe2 | |||
| 68b9c34f78 | |||
| 3584bdf530 | |||
| e712719333 | |||
| 9a21c2f6bd | |||
| 0632d8ff37 | |||
| 6bfaf71c12 | |||
| b6c8c319e5 | |||
| 4fde1b7365 | |||
| 412f5b9210 | |||
| a9e53cd52a | |||
| d0ee7908ab | |||
| e69834dec4 | |||
| 1b9d22615c | |||
| e995536a15 | |||
| e6818faab1 | |||
| 010e834149 | |||
| 16d5e1d9ff | |||
| 765ae80698 | |||
| bbd0ff24d8 | |||
| 7a403613b2 | |||
| 4ad184a3fb | |||
| 48d5f28e7a | |||
| 0cb48121b2 | |||
| 4194ffe2d4 | |||
| 4636fe7e64 | |||
| 182d714b16 | |||
| 540c22ce15 | |||
| 8c3008abce | |||
| 8a22c86aaa | |||
| 22ce142cb8 | |||
| 1a292feebb | |||
| 09f4d812b3 | |||
| 2bab4ebfe8 | |||
| a8647caca9 | |||
| 590597caf6 | |||
| 7b43777b22 | |||
| 77861b52e3 | |||
| 5f9c1e229c | |||
| 119adb3e7b | |||
| 5db38bd0b7 | |||
| 0e1587bc1a | |||
| dc16a8a4c9 | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 0.13.5-stable | ||||
| current_version = 2021.1.1-rc2 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) | ||||
| @ -31,6 +31,6 @@ values = | ||||
|  | ||||
| [bumpversion:file:authentik/__init__.py] | ||||
|  | ||||
| [bumpversion:file:proxy/pkg/version.go] | ||||
| [bumpversion:file:outpost/pkg/version.go] | ||||
|  | ||||
| [bumpversion:file:web/src/constants.ts] | ||||
|  | ||||
							
								
								
									
										20
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @ -18,11 +18,11 @@ jobs: | ||||
|       - name: Building Docker Image | ||||
|         run: docker build | ||||
|           --no-cache | ||||
|           -t beryju/authentik:0.13.5-stable | ||||
|           -t beryju/authentik:2021.1.1-rc2 | ||||
|           -t beryju/authentik:latest | ||||
|           -f Dockerfile . | ||||
|       - name: Push Docker Container to Registry (versioned) | ||||
|         run: docker push beryju/authentik:0.13.5-stable | ||||
|         run: docker push beryju/authentik:2021.1.1-rc2 | ||||
|       - name: Push Docker Container to Registry (latest) | ||||
|         run: docker push beryju/authentik:latest | ||||
|   build-proxy: | ||||
| @ -34,7 +34,7 @@ jobs: | ||||
|           go-version: "^1.15" | ||||
|       - name: prepare go api client | ||||
|         run: | | ||||
|           cd proxy | ||||
|           cd outpost | ||||
|           go get -u github.com/go-swagger/go-swagger/cmd/swagger | ||||
|           swagger generate client -f ../swagger.yaml -A authentik -t pkg/ | ||||
|           go build -v . | ||||
| @ -45,14 +45,14 @@ jobs: | ||||
|         run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD | ||||
|       - name: Building Docker Image | ||||
|         run: | | ||||
|           cd proxy/ | ||||
|           cd outpost/ | ||||
|           docker build \ | ||||
|           --no-cache \ | ||||
|           -t beryju/authentik-proxy:0.13.5-stable \ | ||||
|           -t beryju/authentik-proxy:2021.1.1-rc2 \ | ||||
|           -t beryju/authentik-proxy:latest \ | ||||
|           -f Dockerfile . | ||||
|           -f proxy.Dockerfile . | ||||
|       - name: Push Docker Container to Registry (versioned) | ||||
|         run: docker push beryju/authentik-proxy:0.13.5-stable | ||||
|         run: docker push beryju/authentik-proxy:2021.1.1-rc2 | ||||
|       - name: Push Docker Container to Registry (latest) | ||||
|         run: docker push beryju/authentik-proxy:latest | ||||
|   build-static: | ||||
| @ -69,11 +69,11 @@ jobs: | ||||
|           cd web/ | ||||
|           docker build \ | ||||
|           --no-cache \ | ||||
|           -t beryju/authentik-static:0.13.5-stable \ | ||||
|           -t beryju/authentik-static:2021.1.1-rc2 \ | ||||
|           -t beryju/authentik-static:latest \ | ||||
|           -f Dockerfile . | ||||
|       - name: Push Docker Container to Registry (versioned) | ||||
|         run: docker push beryju/authentik-static:0.13.5-stable | ||||
|         run: docker push beryju/authentik-static:2021.1.1-rc2 | ||||
|       - name: Push Docker Container to Registry (latest) | ||||
|         run: docker push beryju/authentik-static:latest | ||||
|   test-release: | ||||
| @ -107,5 +107,5 @@ jobs: | ||||
|           SENTRY_PROJECT: authentik | ||||
|           SENTRY_URL: https://sentry.beryju.org | ||||
|         with: | ||||
|           tagName: 0.13.5-stable | ||||
|           tagName: 2021.1.1-rc2 | ||||
|           environment: beryjuorg-prod | ||||
|  | ||||
							
								
								
									
										155
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										155
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -74,18 +74,18 @@ | ||||
|         }, | ||||
|         "boto3": { | ||||
|             "hashes": [ | ||||
|                 "sha256:0bb2c3159b9f5e0df50430bf06a155bd7f27f480825b6374dde807d42360a668", | ||||
|                 "sha256:a49b3ab4bfa2f6394ba60165cfc468410797dd410f32eed47e22f61451ee986e" | ||||
|                 "sha256:b5052144034e490358c659d0e480c17a4e604fd3aee9a97ddfe6e361a245a4a5", | ||||
|                 "sha256:efd6c96c98900e9fbf217f13cb58f59b793e51f69a1ce61817eefd31f17c6ef5" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==1.16.43" | ||||
|             "version": "==1.16.55" | ||||
|         }, | ||||
|         "botocore": { | ||||
|             "hashes": [ | ||||
|                 "sha256:7398c900dbd4e3d61647269215396ea3e8082f494f3e7b65d9b6aca049c1d463", | ||||
|                 "sha256:795a67338cadb0c3a45014a6c81659da6af623a4e973812f87a6f9d9fb7712e9" | ||||
|                 "sha256:760d0c16c1474c2a46e3fa45e33ae7457b5cab7410737ab1692340ade764cc73", | ||||
|                 "sha256:b34327d84b3bb5620fb54603677a9a973b167290c2c1e7ab69c4a46b201c6d46" | ||||
|             ], | ||||
|             "version": "==1.19.43" | ||||
|             "version": "==1.19.55" | ||||
|         }, | ||||
|         "cachetools": { | ||||
|             "hashes": [ | ||||
| @ -152,11 +152,11 @@ | ||||
|         }, | ||||
|         "channels": { | ||||
|             "hashes": [ | ||||
|                 "sha256:74db79c9eca616be69d38013b22083ab5d3f9ccda1ab5e69096b1bb7da2d9b18", | ||||
|                 "sha256:f50a6e79757a64c1e45e95e144a2ac5f1e99ee44a0718ab182c501f5e5abd268" | ||||
|                 "sha256:056b72e51080a517a0f33a0a30003e03833b551d75394d6636c885d4edb8188f", | ||||
|                 "sha256:3f15bdd2138bb4796e76ea588a0a344b12a7964ea9b2e456f992fddb988a4317" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==3.0.2" | ||||
|             "version": "==3.0.3" | ||||
|         }, | ||||
|         "channels-redis": { | ||||
|             "hashes": [ | ||||
| @ -265,11 +265,11 @@ | ||||
|         }, | ||||
|         "django": { | ||||
|             "hashes": [ | ||||
|                 "sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2", | ||||
|                 "sha256:edb10b5c45e7e9c0fb1dc00b76ec7449aca258a39ffd613dbd078c51d19c9f03" | ||||
|                 "sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7", | ||||
|                 "sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==3.1.4" | ||||
|             "version": "==3.1.5" | ||||
|         }, | ||||
|         "django-cors-middleware": { | ||||
|             "hashes": [ | ||||
| @ -351,7 +351,8 @@ | ||||
|         }, | ||||
|         "djangorestframework": { | ||||
|             "hashes": [ | ||||
|                 "sha256:0209bafcb7b5010fdfec784034f059d512256424de2a0f084cb82b096d6dd6a7" | ||||
|                 "sha256:0209bafcb7b5010fdfec784034f059d512256424de2a0f084cb82b096d6dd6a7", | ||||
|                 "sha256:0898182b4737a7b584a2c73735d89816343369f259fea932d90dc78e35d8ac33" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==3.12.2" | ||||
| @ -411,10 +412,10 @@ | ||||
|         }, | ||||
|         "h11": { | ||||
|             "hashes": [ | ||||
|                 "sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab", | ||||
|                 "sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87" | ||||
|                 "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", | ||||
|                 "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" | ||||
|             ], | ||||
|             "version": "==0.11.0" | ||||
|             "version": "==0.12.0" | ||||
|         }, | ||||
|         "hiredis": { | ||||
|             "hashes": [ | ||||
| @ -486,10 +487,10 @@ | ||||
|         }, | ||||
|         "hyperlink": { | ||||
|             "hashes": [ | ||||
|                 "sha256:47fcc7cd339c6cb2444463ec3277bdcfe142c8b1daf2160bdd52248deec815af", | ||||
|                 "sha256:c528d405766f15a2c536230de7e160b65a08e20264d8891b3eb03307b0df3c63" | ||||
|                 "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", | ||||
|                 "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4" | ||||
|             ], | ||||
|             "version": "==20.0.1" | ||||
|             "version": "==21.0.0" | ||||
|         }, | ||||
|         "idna": { | ||||
|             "hashes": [ | ||||
| @ -701,10 +702,10 @@ | ||||
|         }, | ||||
|         "prompt-toolkit": { | ||||
|             "hashes": [ | ||||
|                 "sha256:25c95d2ac813909f813c93fde734b6e44406d1477a9faef7c915ff37d39c0a8c", | ||||
|                 "sha256:7debb9a521e0b1ee7d2fe96ee4bd60ef03c6492784de0547337ca4433e46aa63" | ||||
|                 "sha256:ac329c69bd8564cb491940511957312c7b8959bb5b3cf3582b406068a51d5bb7", | ||||
|                 "sha256:b8b3d0bde65da350290c46a8f54f336b3cbf5464a4ac11239668d986852e79d5" | ||||
|             ], | ||||
|             "version": "==3.0.8" | ||||
|             "version": "==3.0.10" | ||||
|         }, | ||||
|         "psycopg2-binary": { | ||||
|             "hashes": [ | ||||
| @ -899,10 +900,10 @@ | ||||
|         }, | ||||
|         "pytz": { | ||||
|             "hashes": [ | ||||
|                 "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268", | ||||
|                 "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" | ||||
|                 "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4", | ||||
|                 "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5" | ||||
|             ], | ||||
|             "version": "==2020.4" | ||||
|             "version": "==2020.5" | ||||
|         }, | ||||
|         "pyyaml": { | ||||
|             "hashes": [ | ||||
| @ -955,11 +956,11 @@ | ||||
|         }, | ||||
|         "rsa": { | ||||
|             "hashes": [ | ||||
|                 "sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa", | ||||
|                 "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233" | ||||
|                 "sha256:69805d6b69f56eb05b62daea3a7dbd7aa44324ad1306445e05da8060232d00f4", | ||||
|                 "sha256:a8774e55b59fd9fc893b0d05e9bfc6f47081f46ff5b46f39ccf24631b7be356b" | ||||
|             ], | ||||
|             "markers": "python_version >= '3.6'", | ||||
|             "version": "==4.6" | ||||
|             "version": "==4.7" | ||||
|         }, | ||||
|         "ruamel.yaml": { | ||||
|             "hashes": [ | ||||
| @ -970,10 +971,10 @@ | ||||
|         }, | ||||
|         "s3transfer": { | ||||
|             "hashes": [ | ||||
|                 "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13", | ||||
|                 "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db" | ||||
|                 "sha256:1e28620e5b444652ed752cf87c7e0cb15b0e578972568c6609f0f18212f259ed", | ||||
|                 "sha256:7fdddb4f22275cf1d32129e21f056337fd2a80b6ccef1664528145b72c49e6d2" | ||||
|             ], | ||||
|             "version": "==0.3.3" | ||||
|             "version": "==0.3.4" | ||||
|         }, | ||||
|         "sentry-sdk": { | ||||
|             "hashes": [ | ||||
| @ -1007,11 +1008,11 @@ | ||||
|         }, | ||||
|         "structlog": { | ||||
|             "hashes": [ | ||||
|                 "sha256:7a48375db6274ed1d0ae6123c486472aa1d0890b08d314d2b016f3aa7f35990b", | ||||
|                 "sha256:8a672be150547a93d90a7d74229a29e765be05bd156a35cdcc527ebf68e9af92" | ||||
|                 "sha256:33dd6bd5f49355e52c1c61bb6a4f20d0b48ce0328cc4a45fe872d38b97a05ccd", | ||||
|                 "sha256:af79dfa547d104af8d60f86eac12fb54825f54a46bc998e4504ef66177103174" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==20.1.0" | ||||
|             "version": "==20.2.0" | ||||
|         }, | ||||
|         "swagger-spec-validator": { | ||||
|             "hashes": [ | ||||
| @ -1083,11 +1084,11 @@ | ||||
|                 "standard" | ||||
|             ], | ||||
|             "hashes": [ | ||||
|                 "sha256:6707fa7f4dbd86fd6982a2d4ecdaad2704e4514d23a1e4278104311288b04691", | ||||
|                 "sha256:d19ca083bebd212843e01f689900e5c637a292c63bb336c7f0735a99300a5f38" | ||||
|                 "sha256:1079c50a06f6338095b4f203e7861dbff318dde5f22f3a324fc6e94c7654164c", | ||||
|                 "sha256:ef1e0bb5f7941c6fe324e06443ddac0331e1632a776175f87891c7bd02694355" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==0.13.2" | ||||
|             "version": "==0.13.3" | ||||
|         }, | ||||
|         "uvloop": { | ||||
|             "hashes": [ | ||||
| @ -1373,11 +1374,11 @@ | ||||
|         }, | ||||
|         "django": { | ||||
|             "hashes": [ | ||||
|                 "sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2", | ||||
|                 "sha256:edb10b5c45e7e9c0fb1dc00b76ec7449aca258a39ffd613dbd078c51d19c9f03" | ||||
|                 "sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7", | ||||
|                 "sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==3.1.4" | ||||
|             "version": "==3.1.5" | ||||
|         }, | ||||
|         "django-debug-toolbar": { | ||||
|             "hashes": [ | ||||
| @ -1417,10 +1418,10 @@ | ||||
|         }, | ||||
|         "gitpython": { | ||||
|             "hashes": [ | ||||
|                 "sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b", | ||||
|                 "sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8" | ||||
|                 "sha256:42dbefd8d9e2576c496ed0059f3103dcef7125b9ce16f9d5f9c834aed44a1dac", | ||||
|                 "sha256:867ec3dfb126aac0f8296b19fb63b8c4a399f32b4b6fafe84c4b10af5fa9f7b5" | ||||
|             ], | ||||
|             "version": "==3.1.11" | ||||
|             "version": "==3.1.12" | ||||
|         }, | ||||
|         "iniconfig": { | ||||
|             "hashes": [ | ||||
| @ -1607,10 +1608,10 @@ | ||||
|         }, | ||||
|         "pytz": { | ||||
|             "hashes": [ | ||||
|                 "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268", | ||||
|                 "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" | ||||
|                 "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4", | ||||
|                 "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5" | ||||
|             ], | ||||
|             "version": "==2020.4" | ||||
|             "version": "==2020.5" | ||||
|         }, | ||||
|         "pyyaml": { | ||||
|             "hashes": [ | ||||
| @ -1741,38 +1742,38 @@ | ||||
|         }, | ||||
|         "typed-ast": { | ||||
|             "hashes": [ | ||||
|                 "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", | ||||
|                 "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", | ||||
|                 "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d", | ||||
|                 "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", | ||||
|                 "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", | ||||
|                 "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", | ||||
|                 "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c", | ||||
|                 "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", | ||||
|                 "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", | ||||
|                 "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", | ||||
|                 "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", | ||||
|                 "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", | ||||
|                 "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", | ||||
|                 "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d", | ||||
|                 "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", | ||||
|                 "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", | ||||
|                 "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c", | ||||
|                 "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", | ||||
|                 "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395", | ||||
|                 "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", | ||||
|                 "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", | ||||
|                 "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", | ||||
|                 "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", | ||||
|                 "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", | ||||
|                 "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072", | ||||
|                 "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298", | ||||
|                 "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91", | ||||
|                 "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", | ||||
|                 "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f", | ||||
|                 "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" | ||||
|                 "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1", | ||||
|                 "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d", | ||||
|                 "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6", | ||||
|                 "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd", | ||||
|                 "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37", | ||||
|                 "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151", | ||||
|                 "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07", | ||||
|                 "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440", | ||||
|                 "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70", | ||||
|                 "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496", | ||||
|                 "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea", | ||||
|                 "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400", | ||||
|                 "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc", | ||||
|                 "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606", | ||||
|                 "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc", | ||||
|                 "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581", | ||||
|                 "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412", | ||||
|                 "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a", | ||||
|                 "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2", | ||||
|                 "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787", | ||||
|                 "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f", | ||||
|                 "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937", | ||||
|                 "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64", | ||||
|                 "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487", | ||||
|                 "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b", | ||||
|                 "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41", | ||||
|                 "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a", | ||||
|                 "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3", | ||||
|                 "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166", | ||||
|                 "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10" | ||||
|             ], | ||||
|             "version": "==1.4.1" | ||||
|             "version": "==1.4.2" | ||||
|         }, | ||||
|         "typing-extensions": { | ||||
|             "hashes": [ | ||||
|  | ||||
							
								
								
									
										12
									
								
								SECURITY.md
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								SECURITY.md
									
									
									
									
									
								
							| @ -2,13 +2,11 @@ | ||||
|  | ||||
| ## Supported Versions | ||||
|  | ||||
| As authentik is currently in a pre-stable, only the latest "stable" version is supported. After authentik 1.0, this will change. | ||||
|  | ||||
| | Version  | Supported          | | ||||
| | -------- | ------------------ | | ||||
| | 0.11.x   | :white_check_mark: | | ||||
| | 0.12.x   | :white_check_mark: | | ||||
| | 0.13.x   | :white_check_mark: | | ||||
| | Version    | Supported          | | ||||
| | ---------- | ------------------ | | ||||
| | 0.13.x     | :white_check_mark: | | ||||
| | 0.14.x     | :white_check_mark: | | ||||
| | 2021.1.x   | :white_check_mark: | | ||||
|  | ||||
| ## Reporting a Vulnerability | ||||
|  | ||||
|  | ||||
| @ -1,2 +1,2 @@ | ||||
| """authentik""" | ||||
| __version__ = "0.13.5-stable" | ||||
| __version__ = "2021.1.1-rc2" | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| from django.core.cache import cache | ||||
| from packaging.version import parse | ||||
| from requests import RequestException, get | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik.events.models import Event, EventAction | ||||
|  | ||||
| @ -38,7 +38,7 @@ | ||||
|                 {% for task in object_list %} | ||||
|                 <tr role="row"> | ||||
|                     <th role="columnheader"> | ||||
|                         <pre>{{ task.task_name }}</pre> | ||||
|                         <span>{{ task.html_name|join:"_­" }}</span> | ||||
|                     </th> | ||||
|                     <td role="cell"> | ||||
|                         <span> | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| from django import template | ||||
| from django.db.models import Model | ||||
| from django.utils.html import mark_safe | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| register = template.Library() | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -32,7 +32,7 @@ REQUEST_MOCK_VALID = Mock( | ||||
|     return_value=MockResponse( | ||||
|         200, | ||||
|         """{ | ||||
|             "tag_name": "version/1.2.3" | ||||
|             "tag_name": "version/99999999.9999999" | ||||
|         }""", | ||||
|     ) | ||||
| ) | ||||
| @ -47,10 +47,11 @@ class TestAdminTasks(TestCase): | ||||
|     def test_version_valid_response(self): | ||||
|         """Test Update checker with valid response""" | ||||
|         update_latest_version.delay().get() | ||||
|         self.assertEqual(cache.get(VERSION_CACHE_KEY), "1.2.3") | ||||
|         self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999") | ||||
|         self.assertTrue( | ||||
|             Event.objects.filter( | ||||
|                 action=EventAction.UPDATE_AVAILABLE, context__new_version="1.2.3" | ||||
|                 action=EventAction.UPDATE_AVAILABLE, | ||||
|                 context__new_version="99999999.9999999", | ||||
|             ).exists() | ||||
|         ) | ||||
|         # test that a consecutive check doesn't create a duplicate event | ||||
| @ -58,7 +59,8 @@ class TestAdminTasks(TestCase): | ||||
|         self.assertEqual( | ||||
|             len( | ||||
|                 Event.objects.filter( | ||||
|                     action=EventAction.UPDATE_AVAILABLE, context__new_version="1.2.3" | ||||
|                     action=EventAction.UPDATE_AVAILABLE, | ||||
|                     context__new_version="99999999.9999999", | ||||
|                 ) | ||||
|             ), | ||||
|             1, | ||||
|  | ||||
| @ -4,6 +4,8 @@ from django.urls import path | ||||
| from authentik.admin.views import ( | ||||
|     applications, | ||||
|     certificate_key_pair, | ||||
|     events_notifications_rules, | ||||
|     events_notifications_transports, | ||||
|     flows, | ||||
|     groups, | ||||
|     outposts, | ||||
| @ -352,4 +354,36 @@ urlpatterns = [ | ||||
|         tasks.TaskListView.as_view(), | ||||
|         name="tasks", | ||||
|     ), | ||||
|     # Event Notification Transpots | ||||
|     path( | ||||
|         "events/transports/create/", | ||||
|         events_notifications_transports.NotificationTransportCreateView.as_view(), | ||||
|         name="notification-transport-create", | ||||
|     ), | ||||
|     path( | ||||
|         "events/transports/<uuid:pk>/update/", | ||||
|         events_notifications_transports.NotificationTransportUpdateView.as_view(), | ||||
|         name="notification-transport-update", | ||||
|     ), | ||||
|     path( | ||||
|         "events/transports/<uuid:pk>/delete/", | ||||
|         events_notifications_transports.NotificationTransportDeleteView.as_view(), | ||||
|         name="notification-transport-delete", | ||||
|     ), | ||||
|     # Event Notification Rules | ||||
|     path( | ||||
|         "events/rules/create/", | ||||
|         events_notifications_rules.NotificationRuleCreateView.as_view(), | ||||
|         name="notification-rule-create", | ||||
|     ), | ||||
|     path( | ||||
|         "events/rules/<uuid:pk>/update/", | ||||
|         events_notifications_rules.NotificationRuleUpdateView.as_view(), | ||||
|         name="notification-rule-update", | ||||
|     ), | ||||
|     path( | ||||
|         "events/rules/<uuid:pk>/delete/", | ||||
|         events_notifications_rules.NotificationRuleDeleteView.as_view(), | ||||
|         name="notification-rule-delete", | ||||
|     ), | ||||
| ] | ||||
|  | ||||
| @ -29,7 +29,7 @@ class ApplicationCreateView( | ||||
|     permission_required = "authentik_core.add_application" | ||||
|  | ||||
|     template_name = "generic/create.html" | ||||
|     success_url = reverse_lazy("authentik_admin:applications") | ||||
|     success_url = reverse_lazy("authentik_core:shell") | ||||
|     success_message = _("Successfully created Application") | ||||
|  | ||||
|  | ||||
| @ -47,7 +47,7 @@ class ApplicationUpdateView( | ||||
|     permission_required = "authentik_core.change_application" | ||||
|  | ||||
|     template_name = "generic/update.html" | ||||
|     success_url = reverse_lazy("authentik_admin:applications") | ||||
|     success_url = reverse_lazy("authentik_core:shell") | ||||
|     success_message = _("Successfully updated Application") | ||||
|  | ||||
|  | ||||
| @ -60,5 +60,5 @@ class ApplicationDeleteView( | ||||
|     permission_required = "authentik_core.delete_application" | ||||
|  | ||||
|     template_name = "generic/delete.html" | ||||
|     success_url = reverse_lazy("authentik_admin:applications") | ||||
|     success_url = reverse_lazy("authentik_core:shell") | ||||
|     success_message = _("Successfully deleted Application") | ||||
|  | ||||
							
								
								
									
										64
									
								
								authentik/admin/views/events_notifications_rules.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								authentik/admin/views/events_notifications_rules.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,64 @@ | ||||
| """authentik NotificationRule administration""" | ||||
| from django.contrib.auth.mixins import LoginRequiredMixin | ||||
| from django.contrib.auth.mixins import ( | ||||
|     PermissionRequiredMixin as DjangoPermissionRequiredMixin, | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| from django.urls import reverse_lazy | ||||
| from django.utils.translation import gettext as _ | ||||
| from django.views.generic import UpdateView | ||||
| from guardian.mixins import PermissionRequiredMixin | ||||
|  | ||||
| from authentik.admin.views.utils import BackSuccessUrlMixin, DeleteMessageView | ||||
| from authentik.events.forms import NotificationRuleForm | ||||
| from authentik.events.models import NotificationRule | ||||
| from authentik.lib.views import CreateAssignPermView | ||||
|  | ||||
|  | ||||
| class NotificationRuleCreateView( | ||||
|     SuccessMessageMixin, | ||||
|     BackSuccessUrlMixin, | ||||
|     LoginRequiredMixin, | ||||
|     DjangoPermissionRequiredMixin, | ||||
|     CreateAssignPermView, | ||||
| ): | ||||
|     """Create new NotificationRule""" | ||||
|  | ||||
|     model = NotificationRule | ||||
|     form_class = NotificationRuleForm | ||||
|     permission_required = "authentik_events.add_NotificationRule" | ||||
|  | ||||
|     template_name = "generic/create.html" | ||||
|     success_url = reverse_lazy("authentik_core:shell") | ||||
|     success_message = _("Successfully created Notification Rule") | ||||
|  | ||||
|  | ||||
| class NotificationRuleUpdateView( | ||||
|     SuccessMessageMixin, | ||||
|     BackSuccessUrlMixin, | ||||
|     LoginRequiredMixin, | ||||
|     PermissionRequiredMixin, | ||||
|     UpdateView, | ||||
| ): | ||||
|     """Update application""" | ||||
|  | ||||
|     model = NotificationRule | ||||
|     form_class = NotificationRuleForm | ||||
|     permission_required = "authentik_events.change_NotificationRule" | ||||
|  | ||||
|     template_name = "generic/update.html" | ||||
|     success_url = reverse_lazy("authentik_core:shell") | ||||
|     success_message = _("Successfully updated Notification Rule") | ||||
|  | ||||
|  | ||||
| class NotificationRuleDeleteView( | ||||
|     LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView | ||||
| ): | ||||
|     """Delete application""" | ||||
|  | ||||
|     model = NotificationRule | ||||
|     permission_required = "authentik_events.delete_NotificationRule" | ||||
|  | ||||
|     template_name = "generic/delete.html" | ||||
|     success_url = reverse_lazy("authentik_core:shell") | ||||
|     success_message = _("Successfully deleted Notification Rule") | ||||
							
								
								
									
										64
									
								
								authentik/admin/views/events_notifications_transports.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								authentik/admin/views/events_notifications_transports.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,64 @@ | ||||
| """authentik NotificationTransport administration""" | ||||
| from django.contrib.auth.mixins import LoginRequiredMixin | ||||
| from django.contrib.auth.mixins import ( | ||||
|     PermissionRequiredMixin as DjangoPermissionRequiredMixin, | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| from django.urls import reverse_lazy | ||||
| from django.utils.translation import gettext as _ | ||||
| from django.views.generic import UpdateView | ||||
| from guardian.mixins import PermissionRequiredMixin | ||||
|  | ||||
| from authentik.admin.views.utils import BackSuccessUrlMixin, DeleteMessageView | ||||
| from authentik.events.forms import NotificationTransportForm | ||||
| from authentik.events.models import NotificationTransport | ||||
| from authentik.lib.views import CreateAssignPermView | ||||
|  | ||||
|  | ||||
| class NotificationTransportCreateView( | ||||
|     SuccessMessageMixin, | ||||
|     BackSuccessUrlMixin, | ||||
|     LoginRequiredMixin, | ||||
|     DjangoPermissionRequiredMixin, | ||||
|     CreateAssignPermView, | ||||
| ): | ||||
|     """Create new NotificationTransport""" | ||||
|  | ||||
|     model = NotificationTransport | ||||
|     form_class = NotificationTransportForm | ||||
|     permission_required = "authentik_events.add_notificationtransport" | ||||
|  | ||||
|     template_name = "generic/create.html" | ||||
|     success_url = reverse_lazy("authentik_core:shell") | ||||
|     success_message = _("Successfully created Notification Transport") | ||||
|  | ||||
|  | ||||
| class NotificationTransportUpdateView( | ||||
|     SuccessMessageMixin, | ||||
|     BackSuccessUrlMixin, | ||||
|     LoginRequiredMixin, | ||||
|     PermissionRequiredMixin, | ||||
|     UpdateView, | ||||
| ): | ||||
|     """Update application""" | ||||
|  | ||||
|     model = NotificationTransport | ||||
|     form_class = NotificationTransportForm | ||||
|     permission_required = "authentik_events.change_notificationtransport" | ||||
|  | ||||
|     template_name = "generic/update.html" | ||||
|     success_url = reverse_lazy("authentik_core:shell") | ||||
|     success_message = _("Successfully updated Notification Transport") | ||||
|  | ||||
|  | ||||
| class NotificationTransportDeleteView( | ||||
|     LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView | ||||
| ): | ||||
|     """Delete application""" | ||||
|  | ||||
|     model = NotificationTransport | ||||
|     permission_required = "authentik_events.delete_notificationtransport" | ||||
|  | ||||
|     template_name = "generic/delete.html" | ||||
|     success_url = reverse_lazy("authentik_core:shell") | ||||
|     success_message = _("Successfully deleted Notification Transport") | ||||
| @ -5,10 +5,11 @@ from django.http.request import HttpRequest | ||||
| from django.http.response import HttpResponse | ||||
| from django.utils.translation import gettext as _ | ||||
| from django.views.generic import FormView | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.admin.forms.overview import FlowCacheClearForm, PolicyCacheClearForm | ||||
| from authentik.admin.mixins import AdminRequiredMixin | ||||
| from authentik.core.api.applications import user_app_cache_key | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -26,6 +27,9 @@ class PolicyCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView): | ||||
|         keys = cache.keys("policy_*") | ||||
|         cache.delete_many(keys) | ||||
|         LOGGER.debug("Cleared Policy cache", keys=len(keys)) | ||||
|         # Also delete user application cache | ||||
|         keys = user_app_cache_key("*") | ||||
|         cache.delete_many(keys) | ||||
|         return super().post(request, *args, **kwargs) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -5,7 +5,7 @@ from typing import Any, Optional, Tuple, Union | ||||
|  | ||||
| from rest_framework.authentication import BaseAuthentication, get_authorization_header | ||||
| from rest_framework.request import Request | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import Token, TokenIntents, User | ||||
|  | ||||
|  | ||||
| @ -19,7 +19,10 @@ from authentik.core.api.sources import SourceViewSet | ||||
| from authentik.core.api.tokens import TokenViewSet | ||||
| from authentik.core.api.users import UserViewSet | ||||
| from authentik.crypto.api import CertificateKeyPairViewSet | ||||
| from authentik.events.api import EventViewSet | ||||
| from authentik.events.api.event import EventViewSet | ||||
| from authentik.events.api.notification import NotificationViewSet | ||||
| from authentik.events.api.notification_rule import NotificationRuleViewSet | ||||
| from authentik.events.api.notification_transport import NotificationTransportViewSet | ||||
| from authentik.flows.api import ( | ||||
|     FlowCacheViewSet, | ||||
|     FlowStageBindingViewSet, | ||||
| @ -37,6 +40,7 @@ from authentik.policies.api import ( | ||||
|     PolicyViewSet, | ||||
| ) | ||||
| from authentik.policies.dummy.api import DummyPolicyViewSet | ||||
| from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet | ||||
| from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet | ||||
| from authentik.policies.expression.api import ExpressionPolicyViewSet | ||||
| from authentik.policies.group_membership.api import GroupMembershipPolicyViewSet | ||||
| @ -97,6 +101,9 @@ router.register("flows/bindings", FlowStageBindingViewSet) | ||||
| router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet) | ||||
|  | ||||
| router.register("events/events", EventViewSet) | ||||
| router.register("events/notifications", NotificationViewSet) | ||||
| router.register("events/transports", NotificationTransportViewSet) | ||||
| router.register("events/rules", NotificationRuleViewSet) | ||||
|  | ||||
| router.register("sources/all", SourceViewSet) | ||||
| router.register("sources/ldap", LDAPSourceViewSet) | ||||
| @ -107,6 +114,7 @@ router.register("policies/all", PolicyViewSet) | ||||
| router.register("policies/cached", PolicyCacheViewSet, basename="policies_cache") | ||||
| router.register("policies/bindings", PolicyBindingViewSet) | ||||
| router.register("policies/expression", ExpressionPolicyViewSet) | ||||
| router.register("policies/event_matcher", EventMatcherPolicyViewSet) | ||||
| router.register("policies/group_membership", GroupMembershipPolicyViewSet) | ||||
| router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet) | ||||
| router.register("policies/password_expiry", PasswordExpiryPolicyViewSet) | ||||
|  | ||||
| @ -4,7 +4,7 @@ from django.apps import AppConfig, apps | ||||
| from django.contrib import admin | ||||
| from django.contrib.admin.sites import AlreadyRegistered | ||||
| from guardian.admin import GuardedModelAdmin | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| """Application API Views""" | ||||
| from django.core.cache import cache | ||||
| from django.db.models import QuerySet | ||||
| from django.http.response import Http404 | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| @ -18,6 +19,11 @@ from authentik.events.models import EventAction | ||||
| from authentik.policies.engine import PolicyEngine | ||||
|  | ||||
|  | ||||
| def user_app_cache_key(user_pk: str) -> str: | ||||
|     """Cache key where application list for user is saved""" | ||||
|     return f"user_app_cache_{user_pk}" | ||||
|  | ||||
|  | ||||
| class ApplicationSerializer(ModelSerializer): | ||||
|     """Application Serializer""" | ||||
|  | ||||
| @ -72,12 +78,15 @@ class ApplicationViewSet(ModelViewSet): | ||||
|         """Custom list method that checks Policy based access instead of guardian""" | ||||
|         queryset = self._filter_queryset_for_list(self.get_queryset()) | ||||
|         self.paginate_queryset(queryset) | ||||
|         allowed_applications = [] | ||||
|         for application in queryset: | ||||
|             engine = PolicyEngine(application, self.request.user, self.request) | ||||
|             engine.build() | ||||
|             if engine.passing: | ||||
|                 allowed_applications.append(application) | ||||
|         allowed_applications = cache.get(user_app_cache_key(self.request.user.pk)) | ||||
|         if not allowed_applications: | ||||
|             allowed_applications = [] | ||||
|             for application in queryset: | ||||
|                 engine = PolicyEngine(application, self.request.user, self.request) | ||||
|                 engine.build() | ||||
|                 if engine.passing: | ||||
|                     allowed_applications.append(application) | ||||
|             cache.set(user_app_cache_key(self.request.user.pk), allowed_applications) | ||||
|         serializer = self.get_serializer(allowed_applications, many=True) | ||||
|         return self.get_paginated_response(serializer.data) | ||||
|  | ||||
|  | ||||
| @ -23,7 +23,7 @@ class PropertyMappingSerializer(ModelSerializer): | ||||
| class PropertyMappingViewSet(ReadOnlyModelViewSet): | ||||
|     """PropertyMapping Viewset""" | ||||
|  | ||||
|     queryset = PropertyMapping.objects.all() | ||||
|     queryset = PropertyMapping.objects.none() | ||||
|     serializer_class = PropertyMappingSerializer | ||||
|  | ||||
|     def get_queryset(self): | ||||
|  | ||||
| @ -39,7 +39,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): | ||||
| class ProviderViewSet(ModelViewSet): | ||||
|     """Provider Viewset""" | ||||
|  | ||||
|     queryset = Provider.objects.all() | ||||
|     queryset = Provider.objects.none() | ||||
|     serializer_class = ProviderSerializer | ||||
|     filterset_fields = { | ||||
|         "application": ["isnull"], | ||||
|  | ||||
| @ -31,7 +31,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | ||||
| class SourceViewSet(ReadOnlyModelViewSet): | ||||
|     """Source Viewset""" | ||||
|  | ||||
|     queryset = Source.objects.all() | ||||
|     queryset = Source.objects.none() | ||||
|     serializer_class = SourceSerializer | ||||
|     lookup_field = "slug" | ||||
|  | ||||
|  | ||||
| @ -34,7 +34,7 @@ class UserSerializer(ModelSerializer): | ||||
| class UserViewSet(ModelViewSet): | ||||
|     """User Viewset""" | ||||
|  | ||||
|     queryset = User.objects.all() | ||||
|     queryset = User.objects.none() | ||||
|     serializer_class = UserSerializer | ||||
|  | ||||
|     def get_queryset(self): | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| """Channels base classes""" | ||||
| from channels.exceptions import DenyConnection | ||||
| from channels.generic.websocket import JsonWebsocketConsumer | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.api.auth import token_from_header | ||||
| from authentik.core.models import User | ||||
|  | ||||
| @ -28,7 +28,7 @@ class PropertyMappingEvaluator(BaseEvaluator): | ||||
|         event = Event.new( | ||||
|             EventAction.PROPERTY_MAPPING_EXCEPTION, | ||||
|             expression=expression_source, | ||||
|             error=error_string, | ||||
|             message=error_string, | ||||
|         ) | ||||
|         if "user" in self._context: | ||||
|             event.set_user(self._context["user"]) | ||||
|  | ||||
| @ -15,7 +15,7 @@ from django.utils.translation import gettext_lazy as _ | ||||
| from guardian.mixins import GuardianUserMixin | ||||
| from model_utils.managers import InheritanceManager | ||||
| from rest_framework.serializers import Serializer | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.exceptions import PropertyMappingExpressionException | ||||
| from authentik.core.signals import password_changed | ||||
|  | ||||
| @ -8,7 +8,7 @@ from dbbackup.db.exceptions import CommandConnectorError | ||||
| from django.contrib.humanize.templatetags.humanize import naturaltime | ||||
| from django.core import management | ||||
| from django.utils.timezone import now | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import ExpiringModel | ||||
| from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||
|  | ||||
| @ -9,14 +9,14 @@ | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||
|         <title>{% block title %}{% trans title|default:config.authentik.branding.title %}{% endblock %}</title> | ||||
|         <link rel="icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}"> | ||||
|         <link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.css' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-addons.css' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/fontawesome.min.css' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> | ||||
|         <script src="{% url 'javascript-catalog' %}"></script> | ||||
|         <script src="{% static 'dist/main.js' %}" type="module"></script> | ||||
|         <link rel="icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}"> | ||||
|         <link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.css' %}?v={{ ak_version }}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-addons.css' %}?v={{ ak_version }}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/fontawesome.min.css' %}?v={{ ak_version }}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}"> | ||||
|         <script src="{% url 'javascript-catalog' %}?v={{ ak_version }}"></script> | ||||
|         <script src="{% static 'dist/main.js' %}?v={{ ak_version }}" type="module"></script> | ||||
|         {% block head %} | ||||
|         {% endblock %} | ||||
|     </head> | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| {% load i18n %} | ||||
| {% load authentik_user_settings %} | ||||
| {% load authentik_utils %} | ||||
|  | ||||
| <div class="pf-c-page"> | ||||
|     <main role="main" class="pf-c-page__main" tabindex="-1"> | ||||
| @ -12,47 +13,45 @@ | ||||
|                 <p>{% trans "Configure settings relevant to your user profile." %}</p> | ||||
|             </div> | ||||
|         </section> | ||||
|         <section class="pf-c-page__main-section"> | ||||
|             <div class="pf-u-display-flex pf-u-justify-content-center"> | ||||
|                 <div class="pf-u-w-75"> | ||||
|                     <ak-site-shell url="{% url 'authentik_core:user-details' %}"> | ||||
|                         <div slot="body"></div> | ||||
|                     </ak-site-shell> | ||||
|         <ak-tabs> | ||||
|             <section slot="page-1" data-tab-title="{% trans 'User details' %}" class="pf-c-page__main-section pf-m-no-padding-mobile"> | ||||
|                 <div class="pf-u-display-flex pf-u-justify-content-center"> | ||||
|                     <div class="pf-u-w-75"> | ||||
|                         <ak-site-shell url="{% url 'authentik_core:user-details' %}"> | ||||
|                             <div slot="body"></div> | ||||
|                         </ak-site-shell> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </section> | ||||
|         <section class="pf-c-page__main-section"> | ||||
|             <div class="pf-u-display-flex pf-u-justify-content-center"> | ||||
|                 <div class="pf-u-w-75"> | ||||
|                     <ak-site-shell url="{% url 'authentik_core:user-tokens' %}"> | ||||
|                         <div slot="body"></div> | ||||
|                     </ak-site-shell> | ||||
|             </section> | ||||
|             <section slot="page-2" data-tab-title="{% trans 'Tokens' %}" class="pf-c-page__main-section pf-m-no-padding-mobile"> | ||||
|                 <ak-site-shell url="{% url 'authentik_core:user-tokens' %}"> | ||||
|                     <div slot="body"></div> | ||||
|                 </ak-site-shell> | ||||
|             </section> | ||||
|             {% user_stages as user_stages_loc %} | ||||
|             {% for stage, stage_link in user_stages_loc.items %} | ||||
|             <section slot="page-{{ stage.pk }}" data-tab-title="{{ stage|verbose_name }}" class="pf-c-page__main-section pf-m-no-padding-mobile"> | ||||
|                 <div class="pf-u-display-flex pf-u-justify-content-center"> | ||||
|                     <div class="pf-u-w-75"> | ||||
|                         <ak-site-shell url="{{ stage_link }}"> | ||||
|                             <div slot="body"></div> | ||||
|                         </ak-site-shell> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </section> | ||||
|         {% user_stages as user_stages_loc %} | ||||
|         {% for stage in user_stages_loc %} | ||||
|         <section class="pf-c-page__main-section"> | ||||
|             <div class="pf-u-display-flex pf-u-justify-content-center"> | ||||
|                 <div class="pf-u-w-75"> | ||||
|                     <ak-site-shell url="{{ stage }}"> | ||||
|                         <div slot="body"></div> | ||||
|                     </ak-site-shell> | ||||
|             </section> | ||||
|             {% endfor %} | ||||
|             {% user_sources as user_sources_loc %} | ||||
|             {% for source, source_link in user_sources_loc.item %} | ||||
|             <section slot="page-{{ source.pk }}" data-tab-title="{{ source|verbose_name }}" class="pf-c-page__main-section pf-m-no-padding-mobile"> | ||||
|                 <div class="pf-u-display-flex pf-u-justify-content-center"> | ||||
|                     <div class="pf-u-w-75"> | ||||
|                         <ak-site-shell url="{{ source_link }}"> | ||||
|                             <div slot="body"></div> | ||||
|                         </ak-site-shell> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </section> | ||||
|         {% endfor %} | ||||
|         {% user_sources as user_sources_loc %} | ||||
|         {% for source in user_sources_loc %} | ||||
|         <section class="pf-c-page__main-section"> | ||||
|             <div class="pf-u-display-flex pf-u-justify-content-center"> | ||||
|                 <div class="pf-u-w-75"> | ||||
|                     <ak-site-shell url="{{ source }}"> | ||||
|                         <div slot="body"></div> | ||||
|                     </ak-site-shell> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </section> | ||||
|         {% endfor %} | ||||
|             </section> | ||||
|             {% endfor %} | ||||
|         </ak-tabs> | ||||
|     </main> | ||||
| </div> | ||||
|  | ||||
| @ -13,26 +13,26 @@ register = template.Library() | ||||
|  | ||||
| @register.simple_tag(takes_context=True) | ||||
| # pylint: disable=unused-argument | ||||
| def user_stages(context: RequestContext) -> list[str]: | ||||
| def user_stages(context: RequestContext) -> dict[Stage, str]: | ||||
|     """Return list of all stages which apply to user""" | ||||
|     _all_stages: Iterable[Stage] = Stage.objects.all().select_subclasses() | ||||
|     matching_stages: list[str] = [] | ||||
|     matching_stages: dict[Stage, str] = {} | ||||
|     for stage in _all_stages: | ||||
|         user_settings = stage.ui_user_settings | ||||
|         if not user_settings: | ||||
|             continue | ||||
|         matching_stages.append(user_settings) | ||||
|         matching_stages[stage] = user_settings | ||||
|     return matching_stages | ||||
|  | ||||
|  | ||||
| @register.simple_tag(takes_context=True) | ||||
| def user_sources(context: RequestContext) -> list[str]: | ||||
| def user_sources(context: RequestContext) -> dict[Source, str]: | ||||
|     """Return a list of all sources which are enabled for the user""" | ||||
|     user = context.get("request").user | ||||
|     _all_sources: Iterable[Source] = Source.objects.filter( | ||||
|         enabled=True | ||||
|     ).select_subclasses() | ||||
|     matching_sources: list[str] = [] | ||||
|     matching_sources: dict[Source, str] = {} | ||||
|     for source in _all_sources: | ||||
|         user_settings = source.ui_user_settings | ||||
|         if not user_settings: | ||||
| @ -40,5 +40,5 @@ def user_sources(context: RequestContext) -> list[str]: | ||||
|         policy_engine = PolicyEngine(source, user, context.get("request")) | ||||
|         policy_engine.build() | ||||
|         if policy_engine.passing: | ||||
|             matching_sources.append(user_settings) | ||||
|             matching_sources[source] = user_settings | ||||
|     return matching_sources | ||||
|  | ||||
| @ -25,7 +25,7 @@ urlpatterns = [ | ||||
|         name="user-tokens-delete", | ||||
|     ), | ||||
|     # Libray | ||||
|     path("library/", library.LibraryView.as_view(), name="overview"), | ||||
|     path("library", library.LibraryView.as_view(), name="overview"), | ||||
|     # Impersonation | ||||
|     path( | ||||
|         "-/impersonation/<int:user_id>/", | ||||
|  | ||||
| @ -3,7 +3,7 @@ | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.shortcuts import get_object_or_404, redirect | ||||
| from django.views import View | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.middleware import ( | ||||
|     SESSION_IMPERSONATE_ORIGINAL_USER, | ||||
|  | ||||
							
								
								
									
										0
									
								
								authentik/events/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/events/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @ -48,6 +48,15 @@ class EventViewSet(ReadOnlyModelViewSet): | ||||
| 
 | ||||
|     queryset = Event.objects.all() | ||||
|     serializer_class = EventSerializer | ||||
|     ordering = ["-created"] | ||||
|     search_fields = [ | ||||
|         "user", | ||||
|         "action", | ||||
|         "app", | ||||
|         "context", | ||||
|         "client_ip", | ||||
|     ] | ||||
|     filterset_fields = ["action"] | ||||
| 
 | ||||
|     @swagger_auto_schema( | ||||
|         method="GET", responses={200: EventTopPerUserSerialier(many=True)} | ||||
							
								
								
									
										53
									
								
								authentik/events/api/notification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								authentik/events/api/notification.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | ||||
| """Notification API Views""" | ||||
| from rest_framework import mixins | ||||
| from rest_framework.fields import ReadOnlyField | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
|  | ||||
| from authentik.events.api.event import EventSerializer | ||||
| from authentik.events.models import Notification | ||||
|  | ||||
|  | ||||
| class NotificationSerializer(ModelSerializer): | ||||
|     """Notification Serializer""" | ||||
|  | ||||
|     body = ReadOnlyField() | ||||
|     severity = ReadOnlyField() | ||||
|     event = EventSerializer() | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         model = Notification | ||||
|         fields = [ | ||||
|             "pk", | ||||
|             "severity", | ||||
|             "body", | ||||
|             "created", | ||||
|             "event", | ||||
|             "seen", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class NotificationViewSet( | ||||
|     mixins.RetrieveModelMixin, | ||||
|     mixins.UpdateModelMixin, | ||||
|     mixins.DestroyModelMixin, | ||||
|     mixins.ListModelMixin, | ||||
|     GenericViewSet, | ||||
| ): | ||||
|     """Notification Viewset""" | ||||
|  | ||||
|     queryset = Notification.objects.all() | ||||
|     serializer_class = NotificationSerializer | ||||
|     filterset_fields = [ | ||||
|         "severity", | ||||
|         "body", | ||||
|         "created", | ||||
|         "event", | ||||
|         "seen", | ||||
|     ] | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         if not self.request: | ||||
|             return super().get_queryset() | ||||
|         return Notification.objects.filter(user=self.request.user) | ||||
							
								
								
									
										28
									
								
								authentik/events/api/notification_rule.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								authentik/events/api/notification_rule.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| """NotificationRule API Views""" | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.events.models import NotificationRule | ||||
|  | ||||
|  | ||||
| class NotificationRuleSerializer(ModelSerializer): | ||||
|     """NotificationRule Serializer""" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         model = NotificationRule | ||||
|         depth = 2 | ||||
|         fields = [ | ||||
|             "pk", | ||||
|             "name", | ||||
|             "transports", | ||||
|             "severity", | ||||
|             "group", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class NotificationRuleViewSet(ModelViewSet): | ||||
|     """NotificationRule Viewset""" | ||||
|  | ||||
|     queryset = NotificationRule.objects.all() | ||||
|     serializer_class = NotificationRuleSerializer | ||||
							
								
								
									
										66
									
								
								authentik/events/api/notification_transport.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								authentik/events/api/notification_transport.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,66 @@ | ||||
| """NotificationTransport API Views""" | ||||
| from django.http.response import Http404 | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import SerializerMethodField | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.events.models import ( | ||||
|     Notification, | ||||
|     NotificationSeverity, | ||||
|     NotificationTransport, | ||||
|     NotificationTransportError, | ||||
|     TransportMode, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class NotificationTransportSerializer(ModelSerializer): | ||||
|     """NotificationTransport Serializer""" | ||||
|  | ||||
|     mode_verbose = SerializerMethodField() | ||||
|  | ||||
|     def get_mode_verbose(self, instance: NotificationTransport): | ||||
|         """Return selected mode with a UI Label""" | ||||
|         return TransportMode(instance.mode).label | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         model = NotificationTransport | ||||
|         fields = [ | ||||
|             "pk", | ||||
|             "name", | ||||
|             "mode", | ||||
|             "mode_verbose", | ||||
|             "webhook_url", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class NotificationTransportViewSet(ModelViewSet): | ||||
|     """NotificationTransport Viewset""" | ||||
|  | ||||
|     queryset = NotificationTransport.objects.all() | ||||
|     serializer_class = NotificationTransportSerializer | ||||
|  | ||||
|     @action(detail=True, methods=["post"]) | ||||
|     # pylint: disable=invalid-name | ||||
|     def test(self, request: Request, pk=None) -> Response: | ||||
|         """Send example notification using selected transport. Requires | ||||
|         Modify permissions.""" | ||||
|         transports = get_objects_for_user( | ||||
|             request.user, "authentik_events.change_notificationtransport" | ||||
|         ).filter(pk=pk) | ||||
|         if not transports.exists(): | ||||
|             raise Http404 | ||||
|         transport: NotificationTransport = transports.first() | ||||
|         notification = Notification( | ||||
|             severity=NotificationSeverity.NOTICE, | ||||
|             body=f"Test Notification from transport {transport.name}", | ||||
|             user=request.user, | ||||
|         ) | ||||
|         try: | ||||
|             return Response(transport.send(notification)) | ||||
|         except NotificationTransportError as exc: | ||||
|             return Response(str(exc.__cause__ or None), status=503) | ||||
| @ -10,7 +10,6 @@ class AuthentikEventsConfig(AppConfig): | ||||
|     name = "authentik.events" | ||||
|     label = "authentik_events" | ||||
|     verbose_name = "authentik Events" | ||||
|     mountpoint = "events/" | ||||
|  | ||||
|     def ready(self): | ||||
|         import_module("authentik.events.signals") | ||||
|  | ||||
							
								
								
									
										47
									
								
								authentik/events/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								authentik/events/forms.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,47 @@ | ||||
| """authentik events NotificationTransport forms""" | ||||
| from django import forms | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from authentik.events.models import NotificationRule, NotificationTransport | ||||
|  | ||||
|  | ||||
| class NotificationTransportForm(forms.ModelForm): | ||||
|     """NotificationTransport Form""" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         model = NotificationTransport | ||||
|         fields = [ | ||||
|             "name", | ||||
|             "mode", | ||||
|             "webhook_url", | ||||
|         ] | ||||
|         widgets = { | ||||
|             "name": forms.TextInput(), | ||||
|             "webhook_url": forms.TextInput(), | ||||
|         } | ||||
|         labels = { | ||||
|             "webhook_url": _("Webhook URL"), | ||||
|         } | ||||
|         help_texts = { | ||||
|             "webhook_url": _( | ||||
|                 ("Only required when the Generic or Slack Webhook is used.") | ||||
|             ), | ||||
|         } | ||||
|  | ||||
|  | ||||
| class NotificationRuleForm(forms.ModelForm): | ||||
|     """NotificationRule Form""" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         model = NotificationRule | ||||
|         fields = [ | ||||
|             "name", | ||||
|             "group", | ||||
|             "transports", | ||||
|             "severity", | ||||
|         ] | ||||
|         widgets = { | ||||
|             "name": forms.TextInput(), | ||||
|         } | ||||
| @ -6,9 +6,10 @@ from django.contrib.auth.models import User | ||||
| from django.db.models import Model | ||||
| from django.db.models.signals import post_save, pre_delete | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from guardian.models import UserObjectPermission | ||||
|  | ||||
| from authentik.core.middleware import LOCAL | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.events.models import Event, EventAction, Notification | ||||
| from authentik.events.signals import EventNewThread | ||||
| from authentik.events.utils import model_to_dict | ||||
|  | ||||
| @ -63,7 +64,7 @@ class AuditMiddleware: | ||||
|         user: User, request: HttpRequest, sender, instance: Model, created: bool, **_ | ||||
|     ): | ||||
|         """Signal handler for all object's post_save""" | ||||
|         if isinstance(instance, Event): | ||||
|         if isinstance(instance, (Event, Notification, UserObjectPermission)): | ||||
|             return | ||||
|  | ||||
|         action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED | ||||
| @ -75,7 +76,7 @@ class AuditMiddleware: | ||||
|         user: User, request: HttpRequest, sender, instance: Model, **_ | ||||
|     ): | ||||
|         """Signal handler for all object's pre_delete""" | ||||
|         if isinstance(instance, Event): | ||||
|         if isinstance(instance, (Event, Notification, UserObjectPermission)): | ||||
|             return | ||||
|  | ||||
|         EventNewThread( | ||||
|  | ||||
| @ -0,0 +1,148 @@ | ||||
| # Generated by Django 3.1.4 on 2021-01-11 16:36 | ||||
|  | ||||
| import uuid | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|         ("authentik_policies", "0004_policy_execution_logging"), | ||||
|         ("authentik_core", "0016_auto_20201202_2234"), | ||||
|         ("authentik_events", "0009_auto_20201227_1210"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="NotificationTransport", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "uuid", | ||||
|                     models.UUIDField( | ||||
|                         default=uuid.uuid4, | ||||
|                         editable=False, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("name", models.TextField(unique=True)), | ||||
|                 ( | ||||
|                     "mode", | ||||
|                     models.TextField( | ||||
|                         choices=[ | ||||
|                             ("webhook", "Generic Webhook"), | ||||
|                             ("webhook_slack", "Slack Webhook (Slack/Discord)"), | ||||
|                             ("email", "Email"), | ||||
|                         ] | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("webhook_url", models.TextField(blank=True)), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Notification Transport", | ||||
|                 "verbose_name_plural": "Notification Transports", | ||||
|             }, | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="NotificationRule", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "policybindingmodel_ptr", | ||||
|                     models.OneToOneField( | ||||
|                         auto_created=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         parent_link=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         to="authentik_policies.policybindingmodel", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("name", models.TextField(unique=True)), | ||||
|                 ( | ||||
|                     "severity", | ||||
|                     models.TextField( | ||||
|                         choices=[ | ||||
|                             ("notice", "Notice"), | ||||
|                             ("warning", "Warning"), | ||||
|                             ("alert", "Alert"), | ||||
|                         ], | ||||
|                         default="notice", | ||||
|                         help_text="Controls which severity level the created notifications will have.", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "group", | ||||
|                     models.ForeignKey( | ||||
|                         blank=True, | ||||
|                         help_text="Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent.", | ||||
|                         null=True, | ||||
|                         on_delete=django.db.models.deletion.SET_NULL, | ||||
|                         to="authentik_core.group", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "transports", | ||||
|                     models.ManyToManyField( | ||||
|                         help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.", | ||||
|                         to="authentik_events.NotificationTransport", | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Notification Rule", | ||||
|                 "verbose_name_plural": "Notification Rules", | ||||
|             }, | ||||
|             bases=("authentik_policies.policybindingmodel",), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="Notification", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "uuid", | ||||
|                     models.UUIDField( | ||||
|                         default=uuid.uuid4, | ||||
|                         editable=False, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "severity", | ||||
|                     models.TextField( | ||||
|                         choices=[ | ||||
|                             ("notice", "Notice"), | ||||
|                             ("warning", "Warning"), | ||||
|                             ("alert", "Alert"), | ||||
|                         ] | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("body", models.TextField()), | ||||
|                 ("created", models.DateTimeField(auto_now_add=True)), | ||||
|                 ("seen", models.BooleanField(default=False)), | ||||
|                 ( | ||||
|                     "event", | ||||
|                     models.ForeignKey( | ||||
|                         blank=True, | ||||
|                         null=True, | ||||
|                         on_delete=django.db.models.deletion.SET_NULL, | ||||
|                         to="authentik_events.event", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "user", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         to=settings.AUTH_USER_MODEL, | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Notification", | ||||
|                 "verbose_name_plural": "Notifications", | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @ -0,0 +1,165 @@ | ||||
| # Generated by Django 3.1.4 on 2021-01-10 18:57 | ||||
|  | ||||
| from django.apps.registry import Apps | ||||
| from django.db import migrations | ||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||
|  | ||||
| from authentik.events.models import EventAction, NotificationSeverity, TransportMode | ||||
|  | ||||
|  | ||||
| def notify_configuration_error(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     Group = apps.get_model("authentik_core", "Group") | ||||
|     PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") | ||||
|     EventMatcherPolicy = apps.get_model( | ||||
|         "authentik_policies_event_matcher", "EventMatcherPolicy" | ||||
|     ) | ||||
|     NotificationRule = apps.get_model("authentik_events", "NotificationRule") | ||||
|     NotificationTransport = apps.get_model("authentik_events", "NotificationTransport") | ||||
|  | ||||
|     admin_group = ( | ||||
|         Group.objects.using(db_alias) | ||||
|         .filter(name="authentik Admins", is_superuser=True) | ||||
|         .first() | ||||
|     ) | ||||
|  | ||||
|     policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create( | ||||
|         name="default-match-configuration-error", | ||||
|         defaults={"action": EventAction.CONFIGURATION_ERROR}, | ||||
|     ) | ||||
|     trigger, _ = NotificationRule.objects.using(db_alias).update_or_create( | ||||
|         name="default-notify-configuration-error", | ||||
|         defaults={"group": admin_group, "severity": NotificationSeverity.ALERT}, | ||||
|     ) | ||||
|     trigger.transports.set( | ||||
|         NotificationTransport.objects.using(db_alias).filter( | ||||
|             name="default-email-transport" | ||||
|         ) | ||||
|     ) | ||||
|     trigger.save() | ||||
|     PolicyBinding.objects.using(db_alias).update_or_create( | ||||
|         target=trigger, | ||||
|         policy=policy, | ||||
|         defaults={ | ||||
|             "order": 0, | ||||
|         }, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def notify_update(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     Group = apps.get_model("authentik_core", "Group") | ||||
|     PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") | ||||
|     EventMatcherPolicy = apps.get_model( | ||||
|         "authentik_policies_event_matcher", "EventMatcherPolicy" | ||||
|     ) | ||||
|     NotificationRule = apps.get_model("authentik_events", "NotificationRule") | ||||
|     NotificationTransport = apps.get_model("authentik_events", "NotificationTransport") | ||||
|  | ||||
|     admin_group = ( | ||||
|         Group.objects.using(db_alias) | ||||
|         .filter(name="authentik Admins", is_superuser=True) | ||||
|         .first() | ||||
|     ) | ||||
|  | ||||
|     policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create( | ||||
|         name="default-match-update", | ||||
|         defaults={"action": EventAction.UPDATE_AVAILABLE}, | ||||
|     ) | ||||
|     trigger, _ = NotificationRule.objects.using(db_alias).update_or_create( | ||||
|         name="default-notify-update", | ||||
|         defaults={"group": admin_group, "severity": NotificationSeverity.ALERT}, | ||||
|     ) | ||||
|     trigger.transports.set( | ||||
|         NotificationTransport.objects.using(db_alias).filter( | ||||
|             name="default-email-transport" | ||||
|         ) | ||||
|     ) | ||||
|     trigger.save() | ||||
|     PolicyBinding.objects.using(db_alias).update_or_create( | ||||
|         target=trigger, | ||||
|         policy=policy, | ||||
|         defaults={ | ||||
|             "order": 0, | ||||
|         }, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def notify_exception(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     Group = apps.get_model("authentik_core", "Group") | ||||
|     PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") | ||||
|     EventMatcherPolicy = apps.get_model( | ||||
|         "authentik_policies_event_matcher", "EventMatcherPolicy" | ||||
|     ) | ||||
|     NotificationRule = apps.get_model("authentik_events", "NotificationRule") | ||||
|     NotificationTransport = apps.get_model("authentik_events", "NotificationTransport") | ||||
|  | ||||
|     admin_group = ( | ||||
|         Group.objects.using(db_alias) | ||||
|         .filter(name="authentik Admins", is_superuser=True) | ||||
|         .first() | ||||
|     ) | ||||
|  | ||||
|     policy_policy_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create( | ||||
|         name="default-match-policy-exception", | ||||
|         defaults={"action": EventAction.POLICY_EXCEPTION}, | ||||
|     ) | ||||
|     policy_pm_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create( | ||||
|         name="default-match-property-mapping-exception", | ||||
|         defaults={"action": EventAction.PROPERTY_MAPPING_EXCEPTION}, | ||||
|     ) | ||||
|     trigger, _ = NotificationRule.objects.using(db_alias).update_or_create( | ||||
|         name="default-notify-exception", | ||||
|         defaults={"group": admin_group, "severity": NotificationSeverity.ALERT}, | ||||
|     ) | ||||
|     trigger.transports.set( | ||||
|         NotificationTransport.objects.using(db_alias).filter( | ||||
|             name="default-email-transport" | ||||
|         ) | ||||
|     ) | ||||
|     trigger.save() | ||||
|     PolicyBinding.objects.using(db_alias).update_or_create( | ||||
|         target=trigger, | ||||
|         policy=policy_policy_exc, | ||||
|         defaults={ | ||||
|             "order": 0, | ||||
|         }, | ||||
|     ) | ||||
|     PolicyBinding.objects.using(db_alias).update_or_create( | ||||
|         target=trigger, | ||||
|         policy=policy_pm_exc, | ||||
|         defaults={ | ||||
|             "order": 1, | ||||
|         }, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def transport_email_global(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     NotificationTransport = apps.get_model("authentik_events", "NotificationTransport") | ||||
|  | ||||
|     NotificationTransport.objects.using(db_alias).update_or_create( | ||||
|         name="default-email-transport", | ||||
|         defaults={"mode": TransportMode.EMAIL}, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ( | ||||
|             "authentik_events", | ||||
|             "0010_notification_notificationtransport_notificationrule", | ||||
|         ), | ||||
|         ("authentik_core", "0016_auto_20201202_2234"), | ||||
|         ("authentik_policies_event_matcher", "0003_auto_20210110_1907"), | ||||
|         ("authentik_policies", "0004_policy_execution_logging"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(transport_email_global), | ||||
|         migrations.RunPython(notify_configuration_error), | ||||
|         migrations.RunPython(notify_update), | ||||
|         migrations.RunPython(notify_exception), | ||||
|     ] | ||||
| @ -1,6 +1,6 @@ | ||||
| """authentik events models""" | ||||
|  | ||||
| from inspect import getmodule, stack | ||||
| from smtplib import SMTPException | ||||
| from typing import Optional, Union | ||||
| from uuid import uuid4 | ||||
|  | ||||
| @ -9,19 +9,29 @@ from django.core.exceptions import ValidationError | ||||
| from django.db import models | ||||
| from django.http import HttpRequest | ||||
| from django.utils.translation import gettext as _ | ||||
| from structlog import get_logger | ||||
| from requests import RequestException, post | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik.core.middleware import ( | ||||
|     SESSION_IMPERSONATE_ORIGINAL_USER, | ||||
|     SESSION_IMPERSONATE_USER, | ||||
| ) | ||||
| from authentik.core.models import User | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.events.utils import cleanse_dict, get_user, sanitize_dict | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.lib.utils.http import get_client_ip | ||||
| from authentik.policies.models import PolicyBindingModel | ||||
| from authentik.stages.email.tasks import send_mail | ||||
| from authentik.stages.email.utils import TemplateEmailMessage | ||||
|  | ||||
| LOGGER = get_logger("authentik.events") | ||||
|  | ||||
|  | ||||
| class NotificationTransportError(SentryIgnoredException): | ||||
|     """Error raised when a notification fails to be delivered""" | ||||
|  | ||||
|  | ||||
| class EventAction(models.TextChoices): | ||||
|     """All possible actions to save into the events log""" | ||||
|  | ||||
| @ -104,10 +114,12 @@ class Event(models.Model): | ||||
|         Events independently from requests. | ||||
|         `user` arguments optionally overrides user from requests.""" | ||||
|         if hasattr(request, "user"): | ||||
|             self.user = get_user( | ||||
|                 request.user, | ||||
|                 request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None), | ||||
|             ) | ||||
|             original_user = None | ||||
|             if hasattr(request, "session"): | ||||
|                 original_user = request.session.get( | ||||
|                     SESSION_IMPERSONATE_ORIGINAL_USER, None | ||||
|                 ) | ||||
|             self.user = get_user(request.user, original_user) | ||||
|         if user: | ||||
|             self.user = get_user(user) | ||||
|         # Check if we're currently impersonating, and add that user | ||||
| @ -127,9 +139,7 @@ class Event(models.Model): | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         if not self._state.adding: | ||||
|             raise ValidationError( | ||||
|                 "you may not edit an existing %s" % self._meta.model_name | ||||
|             ) | ||||
|             raise ValidationError("you may not edit an existing Event") | ||||
|         LOGGER.debug( | ||||
|             "Created Event", | ||||
|             action=self.action, | ||||
| @ -139,7 +149,217 @@ class Event(models.Model): | ||||
|         ) | ||||
|         return super().save(*args, **kwargs) | ||||
|  | ||||
|     @property | ||||
|     def summary(self) -> str: | ||||
|         """Return a summary of this event.""" | ||||
|         if "message" in self.context: | ||||
|             return self.context["message"] | ||||
|         return f"{self.action}: {self.context}" | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"<Event action={self.action} user={self.user} context={self.context}>" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         verbose_name = _("Event") | ||||
|         verbose_name_plural = _("Events") | ||||
|  | ||||
|  | ||||
| class TransportMode(models.TextChoices): | ||||
|     """Modes that a notification transport can send a notification""" | ||||
|  | ||||
|     WEBHOOK = "webhook", _("Generic Webhook") | ||||
|     WEBHOOK_SLACK = "webhook_slack", _("Slack Webhook (Slack/Discord)") | ||||
|     EMAIL = "email", _("Email") | ||||
|  | ||||
|  | ||||
| class NotificationTransport(models.Model): | ||||
|     """Action which is executed when a Rule matches""" | ||||
|  | ||||
|     uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||
|  | ||||
|     name = models.TextField(unique=True) | ||||
|     mode = models.TextField(choices=TransportMode.choices) | ||||
|  | ||||
|     webhook_url = models.TextField(blank=True) | ||||
|  | ||||
|     def send(self, notification: "Notification") -> list[str]: | ||||
|         """Send notification to user, called from async task""" | ||||
|         if self.mode == TransportMode.WEBHOOK: | ||||
|             return self.send_webhook(notification) | ||||
|         if self.mode == TransportMode.WEBHOOK_SLACK: | ||||
|             return self.send_webhook_slack(notification) | ||||
|         if self.mode == TransportMode.EMAIL: | ||||
|             return self.send_email(notification) | ||||
|         raise ValueError(f"Invalid mode {self.mode} set") | ||||
|  | ||||
|     def send_webhook(self, notification: "Notification") -> list[str]: | ||||
|         """Send notification to generic webhook""" | ||||
|         try: | ||||
|             response = post( | ||||
|                 self.webhook_url, | ||||
|                 json={ | ||||
|                     "body": notification.body, | ||||
|                     "severity": notification.severity, | ||||
|                     "user_email": notification.user.email, | ||||
|                     "user_username": notification.user.username, | ||||
|                 }, | ||||
|             ) | ||||
|             response.raise_for_status() | ||||
|         except RequestException as exc: | ||||
|             raise NotificationTransportError(exc.response.text) from exc | ||||
|         return [ | ||||
|             response.status_code, | ||||
|             response.text, | ||||
|         ] | ||||
|  | ||||
|     def send_webhook_slack(self, notification: "Notification") -> list[str]: | ||||
|         """Send notification to slack or slack-compatible endpoints""" | ||||
|         fields = [ | ||||
|             { | ||||
|                 "title": _("Severity"), | ||||
|                 "value": notification.severity, | ||||
|                 "short": True, | ||||
|             }, | ||||
|             { | ||||
|                 "title": _("Dispatched for user"), | ||||
|                 "value": str(notification.user), | ||||
|                 "short": True, | ||||
|             }, | ||||
|         ] | ||||
|         if notification.event: | ||||
|             for key, value in notification.event.context.items(): | ||||
|                 if not isinstance(value, str): | ||||
|                     continue | ||||
|                 # https://birdie0.github.io/discord-webhooks-guide/other/field_limits.html | ||||
|                 if len(fields) >= 25: | ||||
|                     continue | ||||
|                 fields.append({"title": key[:256], "value": value[:1024]}) | ||||
|         body = { | ||||
|             "username": "authentik", | ||||
|             "icon_url": "https://goauthentik.io/img/icon.png", | ||||
|             "attachments": [ | ||||
|                 { | ||||
|                     "author_name": "authentik", | ||||
|                     "author_link": "https://goauthentik.io", | ||||
|                     "author_icon": "https://goauthentik.io/img/icon.png", | ||||
|                     "title": notification.body, | ||||
|                     "color": "#fd4b2d", | ||||
|                     "fields": fields, | ||||
|                     "footer": f"authentik v{__version__}", | ||||
|                 } | ||||
|             ], | ||||
|         } | ||||
|         if notification.event: | ||||
|             body["attachments"][0]["title"] = notification.event.action | ||||
|             body["attachments"][0]["text"] = notification.event.action | ||||
|         try: | ||||
|             response = post(self.webhook_url, json=body) | ||||
|             response.raise_for_status() | ||||
|         except RequestException as exc: | ||||
|             raise NotificationTransportError(exc.response.text) from exc | ||||
|         return [ | ||||
|             response.status_code, | ||||
|             response.text, | ||||
|         ] | ||||
|  | ||||
|     def send_email(self, notification: "Notification") -> list[str]: | ||||
|         """Send notification via global email configuration""" | ||||
|         body_trunc = ( | ||||
|             (notification.body[:75] + "..") | ||||
|             if len(notification.body) > 75 | ||||
|             else notification.body | ||||
|         ) | ||||
|         mail = TemplateEmailMessage( | ||||
|             subject=f"authentik Notification: {body_trunc}", | ||||
|             template_name="email/setup.html", | ||||
|             to=[notification.user.email], | ||||
|             template_context={ | ||||
|                 "body": notification.body, | ||||
|             }, | ||||
|         ) | ||||
|         # Email is sent directly here, as the call to send() should have been from a task. | ||||
|         try: | ||||
|             # pyright: reportGeneralTypeIssues=false | ||||
|             return send_mail(mail.__dict__)  # pylint: disable=no-value-for-parameter | ||||
|         except (SMTPException, ConnectionError) as exc: | ||||
|             raise NotificationTransportError from exc | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"Notification Transport {self.name}" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         verbose_name = _("Notification Transport") | ||||
|         verbose_name_plural = _("Notification Transports") | ||||
|  | ||||
|  | ||||
| class NotificationSeverity(models.TextChoices): | ||||
|     """Severity images that a notification can have""" | ||||
|  | ||||
|     NOTICE = "notice", _("Notice") | ||||
|     WARNING = "warning", _("Warning") | ||||
|     ALERT = "alert", _("Alert") | ||||
|  | ||||
|  | ||||
| class Notification(models.Model): | ||||
|     """Event Notification""" | ||||
|  | ||||
|     uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||
|     severity = models.TextField(choices=NotificationSeverity.choices) | ||||
|     body = models.TextField() | ||||
|     created = models.DateTimeField(auto_now_add=True) | ||||
|     event = models.ForeignKey(Event, on_delete=models.SET_NULL, null=True, blank=True) | ||||
|     seen = models.BooleanField(default=False) | ||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         body_trunc = (self.body[:75] + "..") if len(self.body) > 75 else self.body | ||||
|         return f"Notification for user {self.user}: {body_trunc}" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         verbose_name = _("Notification") | ||||
|         verbose_name_plural = _("Notifications") | ||||
|  | ||||
|  | ||||
| class NotificationRule(PolicyBindingModel): | ||||
|     """Decide when to create a Notification based on policies attached to this object.""" | ||||
|  | ||||
|     name = models.TextField(unique=True) | ||||
|     transports = models.ManyToManyField( | ||||
|         NotificationTransport, | ||||
|         help_text=_( | ||||
|             ( | ||||
|                 "Select which transports should be used to notify the user. If none are " | ||||
|                 "selected, the notification will only be shown in the authentik UI." | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|     severity = models.TextField( | ||||
|         choices=NotificationSeverity.choices, | ||||
|         default=NotificationSeverity.NOTICE, | ||||
|         help_text=_( | ||||
|             "Controls which severity level the created notifications will have." | ||||
|         ), | ||||
|     ) | ||||
|     group = models.ForeignKey( | ||||
|         Group, | ||||
|         help_text=_( | ||||
|             ( | ||||
|                 "Define which group of users this notification should be sent and shown to. " | ||||
|                 "If left empty, Notification won't ben sent." | ||||
|             ) | ||||
|         ), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"Notification Rule {self.name}" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         verbose_name = _("Notification Rule") | ||||
|         verbose_name_plural = _("Notification Rules") | ||||
|  | ||||
| @ -7,12 +7,16 @@ from django.contrib.auth.signals import ( | ||||
|     user_logged_out, | ||||
|     user_login_failed, | ||||
| ) | ||||
| from django.db.models.signals import post_save | ||||
| from django.dispatch import receiver | ||||
| from django.http import HttpRequest | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.core.signals import password_changed | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.events.tasks import event_notification_handler | ||||
| from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan | ||||
| from authentik.flows.views import SESSION_KEY_PLAN | ||||
| from authentik.stages.invitation.models import Invitation | ||||
| from authentik.stages.invitation.signals import invitation_used | ||||
| from authentik.stages.user_write.signals import user_write | ||||
| @ -44,6 +48,11 @@ class EventNewThread(Thread): | ||||
| def on_user_logged_in(sender, request: HttpRequest, user: User, **_): | ||||
|     """Log successful login""" | ||||
|     thread = EventNewThread(EventAction.LOGIN, request) | ||||
|     if SESSION_KEY_PLAN in request.session: | ||||
|         flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN] | ||||
|         if PLAN_CONTEXT_SOURCE in flow_plan.context: | ||||
|             # Login request came from an external source, save it in the context | ||||
|             thread.kwargs["using_source"] = flow_plan.context[PLAN_CONTEXT_SOURCE] | ||||
|     thread.user = user | ||||
|     thread.run() | ||||
|  | ||||
| @ -95,3 +104,10 @@ def on_password_changed(sender, user: User, password: str, **_): | ||||
|     """Log password change""" | ||||
|     thread = EventNewThread(EventAction.PASSWORD_SET, None, user=user) | ||||
|     thread.run() | ||||
|  | ||||
|  | ||||
| @receiver(post_save, sender=Event) | ||||
| # pylint: disable=unused-argument | ||||
| def event_post_save_notification(sender, instance: Event, **_): | ||||
|     """Start task to check if any policies trigger an notification on this event""" | ||||
|     event_notification_handler.delay(instance.event_uuid.hex) | ||||
|  | ||||
							
								
								
									
										99
									
								
								authentik/events/tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								authentik/events/tasks.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,99 @@ | ||||
| """Event notification tasks""" | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
| from structlog import get_logger | ||||
|  | ||||
| from authentik.events.models import ( | ||||
|     Event, | ||||
|     Notification, | ||||
|     NotificationRule, | ||||
|     NotificationTransport, | ||||
|     NotificationTransportError, | ||||
| ) | ||||
| from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||
| from authentik.policies.engine import PolicyEngine, PolicyEngineMode | ||||
| from authentik.policies.models import PolicyBinding | ||||
| from authentik.root.celery import CELERY_APP | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task() | ||||
| def event_notification_handler(event_uuid: str): | ||||
|     """Start task for each trigger definition""" | ||||
|     for trigger in NotificationRule.objects.all(): | ||||
|         event_trigger_handler.apply_async( | ||||
|             args=[event_uuid, trigger.name], queue="authentik_events" | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task() | ||||
| def event_trigger_handler(event_uuid: str, trigger_name: str): | ||||
|     """Check if policies attached to NotificationRule match event""" | ||||
|     event: Event = Event.objects.get(event_uuid=event_uuid) | ||||
|     trigger: NotificationRule = NotificationRule.objects.get(name=trigger_name) | ||||
|  | ||||
|     if "policy_uuid" in event.context: | ||||
|         policy_uuid = event.context["policy_uuid"] | ||||
|         if PolicyBinding.objects.filter( | ||||
|             target__in=NotificationRule.objects.all().values_list( | ||||
|                 "pbm_uuid", flat=True | ||||
|             ), | ||||
|             policy=policy_uuid, | ||||
|         ).exists(): | ||||
|             # If policy that caused this event to be created is attached | ||||
|             # to *any* NotificationRule, we return early. | ||||
|             # This is the most effective way to prevent infinite loops. | ||||
|             LOGGER.debug( | ||||
|                 "e(trigger): attempting to prevent infinite loop", trigger=trigger | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|     if not trigger.group: | ||||
|         LOGGER.debug("e(trigger): trigger has no group", trigger=trigger) | ||||
|         return | ||||
|  | ||||
|     LOGGER.debug("e(trigger): checking if trigger applies", trigger=trigger) | ||||
|     policy_engine = PolicyEngine(trigger, get_anonymous_user()) | ||||
|     policy_engine.mode = PolicyEngineMode.MODE_OR | ||||
|     policy_engine.empty_result = False | ||||
|     policy_engine.use_cache = False | ||||
|     policy_engine.request.context["event"] = event | ||||
|     policy_engine.build() | ||||
|     result = policy_engine.result | ||||
|     if not result.passing: | ||||
|         return | ||||
|  | ||||
|     LOGGER.debug("e(trigger): event trigger matched", trigger=trigger) | ||||
|     # Create the notification objects | ||||
|     for user in trigger.group.users.all(): | ||||
|         notification = Notification.objects.create( | ||||
|             severity=trigger.severity, body=event.summary, event=event, user=user | ||||
|         ) | ||||
|  | ||||
|         for transport in trigger.transports.all(): | ||||
|             notification_transport.apply_async( | ||||
|                 args=[notification.pk, transport.pk], queue="authentik_events" | ||||
|             ) | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task( | ||||
|     bind=True, | ||||
|     autoretry_for=(NotificationTransportError,), | ||||
|     retry_backoff=True, | ||||
|     base=MonitoredTask, | ||||
| ) | ||||
| def notification_transport( | ||||
|     self: MonitoredTask, notification_pk: int, transport_pk: int | ||||
| ): | ||||
|     """Send notification over specified transport""" | ||||
|     self.save_on_success = False | ||||
|     try: | ||||
|         notification: Notification = Notification.objects.get(pk=notification_pk) | ||||
|         transport: NotificationTransport = NotificationTransport.objects.get( | ||||
|             pk=transport_pk | ||||
|         ) | ||||
|         transport.send(notification) | ||||
|         self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL)) | ||||
|     except NotificationTransportError as exc: | ||||
|         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) | ||||
|         raise exc | ||||
| @ -1,90 +0,0 @@ | ||||
| {% extends "base/page.html" %} | ||||
|  | ||||
| {% load i18n %} | ||||
| {% load authentik_utils %} | ||||
|  | ||||
| {% block page_content %} | ||||
| <main role="main" class="pf-c-page__main" tabindex="-1" id="main-content"> | ||||
|     <section class="pf-c-page__main-section pf-m-light"> | ||||
|         <div class="pf-c-content"> | ||||
|             <h1> | ||||
|                 <i class="pf-icon pf-icon-catalog"></i> | ||||
|                 {% trans 'Event Log' %} | ||||
|             </h1> | ||||
|         </div> | ||||
|     </section> | ||||
|     <section class="pf-c-page__main-section pf-m-no-padding-mobile"> | ||||
|         <div class="pf-c-card"> | ||||
|             <div class="pf-c-toolbar"> | ||||
|                 <div class="pf-c-toolbar__content"> | ||||
|                     {% include 'partials/toolbar_search.html' %} | ||||
|                     <button role="ak-refresh" class="pf-c-button pf-m-primary"> | ||||
|                         {% trans 'Refresh' %} | ||||
|                     </button> | ||||
|                     {% include 'partials/pagination.html' %} | ||||
|                 </div> | ||||
|             </div> | ||||
|             <table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid"> | ||||
|                 <thead> | ||||
|                     <tr role="row"> | ||||
|                         <th role="columnheader" scope="col">{% trans 'Action' %}</th> | ||||
|                         <th role="columnheader" scope="col">{% trans 'Context' %}</th> | ||||
|                         <th role="columnheader" scope="col">{% trans 'User' %}</th> | ||||
|                         <th role="columnheader" scope="col">{% trans 'Creation Date' %}</th> | ||||
|                         <th role="columnheader" scope="col">{% trans 'Client IP' %}</th> | ||||
|                     </tr> | ||||
|                 </thead> | ||||
|                 <tbody role="rowgroup"> | ||||
|                     {% for entry in object_list %} | ||||
|                     <tr role="row"> | ||||
|                         <th role="columnheader"> | ||||
|                             <div> | ||||
|                                 <div>{{ entry.action }}</div> | ||||
|                                 <small>{{ entry.app|default:'-' }}</small> | ||||
|                             </div> | ||||
|                         </th> | ||||
|                         <td role="cell"> | ||||
|                             <div> | ||||
|                                 <div> | ||||
|                                     <code>{{ entry.context }}</code> | ||||
|                                 </div> | ||||
|                                 {% if entry.user.on_behalf_of %} | ||||
|                                 <small> | ||||
|                                     {% blocktrans with username=entry.user.on_behalf_of.username %} | ||||
|                                     On behalf of {{ username }} | ||||
|                                     {% endblocktrans %} | ||||
|                                 </small> | ||||
|                                 {% endif %} | ||||
|                             </div> | ||||
|                         </td> | ||||
|                         <td role="cell"> | ||||
|                             <div> | ||||
|                                 <div>{{ entry.user.username }}</div> | ||||
|                                 <small> | ||||
|                                     {% blocktrans with pk=entry.user.pk %} | ||||
|                                     ID: {{ pk }} | ||||
|                                     {% endblocktrans %} | ||||
|                                 </small> | ||||
|                             </div> | ||||
|                         </td> | ||||
|                         <td role="cell"> | ||||
|                             <span> | ||||
|                                 {{ entry.created }} | ||||
|                             </span> | ||||
|                         </td> | ||||
|                         <td role="cell"> | ||||
|                             <span> | ||||
|                                 {{ entry.client_ip }} | ||||
|                             </span> | ||||
|                         </td> | ||||
|                     </tr> | ||||
|                     {% endfor %} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|             <div class="pf-c-pagination pf-m-bottom"> | ||||
|                 {% include 'partials/pagination.html' %} | ||||
|             </div> | ||||
|         </div> | ||||
|     </section> | ||||
| </main> | ||||
| {% endblock %} | ||||
							
								
								
									
										24
									
								
								authentik/events/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								authentik/events/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| """Event API tests""" | ||||
|  | ||||
| from django.shortcuts import reverse | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.events.models import Event, EventAction | ||||
|  | ||||
|  | ||||
| class TestEventsAPI(APITestCase): | ||||
|     """Test Event API""" | ||||
|  | ||||
|     def test_top_n(self): | ||||
|         """Test top_per_user""" | ||||
|         user = User.objects.get(username="akadmin") | ||||
|         self.client.force_login(user) | ||||
|  | ||||
|         event = Event.new(EventAction.AUTHORIZE_APPLICATION) | ||||
|         event.save()  # We save to ensure nothing is un-saveable | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:event-top-per-user"), | ||||
|             data={"filter_action": EventAction.AUTHORIZE_APPLICATION}, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
| @ -1,9 +1,10 @@ | ||||
| """events event tests""" | ||||
| """event tests""" | ||||
|  | ||||
| from django.contrib.contenttypes.models import ContentType | ||||
| from django.test import TestCase | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
|  | ||||
| from authentik.core.models import Group | ||||
| from authentik.events.models import Event | ||||
| from authentik.policies.dummy.models import DummyPolicy | ||||
|  | ||||
| @ -13,14 +14,24 @@ class TestEvents(TestCase): | ||||
|  | ||||
|     def test_new_with_model(self): | ||||
|         """Create a new Event passing a model as kwarg""" | ||||
|         event = Event.new("unittest", test={"model": get_anonymous_user()}) | ||||
|         test_model = Group.objects.create(name="test") | ||||
|         event = Event.new("unittest", test={"model": test_model}) | ||||
|         event.save()  # We save to ensure nothing is un-saveable | ||||
|         model_content_type = ContentType.objects.get_for_model(get_anonymous_user()) | ||||
|         model_content_type = ContentType.objects.get_for_model(test_model) | ||||
|         self.assertEqual( | ||||
|             event.context.get("test").get("model").get("app"), | ||||
|             model_content_type.app_label, | ||||
|         ) | ||||
|  | ||||
|     def test_new_with_user(self): | ||||
|         """Create a new Event passing a user as kwarg""" | ||||
|         event = Event.new("unittest", test={"model": get_anonymous_user()}) | ||||
|         event.save()  # We save to ensure nothing is un-saveable | ||||
|         self.assertEqual( | ||||
|             event.context.get("test").get("model").get("username"), | ||||
|             get_anonymous_user().username, | ||||
|         ) | ||||
|  | ||||
|     def test_new_with_uuid_model(self): | ||||
|         """Create a new Event passing a model (with UUID PK) as kwarg""" | ||||
|         temp_model = DummyPolicy.objects.create(name="test", result=True) | ||||
|  | ||||
							
								
								
									
										48
									
								
								authentik/events/tests/test_middleware.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								authentik/events/tests/test_middleware.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | ||||
| """Event Middleware tests""" | ||||
|  | ||||
| from django.shortcuts import reverse | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import Application, User | ||||
| from authentik.events.models import Event, EventAction | ||||
|  | ||||
|  | ||||
| class TestEventsMiddleware(APITestCase): | ||||
|     """Test Event Middleware""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|         self.user = User.objects.get(username="akadmin") | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|     def test_create(self): | ||||
|         """Test model creation event""" | ||||
|         self.client.post( | ||||
|             reverse("authentik_api:application-list"), | ||||
|             data={"name": "test-create", "slug": "test-create"}, | ||||
|         ) | ||||
|         self.assertTrue(Application.objects.filter(name="test-create").exists()) | ||||
|         self.assertTrue( | ||||
|             Event.objects.filter( | ||||
|                 action=EventAction.MODEL_CREATED, | ||||
|                 context__model__model_name="application", | ||||
|                 context__model__app="authentik_core", | ||||
|                 context__model__name="test-create", | ||||
|             ).exists() | ||||
|         ) | ||||
|  | ||||
|     def test_delete(self): | ||||
|         """Test model creation event""" | ||||
|         Application.objects.create(name="test-delete", slug="test-delete") | ||||
|         self.client.delete( | ||||
|             reverse("authentik_api:application-detail", kwargs={"slug": "test-delete"}) | ||||
|         ) | ||||
|         self.assertFalse(Application.objects.filter(name="test").exists()) | ||||
|         self.assertTrue( | ||||
|             Event.objects.filter( | ||||
|                 action=EventAction.MODEL_DELETED, | ||||
|                 context__model__model_name="application", | ||||
|                 context__model__app="authentik_core", | ||||
|                 context__model__name="test-delete", | ||||
|             ).exists() | ||||
|         ) | ||||
							
								
								
									
										90
									
								
								authentik/events/tests/test_notifications.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								authentik/events/tests/test_notifications.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,90 @@ | ||||
| """Notification tests""" | ||||
|  | ||||
| from unittest.mock import MagicMock, patch | ||||
|  | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.events.models import ( | ||||
|     Event, | ||||
|     EventAction, | ||||
|     NotificationRule, | ||||
|     NotificationTransport, | ||||
| ) | ||||
| from authentik.policies.event_matcher.models import EventMatcherPolicy | ||||
| from authentik.policies.exceptions import PolicyException | ||||
| from authentik.policies.models import PolicyBinding | ||||
|  | ||||
|  | ||||
| class TestEventsNotifications(TestCase): | ||||
|     """Test Event Notifications""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.group = Group.objects.create(name="test-group") | ||||
|         self.user = User.objects.create(name="test-user") | ||||
|         self.group.users.add(self.user) | ||||
|         self.group.save() | ||||
|  | ||||
|     def test_trigger_empty(self): | ||||
|         """Test trigger without any policies attached""" | ||||
|         transport = NotificationTransport.objects.create(name="transport") | ||||
|         trigger = NotificationRule.objects.create(name="trigger", group=self.group) | ||||
|         trigger.transports.add(transport) | ||||
|         trigger.save() | ||||
|  | ||||
|         execute_mock = MagicMock() | ||||
|         with patch("authentik.events.models.NotificationTransport.send", execute_mock): | ||||
|             Event.new(EventAction.CUSTOM_PREFIX).save() | ||||
|         self.assertEqual(execute_mock.call_count, 0) | ||||
|  | ||||
|     def test_trigger_single(self): | ||||
|         """Test simple transport triggering""" | ||||
|         transport = NotificationTransport.objects.create(name="transport") | ||||
|         trigger = NotificationRule.objects.create(name="trigger", group=self.group) | ||||
|         trigger.transports.add(transport) | ||||
|         trigger.save() | ||||
|         matcher = EventMatcherPolicy.objects.create( | ||||
|             name="matcher", action=EventAction.CUSTOM_PREFIX | ||||
|         ) | ||||
|         PolicyBinding.objects.create(target=trigger, policy=matcher, order=0) | ||||
|  | ||||
|         execute_mock = MagicMock() | ||||
|         with patch("authentik.events.models.NotificationTransport.send", execute_mock): | ||||
|             Event.new(EventAction.CUSTOM_PREFIX).save() | ||||
|         self.assertEqual(execute_mock.call_count, 1) | ||||
|  | ||||
|     def test_trigger_no_group(self): | ||||
|         """Test trigger without group""" | ||||
|         trigger = NotificationRule.objects.create(name="trigger") | ||||
|         matcher = EventMatcherPolicy.objects.create( | ||||
|             name="matcher", action=EventAction.CUSTOM_PREFIX | ||||
|         ) | ||||
|         PolicyBinding.objects.create(target=trigger, policy=matcher, order=0) | ||||
|  | ||||
|         execute_mock = MagicMock() | ||||
|         with patch("authentik.events.models.NotificationTransport.send", execute_mock): | ||||
|             Event.new(EventAction.CUSTOM_PREFIX).save() | ||||
|         self.assertEqual(execute_mock.call_count, 0) | ||||
|  | ||||
|     def test_policy_error_recursive(self): | ||||
|         """Test Policy error which would cause recursion""" | ||||
|         transport = NotificationTransport.objects.create(name="transport") | ||||
|         NotificationRule.objects.filter(name__startswith="default").delete() | ||||
|         trigger = NotificationRule.objects.create(name="trigger", group=self.group) | ||||
|         trigger.transports.add(transport) | ||||
|         trigger.save() | ||||
|         matcher = EventMatcherPolicy.objects.create( | ||||
|             name="matcher", action=EventAction.CUSTOM_PREFIX | ||||
|         ) | ||||
|         PolicyBinding.objects.create(target=trigger, policy=matcher, order=0) | ||||
|  | ||||
|         execute_mock = MagicMock() | ||||
|         passes = MagicMock(side_effect=PolicyException) | ||||
|         with patch( | ||||
|             "authentik.policies.event_matcher.models.EventMatcherPolicy.passes", passes | ||||
|         ): | ||||
|             with patch( | ||||
|                 "authentik.events.models.NotificationTransport.send", execute_mock | ||||
|             ): | ||||
|                 Event.new(EventAction.CUSTOM_PREFIX).save() | ||||
|         self.assertEqual(passes.call_count, 0) | ||||
| @ -1,9 +0,0 @@ | ||||
| """authentik events urls""" | ||||
| from django.urls import path | ||||
|  | ||||
| from authentik.events.views import EventListView | ||||
|  | ||||
| urlpatterns = [ | ||||
|     # Event Log | ||||
|     path("log/", EventListView.as_view(), name="log"), | ||||
| ] | ||||
| @ -5,12 +5,15 @@ from typing import Any, Dict, Optional | ||||
| from uuid import UUID | ||||
|  | ||||
| from django.contrib.auth.models import AnonymousUser | ||||
| from django.core.handlers.wsgi import WSGIRequest | ||||
| from django.db import models | ||||
| from django.db.models.base import Model | ||||
| from django.http.request import HttpRequest | ||||
| from django.views.debug import SafeExceptionReporterFilter | ||||
| from guardian.utils import get_anonymous_user | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.policies.types import PolicyRequest | ||||
|  | ||||
| # Special keys which are *not* cleaned, even when the default filter | ||||
| # is matched | ||||
| @ -74,13 +77,22 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: | ||||
|     final_dict = {} | ||||
|     for key, value in source.items(): | ||||
|         if is_dataclass(value): | ||||
|             # Because asdict calls `copy.deepcopy(obj)` on everything thats not tuple/dict, | ||||
|             # and deepcopy doesn't work with HttpRequests (neither django nor rest_framework). | ||||
|             # Currently, the only dataclass that actually holds an http request is a PolicyRequest | ||||
|             if isinstance(value, PolicyRequest): | ||||
|                 value.http_request = None | ||||
|             value = asdict(value) | ||||
|         if isinstance(value, dict): | ||||
|             final_dict[key] = sanitize_dict(value) | ||||
|         elif isinstance(value, User): | ||||
|             final_dict[key] = sanitize_dict(get_user(value)) | ||||
|         elif isinstance(value, models.Model): | ||||
|             final_dict[key] = sanitize_dict(model_to_dict(value)) | ||||
|         elif isinstance(value, UUID): | ||||
|             final_dict[key] = value.hex | ||||
|         elif isinstance(value, (HttpRequest, WSGIRequest)): | ||||
|             continue | ||||
|         else: | ||||
|             final_dict[key] = value | ||||
|     return final_dict | ||||
|  | ||||
| @ -1,30 +0,0 @@ | ||||
| """authentik Event administration""" | ||||
| from django.contrib.auth.mixins import LoginRequiredMixin | ||||
| from django.views.generic import ListView | ||||
| from guardian.mixins import PermissionListMixin | ||||
|  | ||||
| from authentik.admin.views.utils import SearchListMixin, UserPaginateListMixin | ||||
| from authentik.events.models import Event | ||||
|  | ||||
|  | ||||
| class EventListView( | ||||
|     PermissionListMixin, | ||||
|     LoginRequiredMixin, | ||||
|     SearchListMixin, | ||||
|     UserPaginateListMixin, | ||||
|     ListView, | ||||
| ): | ||||
|     """Show list of all invitations""" | ||||
|  | ||||
|     model = Event | ||||
|     template_name = "events/list.html" | ||||
|     permission_required = "authentik_events.view_event" | ||||
|     ordering = "-created" | ||||
|  | ||||
|     search_fields = [ | ||||
|         "user", | ||||
|         "action", | ||||
|         "app", | ||||
|         "context", | ||||
|         "client_ip", | ||||
|     ] | ||||
| @ -78,6 +78,8 @@ class FlowViewSet(ModelViewSet): | ||||
|     queryset = Flow.objects.all() | ||||
|     serializer_class = FlowSerializer | ||||
|     lookup_field = "slug" | ||||
|     search_fields = ["name", "slug", "designation", "title"] | ||||
|     filterset_fields = ["flow_uuid", "name", "slug", "designation"] | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: FlowDiagramSerializer()}) | ||||
|     @action(detail=True, methods=["get"]) | ||||
|  | ||||
| @ -7,7 +7,7 @@ from time import time | ||||
| from django import db | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.test import RequestFactory | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik.core.models import User | ||||
|  | ||||
| @ -3,7 +3,7 @@ from dataclasses import dataclass | ||||
| from typing import TYPE_CHECKING, Optional | ||||
|  | ||||
| from django.http.request import HttpRequest | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.flows.models import Stage | ||||
|  | ||||
| @ -8,7 +8,7 @@ from django.http import HttpRequest | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from model_utils.managers import InheritanceManager | ||||
| from rest_framework.serializers import BaseSerializer | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.lib.models import InheritanceForeignKey, SerializerModel | ||||
| from authentik.policies.models import PolicyBindingModel | ||||
|  | ||||
| @ -6,7 +6,7 @@ from django.core.cache import cache | ||||
| from django.http import HttpRequest | ||||
| from sentry_sdk.hub import Hub | ||||
| from sentry_sdk.tracing import Span | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.events.models import cleanse_dict | ||||
| @ -21,6 +21,7 @@ PLAN_CONTEXT_PENDING_USER = "pending_user" | ||||
| PLAN_CONTEXT_SSO = "is_sso" | ||||
| PLAN_CONTEXT_REDIRECT = "redirect" | ||||
| PLAN_CONTEXT_APPLICATION = "application" | ||||
| PLAN_CONTEXT_SOURCE = "source" | ||||
|  | ||||
|  | ||||
| def cache_key(flow: Flow, user: Optional[User] = None) -> str: | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| from django.core.cache import cache | ||||
| from django.db.models.signals import post_save | ||||
| from django.dispatch import receiver | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @ -15,7 +15,7 @@ from django.template.response import TemplateResponse | ||||
| from django.utils.decorators import method_decorator | ||||
| from django.views.decorators.clickjacking import xframe_options_sameorigin | ||||
| from django.views.generic import TemplateView, View | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import USER_ATTRIBUTE_DEBUG | ||||
| from authentik.events.models import cleanse_dict | ||||
|  | ||||
| @ -5,13 +5,15 @@ from contextlib import contextmanager | ||||
| from glob import glob | ||||
| from json import dumps | ||||
| from time import time | ||||
| from typing import Any, Dict | ||||
| from typing import Any | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| import yaml | ||||
| from django.conf import ImproperlyConfigured | ||||
| from django.http import HttpRequest | ||||
|  | ||||
| from authentik import __version__ | ||||
|  | ||||
| SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob( | ||||
|     "/etc/authentik/config.d/*.yml", recursive=True | ||||
| ) | ||||
| @ -19,10 +21,9 @@ ENV_PREFIX = "AUTHENTIK" | ||||
| ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local") | ||||
|  | ||||
|  | ||||
| def context_processor(request: HttpRequest) -> Dict[str, Any]: | ||||
| def context_processor(request: HttpRequest) -> dict[str, Any]: | ||||
|     """Context Processor that injects config object into every template""" | ||||
|     kwargs = {"config": CONFIG.raw} | ||||
|     return kwargs | ||||
|     return {"config": CONFIG.raw, "ak_version": __version__} | ||||
|  | ||||
|  | ||||
| class ConfigLoader: | ||||
|  | ||||
| @ -21,6 +21,17 @@ error_reporting: | ||||
|   environment: customer | ||||
|   send_pii: false | ||||
|  | ||||
| # Global email settings | ||||
| email: | ||||
|   host: localhost | ||||
|   port: 25 | ||||
|   username: "" | ||||
|   password: "" | ||||
|   use_tls: false | ||||
|   use_ssl: false | ||||
|   timeout: 10 | ||||
|   from: authentik@localhost | ||||
|  | ||||
| outposts: | ||||
|   docker_image_base: "beryju/authentik" # this is prepended to -proxy:version | ||||
|  | ||||
|  | ||||
| @ -7,7 +7,7 @@ from django.core.exceptions import ValidationError | ||||
| from requests import Session | ||||
| from sentry_sdk.hub import Hub | ||||
| from sentry_sdk.tracing import Span | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import User | ||||
|  | ||||
|  | ||||
| @ -11,7 +11,7 @@ from ldap3.core.exceptions import LDAPException | ||||
| from redis.exceptions import ConnectionError as RedisConnectionError | ||||
| from redis.exceptions import RedisError, ResponseError | ||||
| from rest_framework.exceptions import APIException | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
| from websockets.exceptions import WebSocketException | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -52,6 +52,11 @@ class TaskInfo: | ||||
|  | ||||
|     task_description: Optional[str] = field(default=None) | ||||
|  | ||||
|     @property | ||||
|     def html_name(self) -> list[str]: | ||||
|         """Get task_name, but split on underscores, so we can join in the html template.""" | ||||
|         return self.task_name.split("_") | ||||
|  | ||||
|     @staticmethod | ||||
|     def all() -> Dict[str, "TaskInfo"]: | ||||
|         """Get all TaskInfo objects""" | ||||
|  | ||||
| @ -8,7 +8,7 @@ from django.http.request import HttpRequest | ||||
| from django.template import Context | ||||
| from django.templatetags.static import static | ||||
| from django.utils.html import escape, mark_safe | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.lib.config import CONFIG | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.http import HttpResponse | ||||
| from django.shortcuts import redirect, reverse | ||||
| from django.urls import NoReverseMatch | ||||
| from django.utils.http import urlencode | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @ -12,7 +12,7 @@ from django.db import ProgrammingError | ||||
| from docker.constants import DEFAULT_UNIX_SOCKET | ||||
| from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME | ||||
| from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @ -8,7 +8,7 @@ from channels.exceptions import DenyConnection | ||||
| from dacite import from_dict | ||||
| from dacite.data import Data | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.channels import AuthJsonConsumer | ||||
| from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState | ||||
|  | ||||
| @ -1,21 +1,32 @@ | ||||
| """Base Controller""" | ||||
| from typing import Dict, List | ||||
| from dataclasses import dataclass | ||||
|  | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
| from structlog.testing import capture_logs | ||||
|  | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.outposts.models import Outpost, OutpostServiceConnection | ||||
|  | ||||
| FIELD_MANAGER = "goauthentik.io" | ||||
|  | ||||
|  | ||||
| class ControllerException(SentryIgnoredException): | ||||
|     """Exception raised when anything fails during controller run""" | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class DeploymentPort: | ||||
|     """Info about deployment's single port.""" | ||||
|  | ||||
|     port: int | ||||
|     name: str | ||||
|     protocol: str | ||||
|  | ||||
|  | ||||
| class BaseController: | ||||
|     """Base Outpost deployment controller""" | ||||
|  | ||||
|     deployment_ports: Dict[str, int] | ||||
|     deployment_ports: list[DeploymentPort] | ||||
|  | ||||
|     outpost: Outpost | ||||
|     connection: OutpostServiceConnection | ||||
| @ -24,14 +35,14 @@ class BaseController: | ||||
|         self.outpost = outpost | ||||
|         self.connection = connection | ||||
|         self.logger = get_logger() | ||||
|         self.deployment_ports = {} | ||||
|         self.deployment_ports = [] | ||||
|  | ||||
|     # pylint: disable=invalid-name | ||||
|     def up(self): | ||||
|         """Called by scheduled task to reconcile deployment/service/etc""" | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def up_with_logs(self) -> List[str]: | ||||
|     def up_with_logs(self) -> list[str]: | ||||
|         """Call .up() but capture all log output and return it.""" | ||||
|         with capture_logs() as logs: | ||||
|             self.up() | ||||
|  | ||||
| @ -68,7 +68,10 @@ class DockerController(BaseController): | ||||
|                 "image": image_name, | ||||
|                 "name": f"authentik-proxy-{self.outpost.uuid.hex}", | ||||
|                 "detach": True, | ||||
|                 "ports": {x: x for _, x in self.deployment_ports.items()}, | ||||
|                 "ports": { | ||||
|                     f"{port.port}/{port.protocol.lower()}": port.port | ||||
|                     for port in self.deployment_ports | ||||
|                 }, | ||||
|                 "environment": self._get_env(), | ||||
|                 "labels": self._get_labels(), | ||||
|             } | ||||
| @ -139,7 +142,10 @@ class DockerController(BaseController): | ||||
|  | ||||
|     def get_static_deployment(self) -> str: | ||||
|         """Generate docker-compose yaml for proxy, version 3.5""" | ||||
|         ports = [f"{x}:{x}" for _, x in self.deployment_ports.items()] | ||||
|         ports = [ | ||||
|             f"{port.port}:{port.port}/{port.protocol.lower()}" | ||||
|             for port in self.deployment_ports | ||||
|         ] | ||||
|         image_prefix = CONFIG.y("outposts.docker_image_base") | ||||
|         compose = { | ||||
|             "version": "3.5", | ||||
| @ -154,6 +160,7 @@ class DockerController(BaseController): | ||||
|                         ), | ||||
|                         "AUTHENTIK_TOKEN": self.outpost.token.key, | ||||
|                     }, | ||||
|                     "labels": self._get_labels(), | ||||
|                 } | ||||
|             }, | ||||
|         } | ||||
|  | ||||
| @ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Generic, TypeVar | ||||
|  | ||||
| from kubernetes.client import V1ObjectMeta | ||||
| from kubernetes.client.rest import ApiException | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| @ -93,7 +93,8 @@ class KubernetesObjectReconciler(Generic[T]): | ||||
|     def reconcile(self, current: T, reference: T): | ||||
|         """Check what operations should be done, should be raised as | ||||
|         ReconcileTrigger""" | ||||
|         raise NotImplementedError | ||||
|         if current.metadata.labels != reference.metadata.labels: | ||||
|             raise NeedsUpdate() | ||||
|  | ||||
|     def create(self, reference: T): | ||||
|         """API Wrapper to create object""" | ||||
|  | ||||
| @ -18,6 +18,7 @@ from kubernetes.client import ( | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | ||||
| from authentik.outposts.controllers.k8s.base import ( | ||||
|     KubernetesObjectReconciler, | ||||
|     NeedsUpdate, | ||||
| @ -43,6 +44,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|         return f"authentik-outpost-{self.controller.outpost.uuid.hex}" | ||||
|  | ||||
|     def reconcile(self, current: V1Deployment, reference: V1Deployment): | ||||
|         super().reconcile(current, reference) | ||||
|         if current.spec.replicas != reference.spec.replicas: | ||||
|             raise NeedsUpdate() | ||||
|         if ( | ||||
| @ -63,8 +65,14 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|         """Get deployment object for outpost""" | ||||
|         # Generate V1ContainerPort objects | ||||
|         container_ports = [] | ||||
|         for port_name, port in self.controller.deployment_ports.items(): | ||||
|             container_ports.append(V1ContainerPort(container_port=port, name=port_name)) | ||||
|         for port in self.controller.deployment_ports: | ||||
|             container_ports.append( | ||||
|                 V1ContainerPort( | ||||
|                     container_port=port.port, | ||||
|                     name=port.name, | ||||
|                     protocol=port.protocol.upper(), | ||||
|                 ) | ||||
|             ) | ||||
|         meta = self.get_object_meta(name=self.name) | ||||
|         secret_name = f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" | ||||
|         image_prefix = CONFIG.y("outposts.docker_image_base") | ||||
| @ -118,7 +126,9 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|         ) | ||||
|  | ||||
|     def create(self, reference: V1Deployment): | ||||
|         return self.api.create_namespaced_deployment(self.namespace, reference) | ||||
|         return self.api.create_namespaced_deployment( | ||||
|             self.namespace, reference, field_manager=FIELD_MANAGER | ||||
|         ) | ||||
|  | ||||
|     def delete(self, reference: V1Deployment): | ||||
|         return self.api.delete_namespaced_deployment( | ||||
| @ -130,5 +140,8 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|  | ||||
|     def update(self, current: V1Deployment, reference: V1Deployment): | ||||
|         return self.api.patch_namespaced_deployment( | ||||
|             current.metadata.name, self.namespace, reference | ||||
|             current.metadata.name, | ||||
|             self.namespace, | ||||
|             reference, | ||||
|             field_manager=FIELD_MANAGER, | ||||
|         ) | ||||
|  | ||||
| @ -4,6 +4,7 @@ from typing import TYPE_CHECKING | ||||
|  | ||||
| from kubernetes.client import CoreV1Api, V1Secret | ||||
|  | ||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | ||||
| from authentik.outposts.controllers.k8s.base import ( | ||||
|     KubernetesObjectReconciler, | ||||
|     NeedsUpdate, | ||||
| @ -30,6 +31,7 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): | ||||
|         return f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" | ||||
|  | ||||
|     def reconcile(self, current: V1Secret, reference: V1Secret): | ||||
|         super().reconcile(current, reference) | ||||
|         for key in reference.data.keys(): | ||||
|             if current.data[key] != reference.data[key]: | ||||
|                 raise NeedsUpdate() | ||||
| @ -51,7 +53,9 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): | ||||
|         ) | ||||
|  | ||||
|     def create(self, reference: V1Secret): | ||||
|         return self.api.create_namespaced_secret(self.namespace, reference) | ||||
|         return self.api.create_namespaced_secret( | ||||
|             self.namespace, reference, field_manager=FIELD_MANAGER | ||||
|         ) | ||||
|  | ||||
|     def delete(self, reference: V1Secret): | ||||
|         return self.api.delete_namespaced_secret( | ||||
| @ -63,5 +67,8 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): | ||||
|  | ||||
|     def update(self, current: V1Secret, reference: V1Secret): | ||||
|         return self.api.patch_namespaced_secret( | ||||
|             current.metadata.name, self.namespace, reference | ||||
|             current.metadata.name, | ||||
|             self.namespace, | ||||
|             reference, | ||||
|             field_manager=FIELD_MANAGER, | ||||
|         ) | ||||
|  | ||||
| @ -3,6 +3,7 @@ from typing import TYPE_CHECKING | ||||
|  | ||||
| from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec | ||||
|  | ||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | ||||
| from authentik.outposts.controllers.k8s.base import ( | ||||
|     KubernetesObjectReconciler, | ||||
|     NeedsUpdate, | ||||
| @ -25,6 +26,7 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | ||||
|         return f"authentik-outpost-{self.controller.outpost.uuid.hex}" | ||||
|  | ||||
|     def reconcile(self, current: V1Service, reference: V1Service): | ||||
|         super().reconcile(current, reference) | ||||
|         if len(current.spec.ports) != len(reference.spec.ports): | ||||
|             raise NeedsUpdate() | ||||
|         for port in reference.spec.ports: | ||||
| @ -35,8 +37,15 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | ||||
|         """Get deployment object for outpost""" | ||||
|         meta = self.get_object_meta(name=self.name) | ||||
|         ports = [] | ||||
|         for port_name, port in self.controller.deployment_ports.items(): | ||||
|             ports.append(V1ServicePort(name=port_name, port=port)) | ||||
|         for port in self.controller.deployment_ports: | ||||
|             ports.append( | ||||
|                 V1ServicePort( | ||||
|                     name=port.name, | ||||
|                     port=port.port, | ||||
|                     protocol=port.protocol.upper(), | ||||
|                     target_port=port.port, | ||||
|                 ) | ||||
|             ) | ||||
|         selector_labels = DeploymentReconciler(self.controller).get_pod_meta() | ||||
|         return V1Service( | ||||
|             metadata=meta, | ||||
| @ -44,7 +53,9 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | ||||
|         ) | ||||
|  | ||||
|     def create(self, reference: V1Service): | ||||
|         return self.api.create_namespaced_service(self.namespace, reference) | ||||
|         return self.api.create_namespaced_service( | ||||
|             self.namespace, reference, field_manager=FIELD_MANAGER | ||||
|         ) | ||||
|  | ||||
|     def delete(self, reference: V1Service): | ||||
|         return self.api.delete_namespaced_service( | ||||
| @ -56,5 +67,8 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | ||||
|  | ||||
|     def update(self, current: V1Service, reference: V1Service): | ||||
|         return self.api.patch_namespaced_service( | ||||
|             current.metadata.name, self.namespace, reference | ||||
|             current.metadata.name, | ||||
|             self.namespace, | ||||
|             reference, | ||||
|             field_manager=FIELD_MANAGER, | ||||
|         ) | ||||
|  | ||||
| @ -24,7 +24,7 @@ from kubernetes.config.incluster_config import load_incluster_config | ||||
| from kubernetes.config.kube_config import load_kube_config_from_dict | ||||
| from model_utils.managers import InheritanceManager | ||||
| from packaging.version import LegacyVersion, Version, parse | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
| from urllib3.exceptions import HTTPError | ||||
|  | ||||
| from authentik import __version__ | ||||
|  | ||||
| @ -2,17 +2,24 @@ | ||||
| from django.db.models import Model | ||||
| from django.db.models.signals import post_save, pre_delete | ||||
| from django.dispatch import receiver | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import Provider | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.lib.utils.reflection import class_to_path | ||||
| from authentik.outposts.models import Outpost | ||||
| from authentik.outposts.models import Outpost, OutpostServiceConnection | ||||
| from authentik.outposts.tasks import outpost_post_save, outpost_pre_delete | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| UPDATE_TRIGGERING_MODELS = ( | ||||
|     Outpost, | ||||
|     OutpostServiceConnection, | ||||
|     Provider, | ||||
|     CertificateKeyPair, | ||||
| ) | ||||
|  | ||||
|  | ||||
| @receiver(post_save) | ||||
| # pylint: disable=unused-argument | ||||
| def post_save_update(sender, instance: Model, **_): | ||||
|     """If an Outpost is saved, Ensure that token is created/updated | ||||
|  | ||||
| @ -22,6 +29,8 @@ def post_save_update(sender, instance: Model, **_): | ||||
|         return | ||||
|     if instance.__module__ == "__fake__": | ||||
|         return | ||||
|     if sender not in UPDATE_TRIGGERING_MODELS: | ||||
|         return | ||||
|     outpost_post_save.delay(class_to_path(instance.__class__), instance.pk) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -6,7 +6,7 @@ from channels.layers import get_channel_layer | ||||
| from django.core.cache import cache | ||||
| from django.db.models.base import Model | ||||
| from django.utils.text import slugify | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||
| from authentik.lib.utils.reflection import path_to_class | ||||
| @ -124,14 +124,12 @@ def outpost_post_save(model_class: str, model_pk: Any): | ||||
|         _ = instance.token | ||||
|         LOGGER.debug("Trigger reconcile for outpost") | ||||
|         outpost_controller.delay(instance.pk) | ||||
|         return | ||||
|  | ||||
|     if isinstance(instance, (OutpostModel, Outpost)): | ||||
|         LOGGER.debug( | ||||
|             "triggering outpost update from outpostmodel/outpost", instance=instance | ||||
|         ) | ||||
|         outpost_send_update(instance) | ||||
|         return | ||||
|  | ||||
|     if isinstance(instance, OutpostServiceConnection): | ||||
|         LOGGER.debug("triggering ServiceConnection state update", instance=instance) | ||||
|  | ||||
| @ -7,7 +7,7 @@ from django.db import models | ||||
| from django.forms import ModelForm | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from rest_framework.serializers import BaseSerializer | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.policies.models import Policy | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| """authentik policy engine""" | ||||
| from enum import Enum | ||||
| from multiprocessing import Pipe, set_start_method | ||||
| from multiprocessing.connection import Connection | ||||
| from typing import Iterator, List, Optional | ||||
| @ -7,7 +8,7 @@ from django.core.cache import cache | ||||
| from django.http import HttpRequest | ||||
| from sentry_sdk.hub import Hub | ||||
| from sentry_sdk.tracing import Span | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel | ||||
| @ -37,12 +38,23 @@ class PolicyProcessInfo: | ||||
|         self.result = None | ||||
|  | ||||
|  | ||||
| class PolicyEngineMode(Enum): | ||||
|     """Decide how results of multiple policies should be combined.""" | ||||
|  | ||||
|     MODE_AND = "and" | ||||
|     MODE_OR = "or" | ||||
|  | ||||
|  | ||||
| class PolicyEngine: | ||||
|     """Orchestrate policy checking, launch tasks and return result""" | ||||
|  | ||||
|     use_cache: bool | ||||
|     request: PolicyRequest | ||||
|  | ||||
|     mode: PolicyEngineMode | ||||
|     # Allow objects with no policies attached to pass | ||||
|     empty_result: bool | ||||
|  | ||||
|     __pbm: PolicyBindingModel | ||||
|     __cached_policies: List[PolicyResult] | ||||
|     __processes: List[PolicyProcessInfo] | ||||
| @ -52,10 +64,15 @@ class PolicyEngine: | ||||
|     def __init__( | ||||
|         self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None | ||||
|     ): | ||||
|         self.mode = PolicyEngineMode.MODE_AND | ||||
|         # For backwards compatibility, set empty_result to true | ||||
|         # objects with no policies attached will pass. | ||||
|         self.empty_result = True | ||||
|         if not isinstance(pbm, PolicyBindingModel):  # pragma: no cover | ||||
|             raise ValueError(f"{pbm} is not instance of PolicyBindingModel") | ||||
|         self.__pbm = pbm | ||||
|         self.request = PolicyRequest(user) | ||||
|         self.request.obj = pbm | ||||
|         if request: | ||||
|             self.request.http_request = request | ||||
|         self.__cached_policies = [] | ||||
| @ -65,8 +82,10 @@ class PolicyEngine: | ||||
|  | ||||
|     def _iter_bindings(self) -> Iterator[PolicyBinding]: | ||||
|         """Make sure all Policies are their respective classes""" | ||||
|         return PolicyBinding.objects.filter(target=self.__pbm, enabled=True).order_by( | ||||
|             "order" | ||||
|         return ( | ||||
|             PolicyBinding.objects.filter(target=self.__pbm, enabled=True) | ||||
|             .order_by("order") | ||||
|             .iterator() | ||||
|         ) | ||||
|  | ||||
|     def _check_policy_type(self, policy: Policy): | ||||
| @ -118,24 +137,19 @@ class PolicyEngine: | ||||
|             x.result for x in self.__processes if x.result | ||||
|         ] | ||||
|         all_results = list(process_results + self.__cached_policies) | ||||
|         final_result = PolicyResult(False) | ||||
|         final_result.messages = [] | ||||
|         final_result.source_results = all_results | ||||
|         if len(all_results) < self.__expected_result_count:  # pragma: no cover | ||||
|             raise AssertionError("Got less results than polices") | ||||
|         for result in all_results: | ||||
|             LOGGER.debug( | ||||
|                 "P_ENG: result", passing=result.passing, messages=result.messages | ||||
|             ) | ||||
|             if result.messages: | ||||
|                 final_result.messages.extend(result.messages) | ||||
|             if not result.passing: | ||||
|                 final_result.messages = tuple(final_result.messages) | ||||
|                 final_result.passing = False | ||||
|                 return final_result | ||||
|         final_result.messages = tuple(final_result.messages) | ||||
|         final_result.passing = True | ||||
|         return final_result | ||||
|         # No results, no policies attached -> passing | ||||
|         if len(all_results) == 0: | ||||
|             return PolicyResult(self.empty_result) | ||||
|         passing = False | ||||
|         if self.mode == PolicyEngineMode.MODE_AND: | ||||
|             passing = all([x.passing for x in all_results]) | ||||
|         if self.mode == PolicyEngineMode.MODE_OR: | ||||
|             passing = any([x.passing for x in all_results]) | ||||
|         result = PolicyResult(passing) | ||||
|         result.messages = tuple([y for x in all_results for y in x.messages]) | ||||
|         return result | ||||
|  | ||||
|     @property | ||||
|     def passing(self) -> bool: | ||||
|  | ||||
							
								
								
									
										0
									
								
								authentik/policies/event_matcher/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/policies/event_matcher/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										25
									
								
								authentik/policies/event_matcher/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								authentik/policies/event_matcher/api.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | ||||
| """Event Matcher Policy API""" | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.policies.event_matcher.models import EventMatcherPolicy | ||||
| from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS | ||||
|  | ||||
|  | ||||
| class EventMatcherPolicySerializer(ModelSerializer): | ||||
|     """Event Matcher Policy Serializer""" | ||||
|  | ||||
|     class Meta: | ||||
|         model = EventMatcherPolicy | ||||
|         fields = GENERAL_SERIALIZER_FIELDS + [ | ||||
|             "action", | ||||
|             "client_ip", | ||||
|             "app", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class EventMatcherPolicyViewSet(ModelViewSet): | ||||
|     """Event Matcher Policy Viewset""" | ||||
|  | ||||
|     queryset = EventMatcherPolicy.objects.all() | ||||
|     serializer_class = EventMatcherPolicySerializer | ||||
							
								
								
									
										11
									
								
								authentik/policies/event_matcher/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								authentik/policies/event_matcher/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| """authentik Event Matcher policy app config""" | ||||
|  | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class AuthentikPoliciesEventMatcherConfig(AppConfig): | ||||
|     """authentik Event Matcher policy app config""" | ||||
|  | ||||
|     name = "authentik.policies.event_matcher" | ||||
|     label = "authentik_policies_event_matcher" | ||||
|     verbose_name = "authentik Policies.Event Matcher" | ||||
							
								
								
									
										25
									
								
								authentik/policies/event_matcher/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								authentik/policies/event_matcher/forms.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | ||||
| """authentik Event Matcher Policy forms""" | ||||
|  | ||||
| from django import forms | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from authentik.policies.event_matcher.models import EventMatcherPolicy | ||||
| from authentik.policies.forms import GENERAL_FIELDS | ||||
|  | ||||
|  | ||||
| class EventMatcherPolicyForm(forms.ModelForm): | ||||
|     """EventMatcherPolicy Form""" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         model = EventMatcherPolicy | ||||
|         fields = GENERAL_FIELDS + [ | ||||
|             "action", | ||||
|             "client_ip", | ||||
|             "app", | ||||
|         ] | ||||
|         widgets = { | ||||
|             "name": forms.TextInput(), | ||||
|             "client_ip": forms.TextInput(), | ||||
|         } | ||||
|         labels = {"client_ip": _("Client IP")} | ||||
							
								
								
									
										70
									
								
								authentik/policies/event_matcher/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								authentik/policies/event_matcher/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,70 @@ | ||||
| # Generated by Django 3.1.4 on 2020-12-24 10:32 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_policies", "0004_policy_execution_logging"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="EventMatcherPolicy", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "policy_ptr", | ||||
|                     models.OneToOneField( | ||||
|                         auto_created=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         parent_link=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         to="authentik_policies.policy", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "action", | ||||
|                     models.TextField( | ||||
|                         blank=True, | ||||
|                         choices=[ | ||||
|                             ("login", "Login"), | ||||
|                             ("login_failed", "Login Failed"), | ||||
|                             ("logout", "Logout"), | ||||
|                             ("user_write", "User Write"), | ||||
|                             ("suspicious_request", "Suspicious Request"), | ||||
|                             ("password_set", "Password Set"), | ||||
|                             ("token_view", "Token View"), | ||||
|                             ("invitation_created", "Invite Created"), | ||||
|                             ("invitation_used", "Invite Used"), | ||||
|                             ("authorize_application", "Authorize Application"), | ||||
|                             ("source_linked", "Source Linked"), | ||||
|                             ("impersonation_started", "Impersonation Started"), | ||||
|                             ("impersonation_ended", "Impersonation Ended"), | ||||
|                             ("policy_execution", "Policy Execution"), | ||||
|                             ("policy_exception", "Policy Exception"), | ||||
|                             ( | ||||
|                                 "property_mapping_exception", | ||||
|                                 "Property Mapping Exception", | ||||
|                             ), | ||||
|                             ("model_created", "Model Created"), | ||||
|                             ("model_updated", "Model Updated"), | ||||
|                             ("model_deleted", "Model Deleted"), | ||||
|                             ("update_available", "Update Available"), | ||||
|                             ("custom_", "Custom Prefix"), | ||||
|                         ], | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("client_ip", models.TextField(blank=True)), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Group Membership Policy", | ||||
|                 "verbose_name_plural": "Group Membership Policies", | ||||
|             }, | ||||
|             bases=("authentik_policies.policy",), | ||||
|         ), | ||||
|     ] | ||||
| @ -0,0 +1,43 @@ | ||||
| # Generated by Django 3.1.4 on 2020-12-30 20:46 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_policies_event_matcher", "0001_initial"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="eventmatcherpolicy", | ||||
|             name="action", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 choices=[ | ||||
|                     ("login", "Login"), | ||||
|                     ("login_failed", "Login Failed"), | ||||
|                     ("logout", "Logout"), | ||||
|                     ("user_write", "User Write"), | ||||
|                     ("suspicious_request", "Suspicious Request"), | ||||
|                     ("password_set", "Password Set"), | ||||
|                     ("token_view", "Token View"), | ||||
|                     ("invitation_used", "Invite Used"), | ||||
|                     ("authorize_application", "Authorize Application"), | ||||
|                     ("source_linked", "Source Linked"), | ||||
|                     ("impersonation_started", "Impersonation Started"), | ||||
|                     ("impersonation_ended", "Impersonation Ended"), | ||||
|                     ("policy_execution", "Policy Execution"), | ||||
|                     ("policy_exception", "Policy Exception"), | ||||
|                     ("property_mapping_exception", "Property Mapping Exception"), | ||||
|                     ("configuration_error", "Configuration Error"), | ||||
|                     ("model_created", "Model Created"), | ||||
|                     ("model_updated", "Model Updated"), | ||||
|                     ("model_deleted", "Model Deleted"), | ||||
|                     ("update_available", "Update Available"), | ||||
|                     ("custom_", "Custom Prefix"), | ||||
|                 ], | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -0,0 +1,111 @@ | ||||
| # Generated by Django 3.1.4 on 2021-01-10 19:07 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_policies_event_matcher", "0002_auto_20201230_2046"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="eventmatcherpolicy", | ||||
|             name="app", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 choices=[ | ||||
|                     ("authentik.admin", "authentik Admin"), | ||||
|                     ("authentik.api", "authentik API"), | ||||
|                     ("authentik.events", "authentik Events"), | ||||
|                     ("authentik.crypto", "authentik Crypto"), | ||||
|                     ("authentik.flows", "authentik Flows"), | ||||
|                     ("authentik.outposts", "authentik Outpost"), | ||||
|                     ("authentik.lib", "authentik lib"), | ||||
|                     ("authentik.policies", "authentik Policies"), | ||||
|                     ("authentik.policies.dummy", "authentik Policies.Dummy"), | ||||
|                     ( | ||||
|                         "authentik.policies.event_matcher", | ||||
|                         "authentik Policies.Event Matcher", | ||||
|                     ), | ||||
|                     ("authentik.policies.expiry", "authentik Policies.Expiry"), | ||||
|                     ("authentik.policies.expression", "authentik Policies.Expression"), | ||||
|                     ( | ||||
|                         "authentik.policies.group_membership", | ||||
|                         "authentik Policies.Group Membership", | ||||
|                     ), | ||||
|                     ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"), | ||||
|                     ("authentik.policies.password", "authentik Policies.Password"), | ||||
|                     ("authentik.policies.reputation", "authentik Policies.Reputation"), | ||||
|                     ("authentik.providers.proxy", "authentik Providers.Proxy"), | ||||
|                     ("authentik.providers.oauth2", "authentik Providers.OAuth2"), | ||||
|                     ("authentik.providers.saml", "authentik Providers.SAML"), | ||||
|                     ("authentik.recovery", "authentik Recovery"), | ||||
|                     ("authentik.sources.ldap", "authentik Sources.LDAP"), | ||||
|                     ("authentik.sources.oauth", "authentik Sources.OAuth"), | ||||
|                     ("authentik.sources.saml", "authentik Sources.SAML"), | ||||
|                     ("authentik.stages.captcha", "authentik Stages.Captcha"), | ||||
|                     ("authentik.stages.consent", "authentik Stages.Consent"), | ||||
|                     ("authentik.stages.dummy", "authentik Stages.Dummy"), | ||||
|                     ("authentik.stages.email", "authentik Stages.Email"), | ||||
|                     ("authentik.stages.prompt", "authentik Stages.Prompt"), | ||||
|                     ( | ||||
|                         "authentik.stages.identification", | ||||
|                         "authentik Stages.Identification", | ||||
|                     ), | ||||
|                     ("authentik.stages.invitation", "authentik Stages.User Invitation"), | ||||
|                     ("authentik.stages.user_delete", "authentik Stages.User Delete"), | ||||
|                     ("authentik.stages.user_login", "authentik Stages.User Login"), | ||||
|                     ("authentik.stages.user_logout", "authentik Stages.User Logout"), | ||||
|                     ("authentik.stages.user_write", "authentik Stages.User Write"), | ||||
|                     ("authentik.stages.otp_static", "authentik OTP.Static"), | ||||
|                     ("authentik.stages.otp_time", "authentik OTP.Time"), | ||||
|                     ("authentik.stages.otp_validate", "authentik OTP.Validate"), | ||||
|                     ("authentik.stages.password", "authentik Stages.Password"), | ||||
|                     ("authentik.core", "authentik Core"), | ||||
|                 ], | ||||
|                 default="", | ||||
|                 help_text="Match events created by selected application. When left empty, all applications are matched.", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="eventmatcherpolicy", | ||||
|             name="action", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 choices=[ | ||||
|                     ("login", "Login"), | ||||
|                     ("login_failed", "Login Failed"), | ||||
|                     ("logout", "Logout"), | ||||
|                     ("user_write", "User Write"), | ||||
|                     ("suspicious_request", "Suspicious Request"), | ||||
|                     ("password_set", "Password Set"), | ||||
|                     ("token_view", "Token View"), | ||||
|                     ("invitation_used", "Invite Used"), | ||||
|                     ("authorize_application", "Authorize Application"), | ||||
|                     ("source_linked", "Source Linked"), | ||||
|                     ("impersonation_started", "Impersonation Started"), | ||||
|                     ("impersonation_ended", "Impersonation Ended"), | ||||
|                     ("policy_execution", "Policy Execution"), | ||||
|                     ("policy_exception", "Policy Exception"), | ||||
|                     ("property_mapping_exception", "Property Mapping Exception"), | ||||
|                     ("configuration_error", "Configuration Error"), | ||||
|                     ("model_created", "Model Created"), | ||||
|                     ("model_updated", "Model Updated"), | ||||
|                     ("model_deleted", "Model Deleted"), | ||||
|                     ("update_available", "Update Available"), | ||||
|                     ("custom_", "Custom Prefix"), | ||||
|                 ], | ||||
|                 help_text="Match created events with this action type. When left empty, all action types will be matched.", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="eventmatcherpolicy", | ||||
|             name="client_ip", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 help_text="Matches Event's Client IP (strict matching, for network matching use an Expression Policy)", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -0,0 +1,79 @@ | ||||
| # Generated by Django 3.1.4 on 2021-01-12 21:58 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_policies_event_matcher", "0003_auto_20210110_1907"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterModelOptions( | ||||
|             name="eventmatcherpolicy", | ||||
|             options={ | ||||
|                 "verbose_name": "Event Matcher Policy", | ||||
|                 "verbose_name_plural": "Event Matcher Policies", | ||||
|             }, | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="eventmatcherpolicy", | ||||
|             name="app", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 choices=[ | ||||
|                     ("authentik.admin", "authentik Admin"), | ||||
|                     ("authentik.api", "authentik API"), | ||||
|                     ("authentik.events", "authentik Events"), | ||||
|                     ("authentik.crypto", "authentik Crypto"), | ||||
|                     ("authentik.flows", "authentik Flows"), | ||||
|                     ("authentik.outposts", "authentik Outpost"), | ||||
|                     ("authentik.lib", "authentik lib"), | ||||
|                     ("authentik.policies", "authentik Policies"), | ||||
|                     ("authentik.policies.dummy", "authentik Policies.Dummy"), | ||||
|                     ( | ||||
|                         "authentik.policies.event_matcher", | ||||
|                         "authentik Policies.Event Matcher", | ||||
|                     ), | ||||
|                     ("authentik.policies.expiry", "authentik Policies.Expiry"), | ||||
|                     ("authentik.policies.expression", "authentik Policies.Expression"), | ||||
|                     ( | ||||
|                         "authentik.policies.group_membership", | ||||
|                         "authentik Policies.Group Membership", | ||||
|                     ), | ||||
|                     ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"), | ||||
|                     ("authentik.policies.password", "authentik Policies.Password"), | ||||
|                     ("authentik.policies.reputation", "authentik Policies.Reputation"), | ||||
|                     ("authentik.providers.proxy", "authentik Providers.Proxy"), | ||||
|                     ("authentik.providers.oauth2", "authentik Providers.OAuth2"), | ||||
|                     ("authentik.providers.saml", "authentik Providers.SAML"), | ||||
|                     ("authentik.recovery", "authentik Recovery"), | ||||
|                     ("authentik.sources.ldap", "authentik Sources.LDAP"), | ||||
|                     ("authentik.sources.oauth", "authentik Sources.OAuth"), | ||||
|                     ("authentik.sources.saml", "authentik Sources.SAML"), | ||||
|                     ("authentik.stages.captcha", "authentik Stages.Captcha"), | ||||
|                     ("authentik.stages.consent", "authentik Stages.Consent"), | ||||
|                     ("authentik.stages.dummy", "authentik Stages.Dummy"), | ||||
|                     ("authentik.stages.email", "authentik Stages.Email"), | ||||
|                     ("authentik.stages.prompt", "authentik Stages.Prompt"), | ||||
|                     ( | ||||
|                         "authentik.stages.identification", | ||||
|                         "authentik Stages.Identification", | ||||
|                     ), | ||||
|                     ("authentik.stages.invitation", "authentik Stages.User Invitation"), | ||||
|                     ("authentik.stages.user_delete", "authentik Stages.User Delete"), | ||||
|                     ("authentik.stages.user_login", "authentik Stages.User Login"), | ||||
|                     ("authentik.stages.user_logout", "authentik Stages.User Logout"), | ||||
|                     ("authentik.stages.user_write", "authentik Stages.User Write"), | ||||
|                     ("authentik.stages.otp_static", "authentik Stages.OTP.Static"), | ||||
|                     ("authentik.stages.otp_time", "authentik Stages.OTP.Time"), | ||||
|                     ("authentik.stages.otp_validate", "authentik Stages.OTP.Validate"), | ||||
|                     ("authentik.stages.password", "authentik Stages.Password"), | ||||
|                     ("authentik.core", "authentik Core"), | ||||
|                 ], | ||||
|                 default="", | ||||
|                 help_text="Match events created by selected application. When left empty, all applications are matched.", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										88
									
								
								authentik/policies/event_matcher/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								authentik/policies/event_matcher/models.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,88 @@ | ||||
| """Event Matcher models""" | ||||
| from typing import Type | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.db import models | ||||
| from django.forms import ModelForm | ||||
| from django.utils.translation import gettext as _ | ||||
| from rest_framework.serializers import BaseSerializer | ||||
|  | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.policies.models import Policy | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
|  | ||||
|  | ||||
| def app_choices() -> list[tuple[str, str]]: | ||||
|     """Get a list of all installed applications that create events. | ||||
|     Returns a list of tuples containing (dotted.app.path, name)""" | ||||
|     choices = [] | ||||
|     for app in apps.get_app_configs(): | ||||
|         if app.label.startswith("authentik"): | ||||
|             choices.append((app.name, app.verbose_name)) | ||||
|     return choices | ||||
|  | ||||
|  | ||||
| class EventMatcherPolicy(Policy): | ||||
|     """Passes when Event matches selected criteria.""" | ||||
|  | ||||
|     action = models.TextField( | ||||
|         choices=EventAction.choices, | ||||
|         blank=True, | ||||
|         help_text=_( | ||||
|             ( | ||||
|                 "Match created events with this action type. " | ||||
|                 "When left empty, all action types will be matched." | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|     app = models.TextField( | ||||
|         choices=app_choices(), | ||||
|         blank=True, | ||||
|         default="", | ||||
|         help_text=_( | ||||
|             ( | ||||
|                 "Match events created by selected application. " | ||||
|                 "When left empty, all applications are matched." | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|     client_ip = models.TextField( | ||||
|         blank=True, | ||||
|         help_text=_( | ||||
|             ( | ||||
|                 "Matches Event's Client IP (strict matching, " | ||||
|                 "for network matching use an Expression Policy)" | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> BaseSerializer: | ||||
|         from authentik.policies.event_matcher.api import ( | ||||
|             EventMatcherPolicySerializer, | ||||
|         ) | ||||
|  | ||||
|         return EventMatcherPolicySerializer | ||||
|  | ||||
|     @property | ||||
|     def form(self) -> Type[ModelForm]: | ||||
|         from authentik.policies.event_matcher.forms import EventMatcherPolicyForm | ||||
|  | ||||
|         return EventMatcherPolicyForm | ||||
|  | ||||
|     def passes(self, request: PolicyRequest) -> PolicyResult: | ||||
|         if "event" not in request.context: | ||||
|             return PolicyResult(False) | ||||
|         event: Event = request.context["event"] | ||||
|         if event.action == self.action: | ||||
|             return PolicyResult(True, "Action matched.") | ||||
|         if event.client_ip == self.client_ip: | ||||
|             return PolicyResult(True, "Client IP matched.") | ||||
|         if event.app == self.app: | ||||
|             return PolicyResult(True, "App matched.") | ||||
|         return PolicyResult(False) | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         verbose_name = _("Event Matcher Policy") | ||||
|         verbose_name_plural = _("Event Matcher Policies") | ||||
							
								
								
									
										68
									
								
								authentik/policies/event_matcher/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								authentik/policies/event_matcher/tests.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,68 @@ | ||||
| """event_matcher tests""" | ||||
| from django.test import TestCase | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
|  | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.policies.event_matcher.models import EventMatcherPolicy | ||||
| from authentik.policies.types import PolicyRequest | ||||
|  | ||||
|  | ||||
| class TestEventMatcherPolicy(TestCase): | ||||
|     """EventMatcherPolicy tests""" | ||||
|  | ||||
|     def test_match_action(self): | ||||
|         """Test match action""" | ||||
|         event = Event.new(EventAction.LOGIN) | ||||
|         request = PolicyRequest(get_anonymous_user()) | ||||
|         request.context["event"] = event | ||||
|         policy: EventMatcherPolicy = EventMatcherPolicy.objects.create( | ||||
|             action=EventAction.LOGIN | ||||
|         ) | ||||
|         response = policy.passes(request) | ||||
|         self.assertTrue(response.passing) | ||||
|         self.assertTupleEqual(response.messages, ("Action matched.",)) | ||||
|  | ||||
|     def test_match_client_ip(self): | ||||
|         """Test match client_ip""" | ||||
|         event = Event.new(EventAction.LOGIN) | ||||
|         event.client_ip = "1.2.3.4" | ||||
|         request = PolicyRequest(get_anonymous_user()) | ||||
|         request.context["event"] = event | ||||
|         policy: EventMatcherPolicy = EventMatcherPolicy.objects.create( | ||||
|             client_ip="1.2.3.4" | ||||
|         ) | ||||
|         response = policy.passes(request) | ||||
|         self.assertTrue(response.passing) | ||||
|         self.assertTupleEqual(response.messages, ("Client IP matched.",)) | ||||
|  | ||||
|     def test_match_app(self): | ||||
|         """Test match app""" | ||||
|         event = Event.new(EventAction.LOGIN) | ||||
|         event.app = "foo" | ||||
|         request = PolicyRequest(get_anonymous_user()) | ||||
|         request.context["event"] = event | ||||
|         policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(app="foo") | ||||
|         response = policy.passes(request) | ||||
|         self.assertTrue(response.passing) | ||||
|         self.assertTupleEqual(response.messages, ("App matched.",)) | ||||
|  | ||||
|     def test_drop(self): | ||||
|         """Test drop event""" | ||||
|         event = Event.new(EventAction.LOGIN) | ||||
|         event.client_ip = "1.2.3.4" | ||||
|         request = PolicyRequest(get_anonymous_user()) | ||||
|         request.context["event"] = event | ||||
|         policy: EventMatcherPolicy = EventMatcherPolicy.objects.create( | ||||
|             client_ip="1.2.3.5" | ||||
|         ) | ||||
|         response = policy.passes(request) | ||||
|         self.assertFalse(response.passing) | ||||
|  | ||||
|     def test_invalid(self): | ||||
|         """Test passing event""" | ||||
|         request = PolicyRequest(get_anonymous_user()) | ||||
|         policy: EventMatcherPolicy = EventMatcherPolicy.objects.create( | ||||
|             client_ip="1.2.3.4" | ||||
|         ) | ||||
|         response = policy.passes(request) | ||||
|         self.assertFalse(response.passing) | ||||
| @ -1,6 +1,14 @@ | ||||
| """policy exceptions""" | ||||
| from typing import Optional | ||||
|  | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
|  | ||||
|  | ||||
| class PolicyException(SentryIgnoredException): | ||||
|     """Exception that should be raised during Policy Evaluation, and can be recovered from.""" | ||||
|  | ||||
|     src_exc: Optional[Exception] = None | ||||
|  | ||||
|     def __init__(self, src_exc: Optional[Exception] = None) -> None: | ||||
|         super().__init__() | ||||
|         self.src_exc = src_exc | ||||
|  | ||||
| @ -7,7 +7,7 @@ from django.forms import ModelForm | ||||
| from django.utils.timezone import now | ||||
| from django.utils.translation import gettext as _ | ||||
| from rest_framework.serializers import BaseSerializer | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.policies.models import Policy | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
|  | ||||
| @ -1,16 +1,14 @@ | ||||
| """authentik expression policy evaluator""" | ||||
| from ipaddress import ip_address, ip_network | ||||
| from traceback import format_tb | ||||
| from typing import TYPE_CHECKING, List, Optional | ||||
|  | ||||
| from django.http import HttpRequest | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.events.utils import model_to_dict, sanitize_dict | ||||
| from authentik.flows.planner import PLAN_CONTEXT_SSO | ||||
| from authentik.lib.expression.evaluator import BaseEvaluator | ||||
| from authentik.lib.utils.http import get_client_ip | ||||
| from authentik.policies.exceptions import PolicyException | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| @ -57,32 +55,26 @@ class PolicyEvaluator(BaseEvaluator): | ||||
|  | ||||
|     def handle_error(self, exc: Exception, expression_source: str): | ||||
|         """Exception Handler""" | ||||
|         error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)]) | ||||
|         event = Event.new( | ||||
|             EventAction.POLICY_EXCEPTION, | ||||
|             expression=expression_source, | ||||
|             error=error_string, | ||||
|             request=self._context["request"], | ||||
|         ) | ||||
|         if self.policy: | ||||
|             event.context["model"] = sanitize_dict(model_to_dict(self.policy)) | ||||
|         if "http_request" in self._context: | ||||
|             event.from_http(self._context["http_request"]) | ||||
|         else: | ||||
|             event.set_user(self._context["request"].user) | ||||
|             event.save() | ||||
|         # So, this is a bit questionable. Essentially, we are edit the stacktrace | ||||
|         # so the user only sees information relevant to them | ||||
|         # and none of our surrounding error handling | ||||
|         exc.__traceback__ = exc.__traceback__.tb_next | ||||
|         raise PolicyException(exc) | ||||
|  | ||||
|     def evaluate(self, expression_source: str) -> PolicyResult: | ||||
|         """Parse and evaluate expression. Policy is expected to return a truthy object. | ||||
|         Messages can be added using 'do ak_message()'.""" | ||||
|         try: | ||||
|             result = super().evaluate(expression_source) | ||||
|         except PolicyException as exc: | ||||
|             # PolicyExceptions should be propagated back to the process, | ||||
|             # which handles recording and returning a correct result | ||||
|             raise exc | ||||
|         except Exception as exc:  # pylint: disable=broad-except | ||||
|             LOGGER.warning("Expression error", exc=exc) | ||||
|             return PolicyResult(False, str(exc)) | ||||
|         else: | ||||
|             policy_result = PolicyResult(False) | ||||
|             policy_result.messages = tuple(self._messages) | ||||
|             policy_result = PolicyResult(False, *self._messages) | ||||
|             if result is None: | ||||
|                 LOGGER.warning( | ||||
|                     "Expression policy returned None", | ||||
|  | ||||
| @ -3,7 +3,7 @@ from django.core.exceptions import ValidationError | ||||
| from django.test import TestCase | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
|  | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.policies.exceptions import PolicyException | ||||
| from authentik.policies.expression.evaluator import PolicyEvaluator | ||||
| from authentik.policies.expression.models import ExpressionPolicy | ||||
| from authentik.policies.types import PolicyRequest | ||||
| @ -44,30 +44,8 @@ class TestEvaluator(TestCase): | ||||
|         template = ";" | ||||
|         evaluator = PolicyEvaluator("test") | ||||
|         evaluator.set_policy_request(self.request) | ||||
|         result = evaluator.evaluate(template) | ||||
|         self.assertEqual(result.passing, False) | ||||
|         self.assertEqual(result.messages, ("invalid syntax (test, line 3)",)) | ||||
|         self.assertTrue( | ||||
|             Event.objects.filter( | ||||
|                 action=EventAction.POLICY_EXCEPTION, | ||||
|                 context__expression=template, | ||||
|             ).exists() | ||||
|         ) | ||||
|  | ||||
|     def test_undefined(self): | ||||
|         """test undefined result""" | ||||
|         template = "{{ foo.bar }}" | ||||
|         evaluator = PolicyEvaluator("test") | ||||
|         evaluator.set_policy_request(self.request) | ||||
|         result = evaluator.evaluate(template) | ||||
|         self.assertEqual(result.passing, False) | ||||
|         self.assertEqual(result.messages, ("name 'foo' is not defined",)) | ||||
|         self.assertTrue( | ||||
|             Event.objects.filter( | ||||
|                 action=EventAction.POLICY_EXCEPTION, | ||||
|                 context__expression=template, | ||||
|             ).exists() | ||||
|         ) | ||||
|         with self.assertRaises(PolicyException): | ||||
|             evaluator.evaluate(template) | ||||
|  | ||||
|     def test_validate(self): | ||||
|         """test validate""" | ||||
|  | ||||
| @ -7,7 +7,7 @@ from django.forms import ModelForm | ||||
| from django.utils.translation import gettext as _ | ||||
| from requests import get | ||||
| from rest_framework.serializers import BaseSerializer | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.policies.models import Policy, PolicyResult | ||||
| from authentik.policies.types import PolicyRequest | ||||
|  | ||||
| @ -64,7 +64,10 @@ class PolicyBinding(SerializerModel): | ||||
|         return PolicyBindingSerializer | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"Policy Binding {self.target} #{self.order} {self.policy}" | ||||
|         try: | ||||
|             return f"Policy Binding {self.target} #{self.order} {self.policy}" | ||||
|         except PolicyBinding.target.RelatedObjectDoesNotExist:  # pylint: disable=no-member | ||||
|             return f"Policy Binding - #{self.order} {self.policy}" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|  | ||||
| @ -6,7 +6,7 @@ from django.db import models | ||||
| from django.forms import ModelForm | ||||
| from django.utils.translation import gettext as _ | ||||
| from rest_framework.serializers import BaseSerializer | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.policies.models import Policy | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
|  | ||||
| @ -1,12 +1,13 @@ | ||||
| """authentik policy task""" | ||||
| from multiprocessing import Process | ||||
| from multiprocessing.connection import Connection | ||||
| from traceback import format_tb | ||||
| from typing import Optional | ||||
|  | ||||
| from django.core.cache import cache | ||||
| from sentry_sdk.hub import Hub | ||||
| from sentry_sdk.tracing import Span | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.policies.exceptions import PolicyException | ||||
| @ -14,12 +15,13 @@ from authentik.policies.models import PolicyBinding | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| TRACEBACK_HEADER = "Traceback (most recent call last):\n" | ||||
|  | ||||
|  | ||||
| def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str: | ||||
|     """Generate Cache key for policy""" | ||||
|     prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}" | ||||
|     if request.http_request: | ||||
|     if request.http_request and hasattr(request.http_request, "session"): | ||||
|         prefix += f"_{request.http_request.session.session_key}" | ||||
|     if request.user: | ||||
|         prefix += f"#{request.user.pk}" | ||||
| @ -47,6 +49,24 @@ class PolicyProcess(Process): | ||||
|         if connection: | ||||
|             self.connection = connection | ||||
|  | ||||
|     def create_event(self, action: str, message: str, **kwargs): | ||||
|         """Create event with common values from `self.request` and `self.binding`.""" | ||||
|         # Keep a reference to http_request even if its None, because cleanse_dict will remove it | ||||
|         http_request = self.request.http_request | ||||
|         event = Event.new( | ||||
|             action=action, | ||||
|             message=message, | ||||
|             policy_uuid=self.binding.policy.policy_uuid.hex, | ||||
|             binding=self.binding, | ||||
|             request=self.request, | ||||
|             **kwargs, | ||||
|         ) | ||||
|         event.set_user(self.request.user) | ||||
|         if http_request: | ||||
|             event.from_http(http_request) | ||||
|         else: | ||||
|             event.save() | ||||
|  | ||||
|     def execute(self) -> PolicyResult: | ||||
|         """Run actual policy, returns result""" | ||||
|         LOGGER.debug( | ||||
| @ -58,16 +78,23 @@ class PolicyProcess(Process): | ||||
|         try: | ||||
|             policy_result = self.binding.policy.passes(self.request) | ||||
|             if self.binding.policy.execution_logging: | ||||
|                 event = Event.new( | ||||
|                 self.create_event( | ||||
|                     EventAction.POLICY_EXECUTION, | ||||
|                     request=self.request, | ||||
|                     message="Policy Execution", | ||||
|                     result=policy_result, | ||||
|                 ) | ||||
|                 event.set_user(self.request.user) | ||||
|                 event.save() | ||||
|         except PolicyException as exc: | ||||
|             LOGGER.debug("P_ENG(proc): error", exc=exc) | ||||
|             policy_result = PolicyResult(False, str(exc)) | ||||
|             # Either use passed original exception or whatever we have | ||||
|             src_exc = exc.src_exc if exc.src_exc else exc | ||||
|             error_string = ( | ||||
|                 TRACEBACK_HEADER | ||||
|                 + "".join(format_tb(src_exc.__traceback__)) | ||||
|                 + str(src_exc) | ||||
|             ) | ||||
|             # Create policy exception event | ||||
|             self.create_event(EventAction.POLICY_EXCEPTION, message=error_string) | ||||
|             LOGGER.debug("P_ENG(proc): error", exc=src_exc) | ||||
|             policy_result = PolicyResult(False, str(src_exc)) | ||||
|         policy_result.source_policy = self.binding.policy | ||||
|         # Invert result if policy.negate is set | ||||
|         if self.binding.negate: | ||||
| @ -93,4 +120,8 @@ class PolicyProcess(Process): | ||||
|             span: Span | ||||
|             span.set_data("policy", self.binding.policy) | ||||
|             span.set_data("request", self.request) | ||||
|             self.connection.send(self.execute()) | ||||
|             try: | ||||
|                 self.connection.send(self.execute()) | ||||
|             except Exception as exc:  # pylint: disable=broad-except | ||||
|                 LOGGER.warning(str(exc)) | ||||
|                 self.connection.send(PolicyResult(False, str(exc))) | ||||
|  | ||||
| @ -3,7 +3,7 @@ from django.contrib.auth.signals import user_logged_in, user_login_failed | ||||
| from django.core.cache import cache | ||||
| from django.dispatch import receiver | ||||
| from django.http import HttpRequest | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.lib.utils.http import get_client_ip | ||||
| from authentik.policies.reputation.models import ( | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| """Reputation tasks""" | ||||
| from django.core.cache import cache | ||||
| from structlog import get_logger | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	