Compare commits
	
		
			199 Commits
		
	
	
		
			version/0.
			...
			version/0.
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7ac870bfe5 | |||
| 101b973cfe | |||
| d8dc1f8bb5 | |||
| 0f4d5bc3b0 | |||
| 6eed549577 | |||
| be54ba4fe2 | |||
| 68b9c34f78 | |||
| 3584bdf530 | |||
| e712719333 | |||
| 9a21c2f6bd | |||
| 0632d8ff37 | |||
| 6bfaf71c12 | |||
| b6c8c319e5 | |||
| 4fde1b7365 | |||
| 412f5b9210 | |||
| a9e53cd52a | |||
| d0ee7908ab | |||
| e69834dec4 | |||
| 1b9d22615c | |||
| e995536a15 | |||
| e6818faab1 | |||
| 010e834149 | |||
| 16d5e1d9ff | |||
| a1bd6bfe17 | |||
| ebe0f84460 | |||
| 765ae80698 | |||
| bbd0ff24d8 | |||
| 7a403613b2 | |||
| 4ad184a3fb | |||
| 48d5f28e7a | |||
| 0cb48121b2 | |||
| 4194ffe2d4 | |||
| 4636fe7e64 | |||
| 182d714b16 | |||
| 540c22ce15 | |||
| 8c3008abce | |||
| 8a22c86aaa | |||
| c8805cc082 | |||
| db92178d0f | |||
| 22ce142cb8 | |||
| 1a292feebb | |||
| 09f4d812b3 | |||
| ad029d3e0a | |||
| b0bd68232d | |||
| 2bab4ebfe8 | |||
| 65355372ce | |||
| a8647caca9 | |||
| 590597caf6 | |||
| 7b43777b22 | |||
| 77861b52e3 | |||
| 5f9c1e229c | |||
| 119adb3e7b | |||
| 5db38bd0b7 | |||
| 0e1587bc1a | |||
| dc16a8a4c9 | |||
| 53d9092022 | |||
| a6d0c8c26c | |||
| 5797a3743a | |||
| b7e43efb34 | |||
| 48df12d045 | |||
| 4fea0f5939 | |||
| a7bdd63e4d | |||
| e216efb6ec | |||
| 378fe38b12 | |||
| ce9fb8801c | |||
| 67ca83c228 | |||
| ee2e737782 | |||
| b04c9a2098 | |||
| 7f7b7e37c1 | |||
| e7c96eb70d | |||
| e8debce9c8 | |||
| bcd0686a33 | |||
| 55322995a1 | |||
| dff5eb69c8 | |||
| b747022bc1 | |||
| 885fcff495 | |||
| 5b18e28753 | |||
| 9848c5f3eb | |||
| fc98c3934a | |||
| 7964061466 | |||
| 5f90f54195 | |||
| 49eb568d3c | |||
| d47d9103c7 | |||
| 12cbe464fc | |||
| d17b2b0d1b | |||
| f17d809219 | |||
| 6c8e9fb553 | |||
| 43bb29e16a | |||
| 29edbb0357 | |||
| 12ae867759 | |||
| a20ca9136b | |||
| 3759e96e7d | |||
| 480d882a82 | |||
| e5e1e3737d | |||
| 8dddcf891e | |||
| 319104c39b | |||
| a9336f069c | |||
| 33f5169f36 | |||
| 4c690a20ef | |||
| f68c8f7d90 | |||
| 95b56a0005 | |||
| 811c569b54 | |||
| 3ac3a8eebe | |||
| 6a5a243dac | |||
| 3549a9ecdd | |||
| ee916a68a4 | |||
| e9ca42cbb9 | |||
| 692d577217 | |||
| f192ee5052 | |||
| c95f8e8418 | |||
| 9549a7188b | |||
| 4998ccbe41 | |||
| a56ddb2b8e | |||
| 3cc6b8ee38 | |||
| 927ab509a1 | |||
| c85506f43c | |||
| 4157a0780d | |||
| 79da2bf698 | |||
| c3e9168b46 | |||
| d16838bbed | |||
| 6032efb67d | |||
| 322c6f01c2 | |||
| 71a58955f2 | |||
| f035da440a | |||
| 001de38d85 | |||
| 3ea39fe122 | |||
| 7bfa217cae | |||
| fdb9b45c51 | |||
| 116375084c | |||
| 1fca1df9dc | |||
| 4464ecc060 | |||
| 1af4373d97 | |||
| 28bbf5ac7f | |||
| 23f61e6b4f | |||
| db135a6dbc | |||
| a4dc6d13b5 | |||
| 4d88dcff08 | |||
| 6a835ad192 | |||
| efc849e760 | |||
| e62333dfb3 | |||
| e23afd18e4 | |||
| c2a30b760a | |||
| 6e24856d45 | |||
| 98a58b74e3 | |||
| 5f3ab22bea | |||
| 1ed5d5da35 | |||
| 76193e0031 | |||
| 50109ca7ad | |||
| e4b66d991c | |||
| 68adc2d5a5 | |||
| 349a3a67d5 | |||
| e1394207e7 | |||
| f265c1f10b | |||
| 1aecdc7f8f | |||
| a18edaf62b | |||
| c91abe448c | |||
| e531e52403 | |||
| cae536fa65 | |||
| 316b15b8a9 | |||
| e6ccd4fa76 | |||
| 86aabba3ed | |||
| 0b36aad5c8 | |||
| 64d2a216f0 | |||
| a5e5e140d6 | |||
| 29f98abd00 | |||
| 7b5ce4e98a | |||
| d7fa52ebf3 | |||
| 2ffaa94825 | |||
| b80b2626a6 | |||
| 3b7bba5a62 | |||
| 2d9efe035e | |||
| 48438e28fd | |||
| 885a2f0a58 | |||
| cf46ee06b7 | |||
| 9e33b49d29 | |||
| 1179ba4ef2 | |||
| 3c12c8b3ff | |||
| 4d22659b6e | |||
| 2c0709eeee | |||
| c24d1b6b84 | |||
| 040e148a73 | |||
| b85d550ee0 | |||
| ce95139d66 | |||
| 46436a5780 | |||
| 835a9aaaf2 | |||
| 42005e7def | |||
| d9956e1e9c | |||
| 4b1e73251a | |||
| 736dbdca33 | |||
| 789b8e5d3e | |||
| 074b55f66b | |||
| d9bc5ea4d1 | |||
| 716bb9f188 | |||
| dd496619a2 | |||
| 51d07f7913 | |||
| 5c4163579b | |||
| 5a73413d58 | |||
| 51a5d4bf49 | |||
| 8bbb854073 | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 0.13.0-stable | current_version = 0.14.2-stable | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @ -18,11 +18,11 @@ jobs: | |||||||
|       - name: Building Docker Image |       - name: Building Docker Image | ||||||
|         run: docker build |         run: docker build | ||||||
|           --no-cache |           --no-cache | ||||||
|           -t beryju/authentik:0.13.0-stable |           -t beryju/authentik:0.14.2-stable | ||||||
|           -t beryju/authentik:latest |           -t beryju/authentik:latest | ||||||
|           -f Dockerfile . |           -f Dockerfile . | ||||||
|       - name: Push Docker Container to Registry (versioned) |       - name: Push Docker Container to Registry (versioned) | ||||||
|         run: docker push beryju/authentik:0.13.0-stable |         run: docker push beryju/authentik:0.14.2-stable | ||||||
|       - name: Push Docker Container to Registry (latest) |       - name: Push Docker Container to Registry (latest) | ||||||
|         run: docker push beryju/authentik:latest |         run: docker push beryju/authentik:latest | ||||||
|   build-proxy: |   build-proxy: | ||||||
| @ -48,11 +48,11 @@ jobs: | |||||||
|           cd proxy/ |           cd proxy/ | ||||||
|           docker build \ |           docker build \ | ||||||
|           --no-cache \ |           --no-cache \ | ||||||
|           -t beryju/authentik-proxy:0.13.0-stable \ |           -t beryju/authentik-proxy:0.14.2-stable \ | ||||||
|           -t beryju/authentik-proxy:latest \ |           -t beryju/authentik-proxy:latest \ | ||||||
|           -f Dockerfile . |           -f Dockerfile . | ||||||
|       - name: Push Docker Container to Registry (versioned) |       - name: Push Docker Container to Registry (versioned) | ||||||
|         run: docker push beryju/authentik-proxy:0.13.0-stable |         run: docker push beryju/authentik-proxy:0.14.2-stable | ||||||
|       - name: Push Docker Container to Registry (latest) |       - name: Push Docker Container to Registry (latest) | ||||||
|         run: docker push beryju/authentik-proxy:latest |         run: docker push beryju/authentik-proxy:latest | ||||||
|   build-static: |   build-static: | ||||||
| @ -69,11 +69,11 @@ jobs: | |||||||
|           cd web/ |           cd web/ | ||||||
|           docker build \ |           docker build \ | ||||||
|           --no-cache \ |           --no-cache \ | ||||||
|           -t beryju/authentik-static:0.13.0-stable \ |           -t beryju/authentik-static:0.14.2-stable \ | ||||||
|           -t beryju/authentik-static:latest \ |           -t beryju/authentik-static:latest \ | ||||||
|           -f Dockerfile . |           -f Dockerfile . | ||||||
|       - name: Push Docker Container to Registry (versioned) |       - name: Push Docker Container to Registry (versioned) | ||||||
|         run: docker push beryju/authentik-static:0.13.0-stable |         run: docker push beryju/authentik-static:0.14.2-stable | ||||||
|       - name: Push Docker Container to Registry (latest) |       - name: Push Docker Container to Registry (latest) | ||||||
|         run: docker push beryju/authentik-static:latest |         run: docker push beryju/authentik-static:latest | ||||||
|   test-release: |   test-release: | ||||||
| @ -107,5 +107,5 @@ jobs: | |||||||
|           SENTRY_PROJECT: authentik |           SENTRY_PROJECT: authentik | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           tagName: 0.13.0-stable |           tagName: 0.14.2-stable | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|  | |||||||
							
								
								
									
										687
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										687
									
								
								LICENSE
									
									
									
									
									
								
							| @ -1,21 +1,674 @@ | |||||||
| MIT License |                     GNU GENERAL PUBLIC LICENSE | ||||||
|  |                        Version 3, 29 June 2007 | ||||||
|  |  | ||||||
| Copyright (c) 2019 BeryJu.org |  Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> | ||||||
|  |  Everyone is permitted to copy and distribute verbatim copies | ||||||
|  |  of this license document, but changing it is not allowed. | ||||||
|  |  | ||||||
| Permission is hereby granted, free of charge, to any person obtaining a copy |                             Preamble | ||||||
| of this software and associated documentation files (the "Software"), to deal |  | ||||||
| in the Software without restriction, including without limitation the rights |  | ||||||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |  | ||||||
| copies of the Software, and to permit persons to whom the Software is |  | ||||||
| furnished to do so, subject to the following conditions: |  | ||||||
|  |  | ||||||
| The above copyright notice and this permission notice shall be included in all |   The GNU General Public License is a free, copyleft license for | ||||||
| copies or substantial portions of the Software. | software and other kinds of works. | ||||||
|  |  | ||||||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |   The licenses for most software and other practical works are designed | ||||||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | to take away your freedom to share and change the works.  By contrast, | ||||||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | the GNU General Public License is intended to guarantee your freedom to | ||||||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | share and change all versions of a program--to make sure it remains free | ||||||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | software for all its users.  We, the Free Software Foundation, use the | ||||||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | GNU General Public License for most of our software; it applies also to | ||||||
| SOFTWARE. | any other work released this way by its authors.  You can apply it to | ||||||
|  | your programs, too. | ||||||
|  |  | ||||||
|  |   When we speak of free software, we are referring to freedom, not | ||||||
|  | price.  Our General Public Licenses are designed to make sure that you | ||||||
|  | have the freedom to distribute copies of free software (and charge for | ||||||
|  | them if you wish), that you receive source code or can get it if you | ||||||
|  | want it, that you can change the software or use pieces of it in new | ||||||
|  | free programs, and that you know you can do these things. | ||||||
|  |  | ||||||
|  |   To protect your rights, we need to prevent others from denying you | ||||||
|  | these rights or asking you to surrender the rights.  Therefore, you have | ||||||
|  | certain responsibilities if you distribute copies of the software, or if | ||||||
|  | you modify it: responsibilities to respect the freedom of others. | ||||||
|  |  | ||||||
|  |   For example, if you distribute copies of such a program, whether | ||||||
|  | gratis or for a fee, you must pass on to the recipients the same | ||||||
|  | freedoms that you received.  You must make sure that they, too, receive | ||||||
|  | or can get the source code.  And you must show them these terms so they | ||||||
|  | know their rights. | ||||||
|  |  | ||||||
|  |   Developers that use the GNU GPL protect your rights with two steps: | ||||||
|  | (1) assert copyright on the software, and (2) offer you this License | ||||||
|  | giving you legal permission to copy, distribute and/or modify it. | ||||||
|  |  | ||||||
|  |   For the developers' and authors' protection, the GPL clearly explains | ||||||
|  | that there is no warranty for this free software.  For both users' and | ||||||
|  | authors' sake, the GPL requires that modified versions be marked as | ||||||
|  | changed, so that their problems will not be attributed erroneously to | ||||||
|  | authors of previous versions. | ||||||
|  |  | ||||||
|  |   Some devices are designed to deny users access to install or run | ||||||
|  | modified versions of the software inside them, although the manufacturer | ||||||
|  | can do so.  This is fundamentally incompatible with the aim of | ||||||
|  | protecting users' freedom to change the software.  The systematic | ||||||
|  | pattern of such abuse occurs in the area of products for individuals to | ||||||
|  | use, which is precisely where it is most unacceptable.  Therefore, we | ||||||
|  | have designed this version of the GPL to prohibit the practice for those | ||||||
|  | products.  If such problems arise substantially in other domains, we | ||||||
|  | stand ready to extend this provision to those domains in future versions | ||||||
|  | of the GPL, as needed to protect the freedom of users. | ||||||
|  |  | ||||||
|  |   Finally, every program is threatened constantly by software patents. | ||||||
|  | States should not allow patents to restrict development and use of | ||||||
|  | software on general-purpose computers, but in those that do, we wish to | ||||||
|  | avoid the special danger that patents applied to a free program could | ||||||
|  | make it effectively proprietary.  To prevent this, the GPL assures that | ||||||
|  | patents cannot be used to render the program non-free. | ||||||
|  |  | ||||||
|  |   The precise terms and conditions for copying, distribution and | ||||||
|  | modification follow. | ||||||
|  |  | ||||||
|  |                        TERMS AND CONDITIONS | ||||||
|  |  | ||||||
|  |   0. Definitions. | ||||||
|  |  | ||||||
|  |   "This License" refers to version 3 of the GNU General Public License. | ||||||
|  |  | ||||||
|  |   "Copyright" also means copyright-like laws that apply to other kinds of | ||||||
|  | works, such as semiconductor masks. | ||||||
|  |  | ||||||
|  |   "The Program" refers to any copyrightable work licensed under this | ||||||
|  | License.  Each licensee is addressed as "you".  "Licensees" and | ||||||
|  | "recipients" may be individuals or organizations. | ||||||
|  |  | ||||||
|  |   To "modify" a work means to copy from or adapt all or part of the work | ||||||
|  | in a fashion requiring copyright permission, other than the making of an | ||||||
|  | exact copy.  The resulting work is called a "modified version" of the | ||||||
|  | earlier work or a work "based on" the earlier work. | ||||||
|  |  | ||||||
|  |   A "covered work" means either the unmodified Program or a work based | ||||||
|  | on the Program. | ||||||
|  |  | ||||||
|  |   To "propagate" a work means to do anything with it that, without | ||||||
|  | permission, would make you directly or secondarily liable for | ||||||
|  | infringement under applicable copyright law, except executing it on a | ||||||
|  | computer or modifying a private copy.  Propagation includes copying, | ||||||
|  | distribution (with or without modification), making available to the | ||||||
|  | public, and in some countries other activities as well. | ||||||
|  |  | ||||||
|  |   To "convey" a work means any kind of propagation that enables other | ||||||
|  | parties to make or receive copies.  Mere interaction with a user through | ||||||
|  | a computer network, with no transfer of a copy, is not conveying. | ||||||
|  |  | ||||||
|  |   An interactive user interface displays "Appropriate Legal Notices" | ||||||
|  | to the extent that it includes a convenient and prominently visible | ||||||
|  | feature that (1) displays an appropriate copyright notice, and (2) | ||||||
|  | tells the user that there is no warranty for the work (except to the | ||||||
|  | extent that warranties are provided), that licensees may convey the | ||||||
|  | work under this License, and how to view a copy of this License.  If | ||||||
|  | the interface presents a list of user commands or options, such as a | ||||||
|  | menu, a prominent item in the list meets this criterion. | ||||||
|  |  | ||||||
|  |   1. Source Code. | ||||||
|  |  | ||||||
|  |   The "source code" for a work means the preferred form of the work | ||||||
|  | for making modifications to it.  "Object code" means any non-source | ||||||
|  | form of a work. | ||||||
|  |  | ||||||
|  |   A "Standard Interface" means an interface that either is an official | ||||||
|  | standard defined by a recognized standards body, or, in the case of | ||||||
|  | interfaces specified for a particular programming language, one that | ||||||
|  | is widely used among developers working in that language. | ||||||
|  |  | ||||||
|  |   The "System Libraries" of an executable work include anything, other | ||||||
|  | than the work as a whole, that (a) is included in the normal form of | ||||||
|  | packaging a Major Component, but which is not part of that Major | ||||||
|  | Component, and (b) serves only to enable use of the work with that | ||||||
|  | Major Component, or to implement a Standard Interface for which an | ||||||
|  | implementation is available to the public in source code form.  A | ||||||
|  | "Major Component", in this context, means a major essential component | ||||||
|  | (kernel, window system, and so on) of the specific operating system | ||||||
|  | (if any) on which the executable work runs, or a compiler used to | ||||||
|  | produce the work, or an object code interpreter used to run it. | ||||||
|  |  | ||||||
|  |   The "Corresponding Source" for a work in object code form means all | ||||||
|  | the source code needed to generate, install, and (for an executable | ||||||
|  | work) run the object code and to modify the work, including scripts to | ||||||
|  | control those activities.  However, it does not include the work's | ||||||
|  | System Libraries, or general-purpose tools or generally available free | ||||||
|  | programs which are used unmodified in performing those activities but | ||||||
|  | which are not part of the work.  For example, Corresponding Source | ||||||
|  | includes interface definition files associated with source files for | ||||||
|  | the work, and the source code for shared libraries and dynamically | ||||||
|  | linked subprograms that the work is specifically designed to require, | ||||||
|  | such as by intimate data communication or control flow between those | ||||||
|  | subprograms and other parts of the work. | ||||||
|  |  | ||||||
|  |   The Corresponding Source need not include anything that users | ||||||
|  | can regenerate automatically from other parts of the Corresponding | ||||||
|  | Source. | ||||||
|  |  | ||||||
|  |   The Corresponding Source for a work in source code form is that | ||||||
|  | same work. | ||||||
|  |  | ||||||
|  |   2. Basic Permissions. | ||||||
|  |  | ||||||
|  |   All rights granted under this License are granted for the term of | ||||||
|  | copyright on the Program, and are irrevocable provided the stated | ||||||
|  | conditions are met.  This License explicitly affirms your unlimited | ||||||
|  | permission to run the unmodified Program.  The output from running a | ||||||
|  | covered work is covered by this License only if the output, given its | ||||||
|  | content, constitutes a covered work.  This License acknowledges your | ||||||
|  | rights of fair use or other equivalent, as provided by copyright law. | ||||||
|  |  | ||||||
|  |   You may make, run and propagate covered works that you do not | ||||||
|  | convey, without conditions so long as your license otherwise remains | ||||||
|  | in force.  You may convey covered works to others for the sole purpose | ||||||
|  | of having them make modifications exclusively for you, or provide you | ||||||
|  | with facilities for running those works, provided that you comply with | ||||||
|  | the terms of this License in conveying all material for which you do | ||||||
|  | not control copyright.  Those thus making or running the covered works | ||||||
|  | for you must do so exclusively on your behalf, under your direction | ||||||
|  | and control, on terms that prohibit them from making any copies of | ||||||
|  | your copyrighted material outside their relationship with you. | ||||||
|  |  | ||||||
|  |   Conveying under any other circumstances is permitted solely under | ||||||
|  | the conditions stated below.  Sublicensing is not allowed; section 10 | ||||||
|  | makes it unnecessary. | ||||||
|  |  | ||||||
|  |   3. Protecting Users' Legal Rights From Anti-Circumvention Law. | ||||||
|  |  | ||||||
|  |   No covered work shall be deemed part of an effective technological | ||||||
|  | measure under any applicable law fulfilling obligations under article | ||||||
|  | 11 of the WIPO copyright treaty adopted on 20 December 1996, or | ||||||
|  | similar laws prohibiting or restricting circumvention of such | ||||||
|  | measures. | ||||||
|  |  | ||||||
|  |   When you convey a covered work, you waive any legal power to forbid | ||||||
|  | circumvention of technological measures to the extent such circumvention | ||||||
|  | is effected by exercising rights under this License with respect to | ||||||
|  | the covered work, and you disclaim any intention to limit operation or | ||||||
|  | modification of the work as a means of enforcing, against the work's | ||||||
|  | users, your or third parties' legal rights to forbid circumvention of | ||||||
|  | technological measures. | ||||||
|  |  | ||||||
|  |   4. Conveying Verbatim Copies. | ||||||
|  |  | ||||||
|  |   You may convey verbatim copies of the Program's source code as you | ||||||
|  | receive it, in any medium, provided that you conspicuously and | ||||||
|  | appropriately publish on each copy an appropriate copyright notice; | ||||||
|  | keep intact all notices stating that this License and any | ||||||
|  | non-permissive terms added in accord with section 7 apply to the code; | ||||||
|  | keep intact all notices of the absence of any warranty; and give all | ||||||
|  | recipients a copy of this License along with the Program. | ||||||
|  |  | ||||||
|  |   You may charge any price or no price for each copy that you convey, | ||||||
|  | and you may offer support or warranty protection for a fee. | ||||||
|  |  | ||||||
|  |   5. Conveying Modified Source Versions. | ||||||
|  |  | ||||||
|  |   You may convey a work based on the Program, or the modifications to | ||||||
|  | produce it from the Program, in the form of source code under the | ||||||
|  | terms of section 4, provided that you also meet all of these conditions: | ||||||
|  |  | ||||||
|  |     a) The work must carry prominent notices stating that you modified | ||||||
|  |     it, and giving a relevant date. | ||||||
|  |  | ||||||
|  |     b) The work must carry prominent notices stating that it is | ||||||
|  |     released under this License and any conditions added under section | ||||||
|  |     7.  This requirement modifies the requirement in section 4 to | ||||||
|  |     "keep intact all notices". | ||||||
|  |  | ||||||
|  |     c) You must license the entire work, as a whole, under this | ||||||
|  |     License to anyone who comes into possession of a copy.  This | ||||||
|  |     License will therefore apply, along with any applicable section 7 | ||||||
|  |     additional terms, to the whole of the work, and all its parts, | ||||||
|  |     regardless of how they are packaged.  This License gives no | ||||||
|  |     permission to license the work in any other way, but it does not | ||||||
|  |     invalidate such permission if you have separately received it. | ||||||
|  |  | ||||||
|  |     d) If the work has interactive user interfaces, each must display | ||||||
|  |     Appropriate Legal Notices; however, if the Program has interactive | ||||||
|  |     interfaces that do not display Appropriate Legal Notices, your | ||||||
|  |     work need not make them do so. | ||||||
|  |  | ||||||
|  |   A compilation of a covered work with other separate and independent | ||||||
|  | works, which are not by their nature extensions of the covered work, | ||||||
|  | and which are not combined with it such as to form a larger program, | ||||||
|  | in or on a volume of a storage or distribution medium, is called an | ||||||
|  | "aggregate" if the compilation and its resulting copyright are not | ||||||
|  | used to limit the access or legal rights of the compilation's users | ||||||
|  | beyond what the individual works permit.  Inclusion of a covered work | ||||||
|  | in an aggregate does not cause this License to apply to the other | ||||||
|  | parts of the aggregate. | ||||||
|  |  | ||||||
|  |   6. Conveying Non-Source Forms. | ||||||
|  |  | ||||||
|  |   You may convey a covered work in object code form under the terms | ||||||
|  | of sections 4 and 5, provided that you also convey the | ||||||
|  | machine-readable Corresponding Source under the terms of this License, | ||||||
|  | in one of these ways: | ||||||
|  |  | ||||||
|  |     a) Convey the object code in, or embodied in, a physical product | ||||||
|  |     (including a physical distribution medium), accompanied by the | ||||||
|  |     Corresponding Source fixed on a durable physical medium | ||||||
|  |     customarily used for software interchange. | ||||||
|  |  | ||||||
|  |     b) Convey the object code in, or embodied in, a physical product | ||||||
|  |     (including a physical distribution medium), accompanied by a | ||||||
|  |     written offer, valid for at least three years and valid for as | ||||||
|  |     long as you offer spare parts or customer support for that product | ||||||
|  |     model, to give anyone who possesses the object code either (1) a | ||||||
|  |     copy of the Corresponding Source for all the software in the | ||||||
|  |     product that is covered by this License, on a durable physical | ||||||
|  |     medium customarily used for software interchange, for a price no | ||||||
|  |     more than your reasonable cost of physically performing this | ||||||
|  |     conveying of source, or (2) access to copy the | ||||||
|  |     Corresponding Source from a network server at no charge. | ||||||
|  |  | ||||||
|  |     c) Convey individual copies of the object code with a copy of the | ||||||
|  |     written offer to provide the Corresponding Source.  This | ||||||
|  |     alternative is allowed only occasionally and noncommercially, and | ||||||
|  |     only if you received the object code with such an offer, in accord | ||||||
|  |     with subsection 6b. | ||||||
|  |  | ||||||
|  |     d) Convey the object code by offering access from a designated | ||||||
|  |     place (gratis or for a charge), and offer equivalent access to the | ||||||
|  |     Corresponding Source in the same way through the same place at no | ||||||
|  |     further charge.  You need not require recipients to copy the | ||||||
|  |     Corresponding Source along with the object code.  If the place to | ||||||
|  |     copy the object code is a network server, the Corresponding Source | ||||||
|  |     may be on a different server (operated by you or a third party) | ||||||
|  |     that supports equivalent copying facilities, provided you maintain | ||||||
|  |     clear directions next to the object code saying where to find the | ||||||
|  |     Corresponding Source.  Regardless of what server hosts the | ||||||
|  |     Corresponding Source, you remain obligated to ensure that it is | ||||||
|  |     available for as long as needed to satisfy these requirements. | ||||||
|  |  | ||||||
|  |     e) Convey the object code using peer-to-peer transmission, provided | ||||||
|  |     you inform other peers where the object code and Corresponding | ||||||
|  |     Source of the work are being offered to the general public at no | ||||||
|  |     charge under subsection 6d. | ||||||
|  |  | ||||||
|  |   A separable portion of the object code, whose source code is excluded | ||||||
|  | from the Corresponding Source as a System Library, need not be | ||||||
|  | included in conveying the object code work. | ||||||
|  |  | ||||||
|  |   A "User Product" is either (1) a "consumer product", which means any | ||||||
|  | tangible personal property which is normally used for personal, family, | ||||||
|  | or household purposes, or (2) anything designed or sold for incorporation | ||||||
|  | into a dwelling.  In determining whether a product is a consumer product, | ||||||
|  | doubtful cases shall be resolved in favor of coverage.  For a particular | ||||||
|  | product received by a particular user, "normally used" refers to a | ||||||
|  | typical or common use of that class of product, regardless of the status | ||||||
|  | of the particular user or of the way in which the particular user | ||||||
|  | actually uses, or expects or is expected to use, the product.  A product | ||||||
|  | is a consumer product regardless of whether the product has substantial | ||||||
|  | commercial, industrial or non-consumer uses, unless such uses represent | ||||||
|  | the only significant mode of use of the product. | ||||||
|  |  | ||||||
|  |   "Installation Information" for a User Product means any methods, | ||||||
|  | procedures, authorization keys, or other information required to install | ||||||
|  | and execute modified versions of a covered work in that User Product from | ||||||
|  | a modified version of its Corresponding Source.  The information must | ||||||
|  | suffice to ensure that the continued functioning of the modified object | ||||||
|  | code is in no case prevented or interfered with solely because | ||||||
|  | modification has been made. | ||||||
|  |  | ||||||
|  |   If you convey an object code work under this section in, or with, or | ||||||
|  | specifically for use in, a User Product, and the conveying occurs as | ||||||
|  | part of a transaction in which the right of possession and use of the | ||||||
|  | User Product is transferred to the recipient in perpetuity or for a | ||||||
|  | fixed term (regardless of how the transaction is characterized), the | ||||||
|  | Corresponding Source conveyed under this section must be accompanied | ||||||
|  | by the Installation Information.  But this requirement does not apply | ||||||
|  | if neither you nor any third party retains the ability to install | ||||||
|  | modified object code on the User Product (for example, the work has | ||||||
|  | been installed in ROM). | ||||||
|  |  | ||||||
|  |   The requirement to provide Installation Information does not include a | ||||||
|  | requirement to continue to provide support service, warranty, or updates | ||||||
|  | for a work that has been modified or installed by the recipient, or for | ||||||
|  | the User Product in which it has been modified or installed.  Access to a | ||||||
|  | network may be denied when the modification itself materially and | ||||||
|  | adversely affects the operation of the network or violates the rules and | ||||||
|  | protocols for communication across the network. | ||||||
|  |  | ||||||
|  |   Corresponding Source conveyed, and Installation Information provided, | ||||||
|  | in accord with this section must be in a format that is publicly | ||||||
|  | documented (and with an implementation available to the public in | ||||||
|  | source code form), and must require no special password or key for | ||||||
|  | unpacking, reading or copying. | ||||||
|  |  | ||||||
|  |   7. Additional Terms. | ||||||
|  |  | ||||||
|  |   "Additional permissions" are terms that supplement the terms of this | ||||||
|  | License by making exceptions from one or more of its conditions. | ||||||
|  | Additional permissions that are applicable to the entire Program shall | ||||||
|  | be treated as though they were included in this License, to the extent | ||||||
|  | that they are valid under applicable law.  If additional permissions | ||||||
|  | apply only to part of the Program, that part may be used separately | ||||||
|  | under those permissions, but the entire Program remains governed by | ||||||
|  | this License without regard to the additional permissions. | ||||||
|  |  | ||||||
|  |   When you convey a copy of a covered work, you may at your option | ||||||
|  | remove any additional permissions from that copy, or from any part of | ||||||
|  | it.  (Additional permissions may be written to require their own | ||||||
|  | removal in certain cases when you modify the work.)  You may place | ||||||
|  | additional permissions on material, added by you to a covered work, | ||||||
|  | for which you have or can give appropriate copyright permission. | ||||||
|  |  | ||||||
|  |   Notwithstanding any other provision of this License, for material you | ||||||
|  | add to a covered work, you may (if authorized by the copyright holders of | ||||||
|  | that material) supplement the terms of this License with terms: | ||||||
|  |  | ||||||
|  |     a) Disclaiming warranty or limiting liability differently from the | ||||||
|  |     terms of sections 15 and 16 of this License; or | ||||||
|  |  | ||||||
|  |     b) Requiring preservation of specified reasonable legal notices or | ||||||
|  |     author attributions in that material or in the Appropriate Legal | ||||||
|  |     Notices displayed by works containing it; or | ||||||
|  |  | ||||||
|  |     c) Prohibiting misrepresentation of the origin of that material, or | ||||||
|  |     requiring that modified versions of such material be marked in | ||||||
|  |     reasonable ways as different from the original version; or | ||||||
|  |  | ||||||
|  |     d) Limiting the use for publicity purposes of names of licensors or | ||||||
|  |     authors of the material; or | ||||||
|  |  | ||||||
|  |     e) Declining to grant rights under trademark law for use of some | ||||||
|  |     trade names, trademarks, or service marks; or | ||||||
|  |  | ||||||
|  |     f) Requiring indemnification of licensors and authors of that | ||||||
|  |     material by anyone who conveys the material (or modified versions of | ||||||
|  |     it) with contractual assumptions of liability to the recipient, for | ||||||
|  |     any liability that these contractual assumptions directly impose on | ||||||
|  |     those licensors and authors. | ||||||
|  |  | ||||||
|  |   All other non-permissive additional terms are considered "further | ||||||
|  | restrictions" within the meaning of section 10.  If the Program as you | ||||||
|  | received it, or any part of it, contains a notice stating that it is | ||||||
|  | governed by this License along with a term that is a further | ||||||
|  | restriction, you may remove that term.  If a license document contains | ||||||
|  | a further restriction but permits relicensing or conveying under this | ||||||
|  | License, you may add to a covered work material governed by the terms | ||||||
|  | of that license document, provided that the further restriction does | ||||||
|  | not survive such relicensing or conveying. | ||||||
|  |  | ||||||
|  |   If you add terms to a covered work in accord with this section, you | ||||||
|  | must place, in the relevant source files, a statement of the | ||||||
|  | additional terms that apply to those files, or a notice indicating | ||||||
|  | where to find the applicable terms. | ||||||
|  |  | ||||||
|  |   Additional terms, permissive or non-permissive, may be stated in the | ||||||
|  | form of a separately written license, or stated as exceptions; | ||||||
|  | the above requirements apply either way. | ||||||
|  |  | ||||||
|  |   8. Termination. | ||||||
|  |  | ||||||
|  |   You may not propagate or modify a covered work except as expressly | ||||||
|  | provided under this License.  Any attempt otherwise to propagate or | ||||||
|  | modify it is void, and will automatically terminate your rights under | ||||||
|  | this License (including any patent licenses granted under the third | ||||||
|  | paragraph of section 11). | ||||||
|  |  | ||||||
|  |   However, if you cease all violation of this License, then your | ||||||
|  | license from a particular copyright holder is reinstated (a) | ||||||
|  | provisionally, unless and until the copyright holder explicitly and | ||||||
|  | finally terminates your license, and (b) permanently, if the copyright | ||||||
|  | holder fails to notify you of the violation by some reasonable means | ||||||
|  | prior to 60 days after the cessation. | ||||||
|  |  | ||||||
|  |   Moreover, your license from a particular copyright holder is | ||||||
|  | reinstated permanently if the copyright holder notifies you of the | ||||||
|  | violation by some reasonable means, this is the first time you have | ||||||
|  | received notice of violation of this License (for any work) from that | ||||||
|  | copyright holder, and you cure the violation prior to 30 days after | ||||||
|  | your receipt of the notice. | ||||||
|  |  | ||||||
|  |   Termination of your rights under this section does not terminate the | ||||||
|  | licenses of parties who have received copies or rights from you under | ||||||
|  | this License.  If your rights have been terminated and not permanently | ||||||
|  | reinstated, you do not qualify to receive new licenses for the same | ||||||
|  | material under section 10. | ||||||
|  |  | ||||||
|  |   9. Acceptance Not Required for Having Copies. | ||||||
|  |  | ||||||
|  |   You are not required to accept this License in order to receive or | ||||||
|  | run a copy of the Program.  Ancillary propagation of a covered work | ||||||
|  | occurring solely as a consequence of using peer-to-peer transmission | ||||||
|  | to receive a copy likewise does not require acceptance.  However, | ||||||
|  | nothing other than this License grants you permission to propagate or | ||||||
|  | modify any covered work.  These actions infringe copyright if you do | ||||||
|  | not accept this License.  Therefore, by modifying or propagating a | ||||||
|  | covered work, you indicate your acceptance of this License to do so. | ||||||
|  |  | ||||||
|  |   10. Automatic Licensing of Downstream Recipients. | ||||||
|  |  | ||||||
|  |   Each time you convey a covered work, the recipient automatically | ||||||
|  | receives a license from the original licensors, to run, modify and | ||||||
|  | propagate that work, subject to this License.  You are not responsible | ||||||
|  | for enforcing compliance by third parties with this License. | ||||||
|  |  | ||||||
|  |   An "entity transaction" is a transaction transferring control of an | ||||||
|  | organization, or substantially all assets of one, or subdividing an | ||||||
|  | organization, or merging organizations.  If propagation of a covered | ||||||
|  | work results from an entity transaction, each party to that | ||||||
|  | transaction who receives a copy of the work also receives whatever | ||||||
|  | licenses to the work the party's predecessor in interest had or could | ||||||
|  | give under the previous paragraph, plus a right to possession of the | ||||||
|  | Corresponding Source of the work from the predecessor in interest, if | ||||||
|  | the predecessor has it or can get it with reasonable efforts. | ||||||
|  |  | ||||||
|  |   You may not impose any further restrictions on the exercise of the | ||||||
|  | rights granted or affirmed under this License.  For example, you may | ||||||
|  | not impose a license fee, royalty, or other charge for exercise of | ||||||
|  | rights granted under this License, and you may not initiate litigation | ||||||
|  | (including a cross-claim or counterclaim in a lawsuit) alleging that | ||||||
|  | any patent claim is infringed by making, using, selling, offering for | ||||||
|  | sale, or importing the Program or any portion of it. | ||||||
|  |  | ||||||
|  |   11. Patents. | ||||||
|  |  | ||||||
|  |   A "contributor" is a copyright holder who authorizes use under this | ||||||
|  | License of the Program or a work on which the Program is based.  The | ||||||
|  | work thus licensed is called the contributor's "contributor version". | ||||||
|  |  | ||||||
|  |   A contributor's "essential patent claims" are all patent claims | ||||||
|  | owned or controlled by the contributor, whether already acquired or | ||||||
|  | hereafter acquired, that would be infringed by some manner, permitted | ||||||
|  | by this License, of making, using, or selling its contributor version, | ||||||
|  | but do not include claims that would be infringed only as a | ||||||
|  | consequence of further modification of the contributor version.  For | ||||||
|  | purposes of this definition, "control" includes the right to grant | ||||||
|  | patent sublicenses in a manner consistent with the requirements of | ||||||
|  | this License. | ||||||
|  |  | ||||||
|  |   Each contributor grants you a non-exclusive, worldwide, royalty-free | ||||||
|  | patent license under the contributor's essential patent claims, to | ||||||
|  | make, use, sell, offer for sale, import and otherwise run, modify and | ||||||
|  | propagate the contents of its contributor version. | ||||||
|  |  | ||||||
|  |   In the following three paragraphs, a "patent license" is any express | ||||||
|  | agreement or commitment, however denominated, not to enforce a patent | ||||||
|  | (such as an express permission to practice a patent or covenant not to | ||||||
|  | sue for patent infringement).  To "grant" such a patent license to a | ||||||
|  | party means to make such an agreement or commitment not to enforce a | ||||||
|  | patent against the party. | ||||||
|  |  | ||||||
|  |   If you convey a covered work, knowingly relying on a patent license, | ||||||
|  | and the Corresponding Source of the work is not available for anyone | ||||||
|  | to copy, free of charge and under the terms of this License, through a | ||||||
|  | publicly available network server or other readily accessible means, | ||||||
|  | then you must either (1) cause the Corresponding Source to be so | ||||||
|  | available, or (2) arrange to deprive yourself of the benefit of the | ||||||
|  | patent license for this particular work, or (3) arrange, in a manner | ||||||
|  | consistent with the requirements of this License, to extend the patent | ||||||
|  | license to downstream recipients.  "Knowingly relying" means you have | ||||||
|  | actual knowledge that, but for the patent license, your conveying the | ||||||
|  | covered work in a country, or your recipient's use of the covered work | ||||||
|  | in a country, would infringe one or more identifiable patents in that | ||||||
|  | country that you have reason to believe are valid. | ||||||
|  |  | ||||||
|  |   If, pursuant to or in connection with a single transaction or | ||||||
|  | arrangement, you convey, or propagate by procuring conveyance of, a | ||||||
|  | covered work, and grant a patent license to some of the parties | ||||||
|  | receiving the covered work authorizing them to use, propagate, modify | ||||||
|  | or convey a specific copy of the covered work, then the patent license | ||||||
|  | you grant is automatically extended to all recipients of the covered | ||||||
|  | work and works based on it. | ||||||
|  |  | ||||||
|  |   A patent license is "discriminatory" if it does not include within | ||||||
|  | the scope of its coverage, prohibits the exercise of, or is | ||||||
|  | conditioned on the non-exercise of one or more of the rights that are | ||||||
|  | specifically granted under this License.  You may not convey a covered | ||||||
|  | work if you are a party to an arrangement with a third party that is | ||||||
|  | in the business of distributing software, under which you make payment | ||||||
|  | to the third party based on the extent of your activity of conveying | ||||||
|  | the work, and under which the third party grants, to any of the | ||||||
|  | parties who would receive the covered work from you, a discriminatory | ||||||
|  | patent license (a) in connection with copies of the covered work | ||||||
|  | conveyed by you (or copies made from those copies), or (b) primarily | ||||||
|  | for and in connection with specific products or compilations that | ||||||
|  | contain the covered work, unless you entered into that arrangement, | ||||||
|  | or that patent license was granted, prior to 28 March 2007. | ||||||
|  |  | ||||||
|  |   Nothing in this License shall be construed as excluding or limiting | ||||||
|  | any implied license or other defenses to infringement that may | ||||||
|  | otherwise be available to you under applicable patent law. | ||||||
|  |  | ||||||
|  |   12. No Surrender of Others' Freedom. | ||||||
|  |  | ||||||
|  |   If conditions are imposed on you (whether by court order, agreement or | ||||||
|  | otherwise) that contradict the conditions of this License, they do not | ||||||
|  | excuse you from the conditions of this License.  If you cannot convey a | ||||||
|  | covered work so as to satisfy simultaneously your obligations under this | ||||||
|  | License and any other pertinent obligations, then as a consequence you may | ||||||
|  | not convey it at all.  For example, if you agree to terms that obligate you | ||||||
|  | to collect a royalty for further conveying from those to whom you convey | ||||||
|  | the Program, the only way you could satisfy both those terms and this | ||||||
|  | License would be to refrain entirely from conveying the Program. | ||||||
|  |  | ||||||
|  |   13. Use with the GNU Affero General Public License. | ||||||
|  |  | ||||||
|  |   Notwithstanding any other provision of this License, you have | ||||||
|  | permission to link or combine any covered work with a work licensed | ||||||
|  | under version 3 of the GNU Affero General Public License into a single | ||||||
|  | combined work, and to convey the resulting work.  The terms of this | ||||||
|  | License will continue to apply to the part which is the covered work, | ||||||
|  | but the special requirements of the GNU Affero General Public License, | ||||||
|  | section 13, concerning interaction through a network will apply to the | ||||||
|  | combination as such. | ||||||
|  |  | ||||||
|  |   14. Revised Versions of this License. | ||||||
|  |  | ||||||
|  |   The Free Software Foundation may publish revised and/or new versions of | ||||||
|  | the GNU General Public License from time to time.  Such new versions will | ||||||
|  | be similar in spirit to the present version, but may differ in detail to | ||||||
|  | address new problems or concerns. | ||||||
|  |  | ||||||
|  |   Each version is given a distinguishing version number.  If the | ||||||
|  | Program specifies that a certain numbered version of the GNU General | ||||||
|  | Public License "or any later version" applies to it, you have the | ||||||
|  | option of following the terms and conditions either of that numbered | ||||||
|  | version or of any later version published by the Free Software | ||||||
|  | Foundation.  If the Program does not specify a version number of the | ||||||
|  | GNU General Public License, you may choose any version ever published | ||||||
|  | by the Free Software Foundation. | ||||||
|  |  | ||||||
|  |   If the Program specifies that a proxy can decide which future | ||||||
|  | versions of the GNU General Public License can be used, that proxy's | ||||||
|  | public statement of acceptance of a version permanently authorizes you | ||||||
|  | to choose that version for the Program. | ||||||
|  |  | ||||||
|  |   Later license versions may give you additional or different | ||||||
|  | permissions.  However, no additional obligations are imposed on any | ||||||
|  | author or copyright holder as a result of your choosing to follow a | ||||||
|  | later version. | ||||||
|  |  | ||||||
|  |   15. Disclaimer of Warranty. | ||||||
|  |  | ||||||
|  |   THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY | ||||||
|  | APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT | ||||||
|  | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY | ||||||
|  | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, | ||||||
|  | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR | ||||||
|  | PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM | ||||||
|  | IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF | ||||||
|  | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. | ||||||
|  |  | ||||||
|  |   16. Limitation of Liability. | ||||||
|  |  | ||||||
|  |   IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING | ||||||
|  | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS | ||||||
|  | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY | ||||||
|  | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE | ||||||
|  | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF | ||||||
|  | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD | ||||||
|  | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), | ||||||
|  | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF | ||||||
|  | SUCH DAMAGES. | ||||||
|  |  | ||||||
|  |   17. Interpretation of Sections 15 and 16. | ||||||
|  |  | ||||||
|  |   If the disclaimer of warranty and limitation of liability provided | ||||||
|  | above cannot be given local legal effect according to their terms, | ||||||
|  | reviewing courts shall apply local law that most closely approximates | ||||||
|  | an absolute waiver of all civil liability in connection with the | ||||||
|  | Program, unless a warranty or assumption of liability accompanies a | ||||||
|  | copy of the Program in return for a fee. | ||||||
|  |  | ||||||
|  |                      END OF TERMS AND CONDITIONS | ||||||
|  |  | ||||||
|  |             How to Apply These Terms to Your New Programs | ||||||
|  |  | ||||||
|  |   If you develop a new program, and you want it to be of the greatest | ||||||
|  | possible use to the public, the best way to achieve this is to make it | ||||||
|  | free software which everyone can redistribute and change under these terms. | ||||||
|  |  | ||||||
|  |   To do so, attach the following notices to the program.  It is safest | ||||||
|  | to attach them to the start of each source file to most effectively | ||||||
|  | state the exclusion of warranty; and each file should have at least | ||||||
|  | the "copyright" line and a pointer to where the full notice is found. | ||||||
|  |  | ||||||
|  |     <one line to give the program's name and a brief idea of what it does.> | ||||||
|  |     Copyright (C) <year>  <name of author> | ||||||
|  |  | ||||||
|  |     This program is free software: you can redistribute it and/or modify | ||||||
|  |     it under the terms of the GNU General Public License as published by | ||||||
|  |     the Free Software Foundation, either version 3 of the License, or | ||||||
|  |     (at your option) any later version. | ||||||
|  |  | ||||||
|  |     This program is distributed in the hope that it will be useful, | ||||||
|  |     but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  |     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  |     GNU General Public License for more details. | ||||||
|  |  | ||||||
|  |     You should have received a copy of the GNU General Public License | ||||||
|  |     along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  |  | ||||||
|  | Also add information on how to contact you by electronic and paper mail. | ||||||
|  |  | ||||||
|  |   If the program does terminal interaction, make it output a short | ||||||
|  | notice like this when it starts in an interactive mode: | ||||||
|  |  | ||||||
|  |     <program>  Copyright (C) <year>  <name of author> | ||||||
|  |     This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. | ||||||
|  |     This is free software, and you are welcome to redistribute it | ||||||
|  |     under certain conditions; type `show c' for details. | ||||||
|  |  | ||||||
|  | The hypothetical commands `show w' and `show c' should show the appropriate | ||||||
|  | parts of the General Public License.  Of course, your program's commands | ||||||
|  | might be different; for a GUI interface, you would use an "about box". | ||||||
|  |  | ||||||
|  |   You should also get your employer (if you work as a programmer) or school, | ||||||
|  | if any, to sign a "copyright disclaimer" for the program, if necessary. | ||||||
|  | For more information on this, and how to apply and follow the GNU GPL, see | ||||||
|  | <https://www.gnu.org/licenses/>. | ||||||
|  |  | ||||||
|  |   The GNU General Public License does not permit incorporating your program | ||||||
|  | into proprietary programs.  If your program is a subroutine library, you | ||||||
|  | may consider it more useful to permit linking proprietary applications with | ||||||
|  | the library.  If this is what you want to do, use the GNU Lesser General | ||||||
|  | Public License instead of this License.  But first, please read | ||||||
|  | <https://www.gnu.org/licenses/why-not-lgpl.html>. | ||||||
|  | |||||||
							
								
								
									
										305
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										305
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -53,10 +53,10 @@ | |||||||
|         }, |         }, | ||||||
|         "autobahn": { |         "autobahn": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:74ca21d3552825615a65d47ec38d0aa5961a1345f7639f5b0e2abfff40cbfd07", |                 "sha256:410a93e0e29882c8b5d5ab05d220b07609b886ef5f23c0b8d39153254ffd6895", | ||||||
|                 "sha256:85c14b4a404146339ffd171e1ea1f65bf71e2f777d810aaa8a36119273869e3d" |                 "sha256:52ee4236ff9a1fcbbd9500439dcf3284284b37f8a6b31ecc8a36e00cf9f95049" | ||||||
|             ], |             ], | ||||||
|             "version": "==20.12.1" |             "version": "==20.12.3" | ||||||
|         }, |         }, | ||||||
|         "automat": { |         "automat": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -74,18 +74,18 @@ | |||||||
|         }, |         }, | ||||||
|         "boto3": { |         "boto3": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2a6e92194bd6f2341908dc9b133af057ea1ff20b7d7e54674f48cdb531d93ca5", |                 "sha256:197926eaf0065c2c503914a15edc75f4ac259c1e5ae6d17eabd1ba5d8ebd1554", | ||||||
|                 "sha256:a35e0915547ea659ddd832c9aaf55038c56fa894c4cc2a2a46cd6c642494012a" |                 "sha256:d6991e6fd7d0f63bf94282687700a91f5299b807e544cb3367e9b2faeeaf8c62" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.16.35" |             "version": "==1.16.46" | ||||||
|         }, |         }, | ||||||
|         "botocore": { |         "botocore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:633aa910509b060717df4130f7e2841f1101c0c47fd5871f4903b4b1dbab7e23", |                 "sha256:85ca6915ad5471e7f6cd1b00610b74601d2970cbf8e9b1bf255697154cf621a3", | ||||||
|                 "sha256:d31dce56799edb5796085d5296931faae201e28e14e568d9db4dac237a135fe3" |                 "sha256:f7d365c689070368a5a0857aa35a81d7c950556189f23065f42798f810a59cae" | ||||||
|             ], |             ], | ||||||
|             "version": "==1.19.35" |             "version": "==1.19.46" | ||||||
|         }, |         }, | ||||||
|         "cachetools": { |         "cachetools": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -96,11 +96,11 @@ | |||||||
|         }, |         }, | ||||||
|         "celery": { |         "celery": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:45bb7909061862305cefec94289fabc1b89ac004680f4dc7d9dea642a2507e53", |                 "sha256:5e8d364e058554e83bbb116e8377d90c79be254785f357cb2cec026e79febe13", | ||||||
|                 "sha256:533f3635065b7ed362ffc04228635b4c82d53a9ab812118ccdedb5eae281fb97" |                 "sha256:f4efebe6f8629b0da2b8e529424de376494f5b7a743c321c8a2ddc2b1414921c" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==5.0.4" |             "version": "==5.0.5" | ||||||
|         }, |         }, | ||||||
|         "certifi": { |         "certifi": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -152,11 +152,11 @@ | |||||||
|         }, |         }, | ||||||
|         "channels": { |         "channels": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:74db79c9eca616be69d38013b22083ab5d3f9ccda1ab5e69096b1bb7da2d9b18", |                 "sha256:056b72e51080a517a0f33a0a30003e03833b551d75394d6636c885d4edb8188f", | ||||||
|                 "sha256:f50a6e79757a64c1e45e95e144a2ac5f1e99ee44a0718ab182c501f5e5abd268" |                 "sha256:3f15bdd2138bb4796e76ea588a0a344b12a7964ea9b2e456f992fddb988a4317" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==3.0.2" |             "version": "==3.0.3" | ||||||
|         }, |         }, | ||||||
|         "channels-redis": { |         "channels-redis": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -168,10 +168,10 @@ | |||||||
|         }, |         }, | ||||||
|         "chardet": { |         "chardet": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", |                 "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", | ||||||
|                 "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" |                 "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" | ||||||
|             ], |             ], | ||||||
|             "version": "==3.0.4" |             "version": "==4.0.0" | ||||||
|         }, |         }, | ||||||
|         "click": { |         "click": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -343,11 +343,11 @@ | |||||||
|         }, |         }, | ||||||
|         "django-storages": { |         "django-storages": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:12de8fb2605b9b57bfaf54b075280d7cbb3b3ee1ca4bc9b9add147af87fe3a2c", |                 "sha256:c823dbf56c9e35b0999a13d7e05062b837bae36c518a40255d522fbe3750fbb4", | ||||||
|                 "sha256:652275ab7844538c462b62810276c0244866f345878256a9e0e86f5b1283ae18" |                 "sha256:f28765826d507a0309cfaa849bd084894bc71d81bf0d09479168d44785396f80" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.10.1" |             "version": "==1.11.1" | ||||||
|         }, |         }, | ||||||
|         "djangorestframework": { |         "djangorestframework": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -366,11 +366,11 @@ | |||||||
|         }, |         }, | ||||||
|         "docker": { |         "docker": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:317e95a48c32de8c1aac92a48066a5b73e218ed096e03758bcdd799a7130a1a1", |                 "sha256:0604a74719d5d2de438753934b755bfcda6f62f49b8e4b30969a4b0a2a8a1220", | ||||||
|                 "sha256:cffc771d4ea1389fc66bc95cb72d304aa41d1a1563482a9a000fba3a84ed5071" |                 "sha256:e455fa49aabd4f22da9f4e1c1f9d16308286adc60abaf64bf3e1feafaed81d06" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==4.4.0" |             "version": "==4.4.1" | ||||||
|         }, |         }, | ||||||
|         "drf-yasg2": { |         "drf-yasg2": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -646,46 +646,36 @@ | |||||||
|         }, |         }, | ||||||
|         "msgpack": { |         "msgpack": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:01835e300967e5ad6fdbfc36eafe74df67ff47e16e0d6dee8766630550315903", |                 "sha256:0cb94ee48675a45d3b86e61d13c1e6f1696f0183f0715544976356ff86f741d9", | ||||||
|                 "sha256:03c5554315317d76c25a15569dd52ac6047b105df71e861f24faf9675672b72d", |                 "sha256:1026dcc10537d27dd2d26c327e552f05ce148977e9d7b9f1718748281b38c841", | ||||||
|                 "sha256:0968b368a9a9081435bfcb7a57a1e8b75c7bf038ef911b369acd2e038c7f873a", |                 "sha256:26a1759f1a88df5f1d0b393eb582ec022326994e311ba9c5818adc5374736439", | ||||||
|                 "sha256:1d7ab166401f7789bf11262439336c0a01b878f0d602e48f35c35d2e3a555820", |                 "sha256:2a5866bdc88d77f6e1370f82f2371c9bc6fc92fe898fa2dec0c5d4f5435a2694", | ||||||
|                 "sha256:1e8d27bac821f8aa909904a704a67e5e8bc2e42b153415fc3621b7afbc06702b", |                 "sha256:31c17bbf2ae5e29e48d794c693b7ca7a0c73bd4280976d408c53df421e838d2a", | ||||||
|                 "sha256:1fc9f21da9fd77088ebfd3c9941b044ca3f4a048e85f7ff5727f26bcdbffed61", |                 "sha256:497d2c12426adcd27ab83144057a705efb6acc7e85957a51d43cdcf7f258900f", | ||||||
|                 "sha256:20196229acc193939223118c7420838749d5b0cece49cd397739a3a6ffcfe2d1", |                 "sha256:5a9ee2540c78659a1dd0b110f73773533ee3108d4e1219b5a15a8d635b7aca0e", | ||||||
|                 "sha256:2933443313289725f16bd7b99a8c3aa6a2cca1549e661d7407f056a0af80bf7b", |                 "sha256:8521e5be9e3b93d4d5e07cb80b7e32353264d143c1f072309e1863174c6aadb1", | ||||||
|                 "sha256:2966b155356fd231fa441131d7301e1596ee38974ad56dc57fd752fdbe2bb63f", |                 "sha256:87869ba567fe371c4555d2e11e4948778ab6b59d6cc9d8460d543e4cfbbddd1c", | ||||||
|                 "sha256:29a6fb3729215b6fcab786ef4f460a5406a5c056f7021191f70ff7712a3f6ba4", |                 "sha256:8ffb24a3b7518e843cd83538cf859e026d24ec41ac5721c18ed0c55101f9775b", | ||||||
|                 "sha256:35cbefa7d7bddfb4b0770a1b9ff721cd8dfe9a680947a68457974d5e3e6acc2f", |                 "sha256:92be4b12de4806d3c36810b0fe2aeedd8d493db39e2eb90742b9c09299eb5759", | ||||||
|                 "sha256:35ff1ac162a77fb78be360d9f771d36cbf1202e94fc6d70e284ad5db6ab72608", |                 "sha256:9ea52fff0473f9f3000987f313310208c879493491ef3ccf66268eff8d5a0326", | ||||||
|                 "sha256:40dd1ac7420f071e96b3e4a4a7b8e69546a6f8065ff5995dbacf53f86207eb98", |                 "sha256:a4355d2193106c7aa77c98fc955252a737d8550320ecdb2e9ac701e15e2943bc", | ||||||
|                 "sha256:4bea1938e484c9caca9585105f447d6807c496c153b7244fa726b3cc4a68ec9e", |                 "sha256:a99b144475230982aee16b3d249170f1cccebf27fb0a08e9f603b69637a62192", | ||||||
|                 "sha256:4e58b9f4a99bc3a90859bb006ec4422448a5ce39e5cd6e7498c56de5dcec9c34", |                 "sha256:ac25f3e0513f6673e8b405c3a80500eb7be1cf8f57584be524c4fa78fe8e0c83", | ||||||
|                 "sha256:66d47e952856bfcde46d8351380d0b5b928a73112b66bc06d5367dfcc077c06a", |                 "sha256:b28c0876cce1466d7c2195d7658cf50e4730667196e2f1355c4209444717ee06", | ||||||
|                 "sha256:69f6aa503378548ea1e760c11aeb6fc91952bf3634fd806a38a0e47edb507fcd", |                 "sha256:b55f7db883530b74c857e50e149126b91bb75d35c08b28db12dcb0346f15e46e", | ||||||
|                 "sha256:7033215267a0e9f60f4a5e4fb2228a932c404f237817caff8dc3115d9e7cd975", |                 "sha256:b6d9e2dae081aa35c44af9c4298de4ee72991305503442a5c74656d82b581fe9", | ||||||
|                 "sha256:7b50afd767cc053ad92fad39947c3670db27305fd1c49acded44d9d9ac8b56fd", |                 "sha256:c747c0cc08bd6d72a586310bda6ea72eeb28e7505990f342552315b229a19b33", | ||||||
|                 "sha256:99ea9e65876546743b2b8bb5bc7adefbb03b9da78a899827467da197a48f790b", |                 "sha256:d6c64601af8f3893d17ec233237030e3110f11b8a962cb66720bf70c0141aa54", | ||||||
|                 "sha256:abcc62303ac4d789878d4aac4cdba1bbe2adb478d67be99cd4a6d56ac3a4028f", |                 "sha256:d8167b84af26654c1124857d71650404336f4eb5cc06900667a493fc619ddd9f", | ||||||
|                 "sha256:b107f9b36665bf7d7c6176a938a361a7aba16aa179d833919448f77287866484", |                 "sha256:de6bd7990a2c2dabe926b7e62a92886ccbf809425c347ae7de277067f97c2887", | ||||||
|                 "sha256:b5b27923b6c98a2616b7e906a29e4e10e1b4424aea87a0e0d5636327dc6ea315", |                 "sha256:e36a812ef4705a291cdb4a2fd352f013134f26c6ff63477f20235138d1d21009", | ||||||
|                 "sha256:bf8eedc7bfbf63cbc9abe58287c32d78780a347835e82c23033c68f11f80bb05", |                 "sha256:e89ec55871ed5473a041c0495b7b4e6099f6263438e0bd04ccd8418f92d5d7f2", | ||||||
|                 "sha256:c144ff4954a6ea40aa603600c8be175349588fc68696092889fa34ab6e055060", |                 "sha256:f3e6aaf217ac1c7ce1563cf52a2f4f5d5b1f64e8729d794165db71da57257f0c", | ||||||
|                 "sha256:c4e5f96a1d0d916ce7a16decb7499e8923ddef007cf7d68412fb68767766648a", |                 "sha256:f484cd2dca68502de3704f056fa9b318c94b1539ed17a4c784266df5d6978c87", | ||||||
|                 "sha256:c60e8b2bf754b8dcc1075c5bee0b177ed9193e7cbd2377faaf507120a948e697", |                 "sha256:fae04496f5bc150eefad4e9571d1a76c55d021325dcd484ce45065ebbdd00984", | ||||||
|                 "sha256:c82fc6cdba5737eb6ed0c926a30a5d56e7b050297375a16d6c5ad89b576fd979", |                 "sha256:fe07bc6735d08e492a327f496b7850e98cb4d112c56df69b0c844dbebcbb47f6" | ||||||
|                 "sha256:ce4ebe2c79411cd5671b20862831880e7850a2de699cff6626f48853fde61ae6", |  | ||||||
|                 "sha256:d113c6b1239c62669ef3063693842605a3edbfebc39a333cf91ba60d314afe6d", |  | ||||||
|                 "sha256:d3cea07ad16919a44e8d1ea67efa5244855cdce807d672f41694acc24d08834e", |  | ||||||
|                 "sha256:d76672602db16e3f44bc1a85c7ee5f15d79e02fcf5bc9d133c2954753be6eddc", |  | ||||||
|                 "sha256:decf2091b75987ca2564e3b742f9614eb7d57e39ff04eaa68af7a3fc5648f7ed", |  | ||||||
|                 "sha256:e13b9007af66a3f62574bc0a13843df0e4402f5ee4b00a02aa1803f01d26b9fb", |  | ||||||
|                 "sha256:e157edf4213dacafb0f862e0b7a3a18448250cec91aa1334f432f49028acc650", |  | ||||||
|                 "sha256:e234ff83628ca3ab345bf97fb36ccbf6d2f1700f5e08868643bf4489edc960f8", |  | ||||||
|                 "sha256:f08d9dd3ce0c5e972dc4653f0fb66d2703941e65356388c13032b578dd718261", |  | ||||||
|                 "sha256:f20d7d4f1f0728560408ba6933154abccf0c20f24642a2404b43d5c23e4119ab" |  | ||||||
|             ], |             ], | ||||||
|             "version": "==1.0.1" |             "version": "==1.0.2" | ||||||
|         }, |         }, | ||||||
|         "oauthlib": { |         "oauthlib": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -875,10 +865,10 @@ | |||||||
|         }, |         }, | ||||||
|         "pyopenssl": { |         "pyopenssl": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:898aefbde331ba718570244c3b01dcddb1b31a3b336613436a45e52e27d9a82d", |                 "sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51", | ||||||
|                 "sha256:92f08eccbd73701cf744e8ffd6989aa7842d48cbe3fea8a7c031c5647f590ac5" |                 "sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b" | ||||||
|             ], |             ], | ||||||
|             "version": "==20.0.0" |             "version": "==20.0.1" | ||||||
|         }, |         }, | ||||||
|         "pyparsing": { |         "pyparsing": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -909,10 +899,10 @@ | |||||||
|         }, |         }, | ||||||
|         "pytz": { |         "pytz": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268", |                 "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4", | ||||||
|                 "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" |                 "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5" | ||||||
|             ], |             ], | ||||||
|             "version": "==2020.4" |             "version": "==2020.5" | ||||||
|         }, |         }, | ||||||
|         "pyyaml": { |         "pyyaml": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -950,10 +940,10 @@ | |||||||
|         }, |         }, | ||||||
|         "requests": { |         "requests": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", |                 "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", | ||||||
|                 "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" |                 "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" | ||||||
|             ], |             ], | ||||||
|             "version": "==2.25.0" |             "version": "==2.25.1" | ||||||
|         }, |         }, | ||||||
|         "requests-oauthlib": { |         "requests-oauthlib": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1064,10 +1054,10 @@ | |||||||
|         }, |         }, | ||||||
|         "txaio": { |         "txaio": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:17938f2bca4a9cabce61346758e482ca4e600160cbc28e861493eac74a19539d", |                 "sha256:1488d31d564a116538cc1265ac3f7979fb6223bb5a9e9f1479436ee2c17d8549", | ||||||
|                 "sha256:38a469daf93c37e5527cb062653d6393ae11663147c42fab7ddc3f6d00d434ae" |                 "sha256:a8676d6c68aea1f0e2548c4afdb8e6253873af3bc2659bb5bcd9f39dff7ff90f" | ||||||
|             ], |             ], | ||||||
|             "version": "==20.4.1" |             "version": "==20.12.1" | ||||||
|         }, |         }, | ||||||
|         "uritemplate": { |         "uritemplate": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1093,11 +1083,11 @@ | |||||||
|                 "standard" |                 "standard" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2a7b17f4d9848d6557ccc2274a5f7c97f1daf037d130a0c6918f67cd9bc8cdf5", |                 "sha256:1079c50a06f6338095b4f203e7861dbff318dde5f22f3a324fc6e94c7654164c", | ||||||
|                 "sha256:6fcce74c00b77d4f4b3ed7ba1b2a370d27133bfdb46f835b7a76dfe0a8c110ae" |                 "sha256:ef1e0bb5f7941c6fe324e06443ddac0331e1632a776175f87891c7bd02694355" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==0.13.1" |             "version": "==0.13.3" | ||||||
|         }, |         }, | ||||||
|         "uvloop": { |         "uvloop": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1328,43 +1318,58 @@ | |||||||
|         }, |         }, | ||||||
|         "coverage": { |         "coverage": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", |                 "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297", | ||||||
|                 "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", |                 "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1", | ||||||
|                 "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", |                 "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497", | ||||||
|                 "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", |                 "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606", | ||||||
|                 "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", |                 "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528", | ||||||
|                 "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", |                 "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b", | ||||||
|                 "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", |                 "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4", | ||||||
|                 "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", |                 "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830", | ||||||
|                 "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", |                 "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1", | ||||||
|                 "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", |                 "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f", | ||||||
|                 "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", |                 "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d", | ||||||
|                 "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", |                 "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3", | ||||||
|                 "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", |                 "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8", | ||||||
|                 "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", |                 "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500", | ||||||
|                 "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", |                 "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7", | ||||||
|                 "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", |                 "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb", | ||||||
|                 "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", |                 "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b", | ||||||
|                 "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", |                 "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059", | ||||||
|                 "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", |                 "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b", | ||||||
|                 "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", |                 "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72", | ||||||
|                 "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", |                 "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36", | ||||||
|                 "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", |                 "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277", | ||||||
|                 "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", |                 "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c", | ||||||
|                 "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", |                 "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631", | ||||||
|                 "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", |                 "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff", | ||||||
|                 "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", |                 "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8", | ||||||
|                 "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", |                 "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec", | ||||||
|                 "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", |                 "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b", | ||||||
|                 "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", |                 "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7", | ||||||
|                 "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", |                 "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105", | ||||||
|                 "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", |                 "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b", | ||||||
|                 "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", |                 "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c", | ||||||
|                 "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", |                 "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b", | ||||||
|                 "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" |                 "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98", | ||||||
|  |                 "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4", | ||||||
|  |                 "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879", | ||||||
|  |                 "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f", | ||||||
|  |                 "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4", | ||||||
|  |                 "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044", | ||||||
|  |                 "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e", | ||||||
|  |                 "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899", | ||||||
|  |                 "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f", | ||||||
|  |                 "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448", | ||||||
|  |                 "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714", | ||||||
|  |                 "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2", | ||||||
|  |                 "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d", | ||||||
|  |                 "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd", | ||||||
|  |                 "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7", | ||||||
|  |                 "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==5.3" |             "version": "==5.3.1" | ||||||
|         }, |         }, | ||||||
|         "django": { |         "django": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1586,11 +1591,11 @@ | |||||||
|         }, |         }, | ||||||
|         "pytest": { |         "pytest": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:b12e09409c5bdedc28d308469e156127004a436b41e9b44f9bff6446cbab9152", |                 "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8", | ||||||
|                 "sha256:d69e1a80b34fe4d596c9142f35d9e523d98a2838976f1a68419a8f051b24cec6" |                 "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==6.2.0" |             "version": "==6.2.1" | ||||||
|         }, |         }, | ||||||
|         "pytest-django": { |         "pytest-django": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1602,10 +1607,10 @@ | |||||||
|         }, |         }, | ||||||
|         "pytz": { |         "pytz": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268", |                 "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4", | ||||||
|                 "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" |                 "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5" | ||||||
|             ], |             ], | ||||||
|             "version": "==2020.4" |             "version": "==2020.5" | ||||||
|         }, |         }, | ||||||
|         "pyyaml": { |         "pyyaml": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1736,38 +1741,38 @@ | |||||||
|         }, |         }, | ||||||
|         "typed-ast": { |         "typed-ast": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", |                 "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1", | ||||||
|                 "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", |                 "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d", | ||||||
|                 "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d", |                 "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6", | ||||||
|                 "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", |                 "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd", | ||||||
|                 "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", |                 "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37", | ||||||
|                 "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", |                 "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151", | ||||||
|                 "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c", |                 "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07", | ||||||
|                 "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", |                 "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440", | ||||||
|                 "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", |                 "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70", | ||||||
|                 "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", |                 "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496", | ||||||
|                 "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", |                 "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea", | ||||||
|                 "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", |                 "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400", | ||||||
|                 "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", |                 "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc", | ||||||
|                 "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d", |                 "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606", | ||||||
|                 "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", |                 "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc", | ||||||
|                 "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", |                 "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581", | ||||||
|                 "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c", |                 "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412", | ||||||
|                 "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", |                 "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a", | ||||||
|                 "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395", |                 "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2", | ||||||
|                 "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", |                 "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787", | ||||||
|                 "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", |                 "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f", | ||||||
|                 "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", |                 "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937", | ||||||
|                 "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", |                 "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64", | ||||||
|                 "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", |                 "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487", | ||||||
|                 "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072", |                 "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b", | ||||||
|                 "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298", |                 "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41", | ||||||
|                 "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91", |                 "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a", | ||||||
|                 "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", |                 "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3", | ||||||
|                 "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f", |                 "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166", | ||||||
|                 "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" |                 "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10" | ||||||
|             ], |             ], | ||||||
|             "version": "==1.4.1" |             "version": "==1.4.2" | ||||||
|         }, |         }, | ||||||
|         "typing-extensions": { |         "typing-extensions": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| <img src="web/icons/icon_top_brand.svg" height="250" alt="authentik logo"> | <img src="https://goauthentik.io/img/icon_top_brand_colour.svg" height="250" alt="authentik logo"> | ||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
| @ -21,8 +21,8 @@ For bigger setups, there is a Helm Chart in the `helm/` directory. This is docum | |||||||
|  |  | ||||||
| ## Screenshots | ## Screenshots | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| ## Development | ## Development | ||||||
|  |  | ||||||
|  | |||||||
| @ -6,9 +6,9 @@ As authentik is currently in a pre-stable, only the latest "stable" version is s | |||||||
|  |  | ||||||
| | Version  | Supported          | | | Version  | Supported          | | ||||||
| | -------- | ------------------ | | | -------- | ------------------ | | ||||||
| | 0.11.x   | :white_check_mark: | |  | ||||||
| | 0.12.x   | :white_check_mark: | | | 0.12.x   | :white_check_mark: | | ||||||
| | 0.13.x   | :white_check_mark: | | | 0.13.x   | :white_check_mark: | | ||||||
|  | | 0.14.x   | :white_check_mark: | | ||||||
|  |  | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,2 +1,2 @@ | |||||||
| """authentik""" | """authentik""" | ||||||
| __version__ = "0.13.0-stable" | __version__ = "0.14.2-stable" | ||||||
|  | |||||||
| @ -1,13 +1,12 @@ | |||||||
| """authentik administration overview""" | """authentik administration metrics""" | ||||||
| import time | import time | ||||||
| from collections import Counter | from collections import Counter | ||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
| from typing import Dict, List | from typing import Dict, List | ||||||
| 
 | 
 | ||||||
| from django.db.models import Count, ExpressionWrapper, F | from django.db.models import Count, ExpressionWrapper, F, Model | ||||||
| from django.db.models.fields import DurationField | from django.db.models.fields import DurationField | ||||||
| from django.db.models.functions import ExtractHour | from django.db.models.functions import ExtractHour | ||||||
| from django.http import response |  | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from drf_yasg2.utils import swagger_auto_schema | from drf_yasg2.utils import swagger_auto_schema | ||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
| @ -17,7 +16,7 @@ from rest_framework.response import Response | |||||||
| from rest_framework.serializers import Serializer | from rest_framework.serializers import Serializer | ||||||
| from rest_framework.viewsets import ViewSet | from rest_framework.viewsets import ViewSet | ||||||
| 
 | 
 | ||||||
| from authentik.audit.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_events_per_1h(**filter_kwargs) -> List[Dict[str, int]]: | def get_events_per_1h(**filter_kwargs) -> List[Dict[str, int]]: | ||||||
| @ -47,7 +46,7 @@ def get_events_per_1h(**filter_kwargs) -> List[Dict[str, int]]: | |||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class AdministrationMetricsSerializer(Serializer): | class AdministrationMetricsSerializer(Serializer): | ||||||
|     """Overview View""" |     """Login Metrics per 1h""" | ||||||
| 
 | 
 | ||||||
|     logins_per_1h = SerializerMethodField() |     logins_per_1h = SerializerMethodField() | ||||||
|     logins_failed_per_1h = SerializerMethodField() |     logins_failed_per_1h = SerializerMethodField() | ||||||
| @ -60,20 +59,20 @@ class AdministrationMetricsSerializer(Serializer): | |||||||
|         """Get failed logins per hour for the last 24 hours""" |         """Get failed logins per hour for the last 24 hours""" | ||||||
|         return get_events_per_1h(action=EventAction.LOGIN_FAILED) |         return get_events_per_1h(action=EventAction.LOGIN_FAILED) | ||||||
| 
 | 
 | ||||||
|     def create(self, request: Request) -> response: |     def create(self, validated_data: dict) -> Model: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
| 
 | 
 | ||||||
|     def update(self, request: Request) -> Response: |     def update(self, instance: Model, validated_data: dict) -> Model: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class AdministrationMetricsViewSet(ViewSet): | class AdministrationMetricsViewSet(ViewSet): | ||||||
|     """Return single instance of AdministrationMetricsSerializer""" |     """Login Metrics per 1h""" | ||||||
| 
 | 
 | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
| 
 | 
 | ||||||
|     @swagger_auto_schema(responses={200: AdministrationMetricsSerializer(many=True)}) |     @swagger_auto_schema(responses={200: AdministrationMetricsSerializer(many=True)}) | ||||||
|     def list(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """Return single instance of AdministrationMetricsSerializer""" |         """Login Metrics per 1h""" | ||||||
|         serializer = AdministrationMetricsSerializer(True) |         serializer = AdministrationMetricsSerializer(True) | ||||||
|         return Response(serializer.data) |         return Response(serializer.data) | ||||||
| @ -1,79 +0,0 @@ | |||||||
| """authentik administration overview""" |  | ||||||
| from django.core.cache import cache |  | ||||||
| from drf_yasg2.utils import swagger_auto_schema |  | ||||||
| from rest_framework.fields import SerializerMethodField |  | ||||||
| from rest_framework.permissions import IsAdminUser |  | ||||||
| from rest_framework.request import Request |  | ||||||
| from rest_framework.response import Response |  | ||||||
| from rest_framework.serializers import Serializer |  | ||||||
| from rest_framework.viewsets import ViewSet |  | ||||||
|  |  | ||||||
| from authentik import __version__ |  | ||||||
| from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version |  | ||||||
| from authentik.core.models import Provider |  | ||||||
| from authentik.policies.models import Policy |  | ||||||
| from authentik.root.celery import CELERY_APP |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AdministrationOverviewSerializer(Serializer): |  | ||||||
|     """Overview View""" |  | ||||||
|  |  | ||||||
|     version = SerializerMethodField() |  | ||||||
|     version_latest = SerializerMethodField() |  | ||||||
|     worker_count = SerializerMethodField() |  | ||||||
|     providers_without_application = SerializerMethodField() |  | ||||||
|     policies_without_binding = SerializerMethodField() |  | ||||||
|     cached_policies = SerializerMethodField() |  | ||||||
|     cached_flows = SerializerMethodField() |  | ||||||
|  |  | ||||||
|     def get_version(self, _) -> str: |  | ||||||
|         """Get current version""" |  | ||||||
|         return __version__ |  | ||||||
|  |  | ||||||
|     def get_version_latest(self, _) -> str: |  | ||||||
|         """Get latest version from cache""" |  | ||||||
|         version_in_cache = cache.get(VERSION_CACHE_KEY) |  | ||||||
|         if not version_in_cache: |  | ||||||
|             update_latest_version.delay() |  | ||||||
|             return __version__ |  | ||||||
|         return version_in_cache |  | ||||||
|  |  | ||||||
|     def get_worker_count(self, _) -> int: |  | ||||||
|         """Ping workers""" |  | ||||||
|         return len(CELERY_APP.control.ping(timeout=0.5)) |  | ||||||
|  |  | ||||||
|     def get_providers_without_application(self, _) -> int: |  | ||||||
|         """Count of providers without application""" |  | ||||||
|         return len(Provider.objects.filter(application=None)) |  | ||||||
|  |  | ||||||
|     def get_policies_without_binding(self, _) -> int: |  | ||||||
|         """Count of policies not bound or use in prompt stages""" |  | ||||||
|         return len( |  | ||||||
|             Policy.objects.filter(bindings__isnull=True, promptstage__isnull=True) |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def get_cached_policies(self, _) -> int: |  | ||||||
|         """Get cached policy count""" |  | ||||||
|         return len(cache.keys("policy_*")) |  | ||||||
|  |  | ||||||
|     def get_cached_flows(self, _) -> int: |  | ||||||
|         """Get cached flow count""" |  | ||||||
|         return len(cache.keys("flow_*")) |  | ||||||
|  |  | ||||||
|     def create(self, request: Request) -> Response: |  | ||||||
|         raise NotImplementedError |  | ||||||
|  |  | ||||||
|     def update(self, request: Request) -> Response: |  | ||||||
|         raise NotImplementedError |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AdministrationOverviewViewSet(ViewSet): |  | ||||||
|     """Return single instance of AdministrationOverviewSerializer""" |  | ||||||
|  |  | ||||||
|     permission_classes = [IsAdminUser] |  | ||||||
|  |  | ||||||
|     @swagger_auto_schema(responses={200: AdministrationOverviewSerializer(many=True)}) |  | ||||||
|     def list(self, request: Request) -> Response: |  | ||||||
|         """Return single instance of AdministrationOverviewSerializer""" |  | ||||||
|         serializer = AdministrationOverviewSerializer(True) |  | ||||||
|         return Response(serializer.data) |  | ||||||
| @ -2,6 +2,7 @@ | |||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.contrib import messages | from django.contrib import messages | ||||||
|  | from django.db.models import Model | ||||||
| from django.http.response import Http404 | from django.http.response import Http404 | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from drf_yasg2.utils import swagger_auto_schema | from drf_yasg2.utils import swagger_auto_schema | ||||||
| @ -26,10 +27,10 @@ class TaskSerializer(Serializer): | |||||||
|     status = IntegerField(source="result.status.value") |     status = IntegerField(source="result.status.value") | ||||||
|     messages = ListField(source="result.messages") |     messages = ListField(source="result.messages") | ||||||
|  |  | ||||||
|     def create(self, request: Request) -> Response: |     def create(self, validated_data: dict) -> Model: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     def update(self, request: Request) -> Response: |     def update(self, instance: Model, validated_data: dict) -> Model: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -66,7 +67,7 @@ class TaskViewSet(ViewSet): | |||||||
|                     "successful": True, |                     "successful": True, | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|         except ImportError: |         except ImportError:  # pragma: no cover | ||||||
|             # if we get an import error, the module path has probably changed |             # if we get an import error, the module path has probably changed | ||||||
|             task.delete() |             task.delete() | ||||||
|             return Response({"successful": False}) |             return Response({"successful": False}) | ||||||
|  | |||||||
							
								
								
									
										61
									
								
								authentik/admin/api/version.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								authentik/admin/api/version.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,61 @@ | |||||||
|  | """authentik administration overview""" | ||||||
|  | from django.core.cache import cache | ||||||
|  | from django.db.models import Model | ||||||
|  | from drf_yasg2.utils import swagger_auto_schema | ||||||
|  | from packaging.version import parse | ||||||
|  | from rest_framework.fields import SerializerMethodField | ||||||
|  | from rest_framework.mixins import ListModelMixin | ||||||
|  | from rest_framework.permissions import IsAdminUser | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  | from rest_framework.serializers import Serializer | ||||||
|  | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
|  | from authentik import __version__ | ||||||
|  | from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class VersionSerializer(Serializer): | ||||||
|  |     """Get running and latest version.""" | ||||||
|  |  | ||||||
|  |     version_current = SerializerMethodField() | ||||||
|  |     version_latest = SerializerMethodField() | ||||||
|  |     outdated = SerializerMethodField() | ||||||
|  |  | ||||||
|  |     def get_version_current(self, _) -> str: | ||||||
|  |         """Get current version""" | ||||||
|  |         return __version__ | ||||||
|  |  | ||||||
|  |     def get_version_latest(self, _) -> str: | ||||||
|  |         """Get latest version from cache""" | ||||||
|  |         version_in_cache = cache.get(VERSION_CACHE_KEY) | ||||||
|  |         if not version_in_cache:  # pragma: no cover | ||||||
|  |             update_latest_version.delay() | ||||||
|  |             return __version__ | ||||||
|  |         return version_in_cache | ||||||
|  |  | ||||||
|  |     def get_outdated(self, instance) -> bool: | ||||||
|  |         """Check if we're running the latest version""" | ||||||
|  |         return parse(self.get_version_current(instance)) < parse( | ||||||
|  |             self.get_version_latest(instance) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def create(self, validated_data: dict) -> Model: | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     def update(self, instance: Model, validated_data: dict) -> Model: | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class VersionViewSet(ListModelMixin, GenericViewSet): | ||||||
|  |     """Get running and latest version.""" | ||||||
|  |  | ||||||
|  |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|  |     def get_queryset(self):  # pragma: no cover | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     @swagger_auto_schema(responses={200: VersionSerializer(many=True)}) | ||||||
|  |     def list(self, request: Request) -> Response: | ||||||
|  |         """Get running and latest version.""" | ||||||
|  |         return Response(VersionSerializer(True).data) | ||||||
							
								
								
									
										25
									
								
								authentik/admin/api/workers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								authentik/admin/api/workers.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | |||||||
|  | """authentik administration overview""" | ||||||
|  | from rest_framework.mixins import ListModelMixin | ||||||
|  | from rest_framework.permissions import IsAdminUser | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  | from rest_framework.serializers import Serializer | ||||||
|  | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
|  | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class WorkerViewSet(ListModelMixin, GenericViewSet): | ||||||
|  |     """Get currently connected worker count.""" | ||||||
|  |  | ||||||
|  |     serializer_class = Serializer | ||||||
|  |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|  |     def get_queryset(self):  # pragma: no cover | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     def list(self, request: Request) -> Response: | ||||||
|  |         """Get currently connected worker count.""" | ||||||
|  |         return Response( | ||||||
|  |             {"pagination": {"count": len(CELERY_APP.control.ping(timeout=0.5))}} | ||||||
|  |         ) | ||||||
| @ -14,4 +14,6 @@ SOURCE_SERIALIZER_FIELDS = [ | |||||||
|     "enabled", |     "enabled", | ||||||
|     "authentication_flow", |     "authentication_flow", | ||||||
|     "enrollment_flow", |     "enrollment_flow", | ||||||
|  |     "verbose_name", | ||||||
|  |     "verbose_name_plural", | ||||||
| ] | ] | ||||||
|  | |||||||
| @ -1,8 +1,11 @@ | |||||||
| """authentik admin tasks""" | """authentik admin tasks""" | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
|  | from packaging.version import parse | ||||||
| from requests import RequestException, get | from requests import RequestException, get | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
|  | from authentik import __version__ | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus | from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
| @ -19,12 +22,24 @@ def update_latest_version(self: MonitoredTask): | |||||||
|         response.raise_for_status() |         response.raise_for_status() | ||||||
|         data = response.json() |         data = response.json() | ||||||
|         tag_name = data.get("tag_name") |         tag_name = data.get("tag_name") | ||||||
|         cache.set(VERSION_CACHE_KEY, tag_name.split("/")[1], VERSION_CACHE_TIMEOUT) |         upstream_version = tag_name.split("/")[1] | ||||||
|  |         cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT) | ||||||
|         self.set_status( |         self.set_status( | ||||||
|             TaskResult( |             TaskResult( | ||||||
|                 TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"] |                 TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"] | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|  |         # Check if upstream version is newer than what we're running, | ||||||
|  |         # and if no event exists yet, create one. | ||||||
|  |         local_version = parse(__version__) | ||||||
|  |         if local_version < parse(upstream_version): | ||||||
|  |             # Event has already been created, don't create duplicate | ||||||
|  |             if Event.objects.filter( | ||||||
|  |                 action=EventAction.UPDATE_AVAILABLE, | ||||||
|  |                 context__new_version=upstream_version, | ||||||
|  |             ).exists(): | ||||||
|  |                 return | ||||||
|  |             Event.new(EventAction.UPDATE_AVAILABLE, new_version=upstream_version).save() | ||||||
|     except (RequestException, IndexError) as exc: |     except (RequestException, IndexError) as exc: | ||||||
|         cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) |         cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) | ||||||
|         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) |         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) | ||||||
|  | |||||||
| @ -1,131 +0,0 @@ | |||||||
| {% extends "administration/base.html" %} |  | ||||||
|  |  | ||||||
| {% load i18n %} |  | ||||||
| {% load authentik_utils %} |  | ||||||
|  |  | ||||||
| {% block content %} |  | ||||||
| <section class="pf-c-page__main-section pf-m-light"> |  | ||||||
|     <div class="pf-c-content"> |  | ||||||
|         <h1> |  | ||||||
|             <i class="pf-icon pf-icon-applications"></i> |  | ||||||
|             {% trans 'Applications' %} |  | ||||||
|         </h1> |  | ||||||
|         <p>{% trans "External Applications which use authentik as Identity-Provider, utilizing protocols like OAuth2 and SAML." %}</p> |  | ||||||
|     </div> |  | ||||||
| </section> |  | ||||||
| <section class="pf-c-page__main-section pf-m-no-padding-mobile"> |  | ||||||
|     <div class="pf-c-card"> |  | ||||||
|         {% if object_list %} |  | ||||||
|         <div class="pf-c-toolbar"> |  | ||||||
|             <div class="pf-c-toolbar__content"> |  | ||||||
|                 {% include 'partials/toolbar_search.html' %} |  | ||||||
|                 <div class="pf-c-toolbar__bulk-select"> |  | ||||||
|                     <ak-modal-button href="{% url 'authentik_admin:application-create' %}"> |  | ||||||
|                         <ak-spinner-button slot="trigger" class="pf-m-primary"> |  | ||||||
|                             {% trans 'Create' %} |  | ||||||
|                         </ak-spinner-button> |  | ||||||
|                         <div slot="modal"></div> |  | ||||||
|                     </ak-modal-button> |  | ||||||
|                     <button role="ak-refresh" class="pf-c-button pf-m-primary"> |  | ||||||
|                         {% trans 'Refresh' %} |  | ||||||
|                     </button> |  | ||||||
|                 </div> |  | ||||||
|                 {% 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"></th> |  | ||||||
|                     <th role="columnheader" scope="col">{% trans 'Name' %}</th> |  | ||||||
|                     <th role="columnheader" scope="col">{% trans 'Slug' %}</th> |  | ||||||
|                     <th role="columnheader" scope="col">{% trans 'Provider' %}</th> |  | ||||||
|                     <th role="columnheader" scope="col">{% trans 'Provider Type' %}</th> |  | ||||||
|                     <th role="columnheader"></th> |  | ||||||
|                 </tr> |  | ||||||
|             </thead> |  | ||||||
|             <tbody role="rowgroup"> |  | ||||||
|                 {% for application in object_list %} |  | ||||||
|                 <tr role="row"> |  | ||||||
|                     <td role="cell" {% if application.meta_icon %} style="vertical-align: bottom;" {% endif %}> |  | ||||||
|                         {% if application.meta_icon %} |  | ||||||
|                         <img class="app-icon pf-c-avatar" src="{{ application.meta_icon.url }}" alt="{% trans 'Application Icon' %}"> |  | ||||||
|                         {% else %} |  | ||||||
|                         <i class="pf-icon pf-icon-arrow"></i> |  | ||||||
|                         {% endif %} |  | ||||||
|                     </td> |  | ||||||
|                     <td role="cell"> |  | ||||||
|                         <a href="/applications/{{ application.slug }}/"> |  | ||||||
|                             <div> |  | ||||||
|                                 {{ application.name }} |  | ||||||
|                             </div> |  | ||||||
|                             {% if application.meta_publisher %} |  | ||||||
|                             <small>{{ application.meta_publisher }}</small> |  | ||||||
|                             {% endif %} |  | ||||||
|                         </a> |  | ||||||
|                     </td> |  | ||||||
|                     <td role="cell"> |  | ||||||
|                         <code>{{ application.slug }}</span> |  | ||||||
|                     </td> |  | ||||||
|                     <td role="cell"> |  | ||||||
|                         <span> |  | ||||||
|                             {{ application.get_provider }} |  | ||||||
|                         </span> |  | ||||||
|                     </td> |  | ||||||
|                     <td role="cell"> |  | ||||||
|                         <span> |  | ||||||
|                             {{ application.get_provider|verbose_name }} |  | ||||||
|                         </span> |  | ||||||
|                     </td> |  | ||||||
|                     <td> |  | ||||||
|                         <ak-modal-button href="{% url 'authentik_admin:application-update' pk=application.pk %}"> |  | ||||||
|                             <ak-spinner-button slot="trigger" class="pf-m-secondary"> |  | ||||||
|                                 {% trans 'Edit' %} |  | ||||||
|                             </ak-spinner-button> |  | ||||||
|                             <div slot="modal"></div> |  | ||||||
|                         </ak-modal-button> |  | ||||||
|                         <ak-modal-button href="{% url 'authentik_admin:application-delete' pk=application.pk %}"> |  | ||||||
|                             <ak-spinner-button slot="trigger" class="pf-m-danger"> |  | ||||||
|                                 {% trans 'Delete' %} |  | ||||||
|                             </ak-spinner-button> |  | ||||||
|                             <div slot="modal"></div> |  | ||||||
|                         </ak-modal-button> |  | ||||||
|                     </td> |  | ||||||
|                 </tr> |  | ||||||
|                 {% endfor %} |  | ||||||
|             </tbody> |  | ||||||
|         </table> |  | ||||||
|         <div class="pf-c-pagination pf-m-bottom"> |  | ||||||
|             {% include 'partials/pagination.html' %} |  | ||||||
|         </div> |  | ||||||
|         {% else %} |  | ||||||
|         <div class="pf-c-toolbar"> |  | ||||||
|             <div class="pf-c-toolbar__content"> |  | ||||||
|                 {% include 'partials/toolbar_search.html' %} |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|         <div class="pf-c-empty-state"> |  | ||||||
|             <div class="pf-c-empty-state__content"> |  | ||||||
|                 <i class="pf-icon pf-icon-applications pf-c-empty-state__icon" aria-hidden="true"></i> |  | ||||||
|                 <h1 class="pf-c-title pf-m-lg"> |  | ||||||
|                     {% trans 'No Applications.' %} |  | ||||||
|                 </h1> |  | ||||||
|                 <div class="pf-c-empty-state__body"> |  | ||||||
|                 {% if request.GET.search != "" %} |  | ||||||
|                     {% trans "Your search query doesn't match any application." %} |  | ||||||
|                 {% else %} |  | ||||||
|                     {% trans 'Currently no applications exist. Click the button below to create one.' %} |  | ||||||
|                 {% endif %} |  | ||||||
|                 </div> |  | ||||||
|                 <ak-modal-button href="{% url 'authentik_admin:application-create' %}"> |  | ||||||
|                     <ak-spinner-button slot="trigger" class="pf-m-primary"> |  | ||||||
|                         {% trans 'Create' %} |  | ||||||
|                     </ak-spinner-button> |  | ||||||
|                     <div slot="modal"></div> |  | ||||||
|                 </ak-modal-button> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|         {% endif %} |  | ||||||
|     </div> |  | ||||||
| </section> |  | ||||||
| {% endblock %} |  | ||||||
| @ -53,7 +53,7 @@ | |||||||
|                 {% for flow in object_list %} |                 {% for flow in object_list %} | ||||||
|                 <tr role="row"> |                 <tr role="row"> | ||||||
|                     <th role="columnheader"> |                     <th role="columnheader"> | ||||||
|                         <a href="/flows/{{ flow.slug }}/"> |                         <a href="/flows/{{ flow.slug }}"> | ||||||
|                             <div><code>{{ flow.slug }}</code></div> |                             <div><code>{{ flow.slug }}</code></div> | ||||||
|                             <small>{{ flow.name }}</small> |                             <small>{{ flow.name }}</small> | ||||||
|                         </a> |                         </a> | ||||||
|  | |||||||
| @ -1,230 +0,0 @@ | |||||||
| {% extends "administration/base.html" %} |  | ||||||
|  |  | ||||||
| {% load i18n %} |  | ||||||
| {% load static %} |  | ||||||
|  |  | ||||||
| {% block content %} |  | ||||||
| <section class="pf-c-page__main-section pf-m-light"> |  | ||||||
|     <div class="pf-c-content"> |  | ||||||
|         <h1>{% trans 'System Overview' %}</h1> |  | ||||||
|     </div> |  | ||||||
| </section> |  | ||||||
| <section class="pf-c-page__main-section"> |  | ||||||
|     <div class="pf-l-gallery pf-m-gutter"> |  | ||||||
|         <div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-4-col" style="grid-column-end: span 3;grid-row-end: span 2;"> |  | ||||||
|             <div class="pf-c-card__header"> |  | ||||||
|                 <div class="pf-c-card__header-main"> |  | ||||||
|                     <i class="pf-icon pf-icon-server"></i> {% trans 'Logins over the last 24 hours' %} |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|             <div class="pf-c-card__body"> |  | ||||||
|                 <ak-admin-logins-chart url="{% url 'authentik_api:admin_metrics-list' %}"></ak-admin-logins-chart> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|         <div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-4-col" style="grid-column-end: span 2;grid-row-end: span 3;"> |  | ||||||
|             <div class="pf-c-card__header"> |  | ||||||
|                 <div class="pf-c-card__header-main"> |  | ||||||
|                     <i class="pf-icon pf-icon-server"></i> {% trans 'Apps with most usage' %} |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|             <div class="pf-c-card__body"> |  | ||||||
|                 <table class="pf-c-table pf-m-compact" role="grid"> |  | ||||||
|                     <thead> |  | ||||||
|                         <tr role="row"> |  | ||||||
|                             <th role="columnheader" scope="col">{% trans 'Application' %}</th> |  | ||||||
|                             <th role="columnheader" scope="col">{% trans 'Logins' %}</th> |  | ||||||
|                             <th role="columnheader" scope="col"></th> |  | ||||||
|                         </tr> |  | ||||||
|                     </thead> |  | ||||||
|                     <tbody role="rowgroup"> |  | ||||||
|                         {% for app in most_used_applications %} |  | ||||||
|                         <tr role="row"> |  | ||||||
|                             <td role="cell"> |  | ||||||
|                                 {{ app.application.name }} |  | ||||||
|                             </td> |  | ||||||
|                             <td role="cell"> |  | ||||||
|                                 {{ app.total_logins }} |  | ||||||
|                             </td> |  | ||||||
|                             <td role="cell"> |  | ||||||
|                                 <progress value="{{ app.total_logins }}" max="{{ most_used_applications.0.total_logins }}"></progress> |  | ||||||
|                             </td> |  | ||||||
|                         </tr> |  | ||||||
|                         {% endfor %} |  | ||||||
|                     </tbody> |  | ||||||
|                 </table> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact"> |  | ||||||
|             <div class="pf-c-card__header pf-l-flex pf-m-justify-content-space-between"> |  | ||||||
|                 <div class="pf-c-card__header-main"> |  | ||||||
|                     <i class="pf-icon pf-icon-plugged"></i> {% trans 'Providers' %} |  | ||||||
|                 </div> |  | ||||||
|                 <a href="{% url 'authentik_admin:providers' %}"> |  | ||||||
|                     <i class="fa fa-external-link-alt"> </i> |  | ||||||
|                 </a> |  | ||||||
|             </div> |  | ||||||
|             <div class="pf-c-card__body"> |  | ||||||
|                 {% if providers_without_application.exists %} |  | ||||||
|                 <p class="ak-aggregate-card"> |  | ||||||
|                     <i class="fa fa-exclamation-triangle"></i> {{ provider_count }} |  | ||||||
|                 </p> |  | ||||||
|                 <p>{% trans 'Warning: At least one Provider has no application assigned.' %}</p> |  | ||||||
|                 {% else %} |  | ||||||
|                 <p class="ak-aggregate-card"> |  | ||||||
|                     <i class="fa fa-check-circle"></i> {{ provider_count }} |  | ||||||
|                 </p> |  | ||||||
|                 {% endif %} |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact"> |  | ||||||
|             <div class="pf-c-card__header pf-l-flex pf-m-justify-content-space-between"> |  | ||||||
|                 <div class="pf-c-card__header-main"> |  | ||||||
|                     <i class="pf-icon pf-icon-infrastructure"></i> {% trans 'Policies' %} |  | ||||||
|                 </div> |  | ||||||
|                 <a href="{% url 'authentik_admin:policies' %}"> |  | ||||||
|                     <i class="fa fa-external-link-alt"> </i> |  | ||||||
|                 </a> |  | ||||||
|             </div> |  | ||||||
|             <div class="pf-c-card__body"> |  | ||||||
|                 {% if policies_without_binding %} |  | ||||||
|                 <p class="ak-aggregate-card"> |  | ||||||
|                     <i class="fa fa-exclamation-triangle"></i> {{ policy_count }} |  | ||||||
|                 </p> |  | ||||||
|                 <p>{% trans 'Policies without binding exist.' %}</p> |  | ||||||
|                 {% else %} |  | ||||||
|                 <p class="ak-aggregate-card"> |  | ||||||
|                     <i class="fa fa-check-circle"></i> {{ policy_count }} |  | ||||||
|                 </p> |  | ||||||
|                 {% endif %} |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact"> |  | ||||||
|             <div class="pf-c-card__header pf-l-flex pf-m-justify-content-space-between"> |  | ||||||
|                 <div class="pf-c-card__header-main"> |  | ||||||
|                     <i class="pf-icon pf-icon-user"></i> {% trans 'Users' %} |  | ||||||
|                 </div> |  | ||||||
|                 <a href="{% url 'authentik_admin:users' %}"> |  | ||||||
|                     <i class="fa fa-external-link-alt"> </i> |  | ||||||
|                 </a> |  | ||||||
|             </div> |  | ||||||
|             <div class="pf-c-card__body"> |  | ||||||
|                 <p class="ak-aggregate-card"> |  | ||||||
|                     <i class="fa fa-check-circle"></i> {{ user_count }} |  | ||||||
|                 </p> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact"> |  | ||||||
|             <div class="pf-c-card__header pf-l-flex pf-m-justify-content-space-between"> |  | ||||||
|                 <div class="pf-c-card__header-main"> |  | ||||||
|                     <i class="pf-icon pf-icon-bundle"></i> {% trans 'Version' %} |  | ||||||
|                 </div> |  | ||||||
|                 <a href="https://github.com/BeryJu/authentik/releases" target="_blank"> |  | ||||||
|                     <i class="fa fa-external-link-alt"> </i> |  | ||||||
|                 </a> |  | ||||||
|             </div> |  | ||||||
|             <div class="pf-c-card__body"> |  | ||||||
|                 <p class="ak-aggregate-card"> |  | ||||||
|                     {% if version >= version_latest %} |  | ||||||
|                     <i class="fa fa-check-circle"></i> {{ version }} |  | ||||||
|                     {% else %} |  | ||||||
|                     <i class="fa fa-exclamation-triangle"></i> {{ version }} |  | ||||||
|                     {% endif %} |  | ||||||
|                 </p> |  | ||||||
|                 {% if version >= version_latest %} |  | ||||||
|                     {% blocktrans %} |  | ||||||
|                     Up-to-date! |  | ||||||
|                     {% endblocktrans %} |  | ||||||
|                 {% else %} |  | ||||||
|                     {% blocktrans with latest=version_latest %} |  | ||||||
|                     {{ latest }} is available! |  | ||||||
|                     {% endblocktrans %} |  | ||||||
|                 {% endif %} |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact"> |  | ||||||
|             <div class="pf-c-card__header"> |  | ||||||
|                 <div class="pf-c-card__header-main"> |  | ||||||
|                     <i class="pf-icon pf-icon-server"></i> {% trans 'Workers' %} |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|             <fetch-fill-slot class="pf-c-card__body" url="{% url 'authentik_api:admin_overview-list' %}" key="worker_count"> |  | ||||||
|                 <div slot="value < 1"> |  | ||||||
|                     <p class="ak-aggregate-card"> |  | ||||||
|                         <i class="fa fa-exclamation-triangle"></i> <span data-value></span> |  | ||||||
|                     </p> |  | ||||||
|                     <p>{% trans 'No workers connected.' %}</p> |  | ||||||
|                 </div> |  | ||||||
|                 <div slot="value >= 1"> |  | ||||||
|                     <p class="ak-aggregate-card"> |  | ||||||
|                         <i class="fa fa-check-circle"></i> <span data-value></span> |  | ||||||
|                     </p> |  | ||||||
|                 </div> |  | ||||||
|                 <div> |  | ||||||
|                     <span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading..."> |  | ||||||
|                         <span class="pf-c-spinner__clipper"></span> |  | ||||||
|                         <span class="pf-c-spinner__lead-ball"></span> |  | ||||||
|                         <span class="pf-c-spinner__tail-ball"></span> |  | ||||||
|                     </span> |  | ||||||
|                 </div> |  | ||||||
|             </fetch-fill-slot> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact"> |  | ||||||
|             <div class="pf-c-card__header pf-l-flex pf-m-justify-content-space-between"> |  | ||||||
|                 <div class="pf-c-card__header-main"> |  | ||||||
|                     <i class="pf-icon pf-icon-server"></i> {% trans 'Cached Policies' %} |  | ||||||
|                 </div> |  | ||||||
|                 <ak-modal-button href="{% url 'authentik_admin:overview-clear-policy-cache' %}"> |  | ||||||
|                     <a slot="trigger"> |  | ||||||
|                         <i class="fa fa-trash"> </i> |  | ||||||
|                     </a> |  | ||||||
|                     <div slot="modal"></div> |  | ||||||
|                 </ak-modal-button> |  | ||||||
|             </div> |  | ||||||
|             <div class="pf-c-card__body"> |  | ||||||
|                 {% if cached_policies < 1 %} |  | ||||||
|                 <p class="ak-aggregate-card"> |  | ||||||
|                     <i class="fa fa-exclamation-triangle"></i> {{ cached_policies }} |  | ||||||
|                 </p> |  | ||||||
|                 <p>{% trans 'No policies cached. Users may experience slow response times.' %}</p> |  | ||||||
|                 {% else %} |  | ||||||
|                 <p class="ak-aggregate-card"> |  | ||||||
|                     <i class="fa fa-check-circle"></i> {{ cached_policies }} |  | ||||||
|                 </p> |  | ||||||
|                 {% endif %} |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact"> |  | ||||||
|             <div class="pf-c-card__header pf-l-flex pf-m-justify-content-space-between"> |  | ||||||
|                 <div class="pf-c-card__header-main"> |  | ||||||
|                     <i class="pf-icon pf-icon-server"></i> {% trans 'Cached Flows' %} |  | ||||||
|                 </div> |  | ||||||
|                 <ak-modal-button href="{% url 'authentik_admin:overview-clear-flow-cache' %}"> |  | ||||||
|                     <a slot="trigger"> |  | ||||||
|                         <i class="fa fa-trash"> </i> |  | ||||||
|                     </a> |  | ||||||
|                     <div slot="modal"></div> |  | ||||||
|                 </ak-modal-button> |  | ||||||
|             </div> |  | ||||||
|             <div class="pf-c-card__body"> |  | ||||||
|                 {% if cached_flows < 1 %} |  | ||||||
|                 <p class="ak-aggregate-card"> |  | ||||||
|                     <span class="fa fa-exclamation-triangle"></span> {{ cached_flows }} |  | ||||||
|                 </p> |  | ||||||
|                 <p>{% trans 'No flows cached.' %}</p> |  | ||||||
|                 {% else %} |  | ||||||
|                 <p class="ak-aggregate-card"> |  | ||||||
|                     <i class="fa fa-check-circle"></i> {{ cached_flows }} |  | ||||||
|                 </p> |  | ||||||
|                 {% endif %} |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     </div> |  | ||||||
| </section> |  | ||||||
| {% endblock %} |  | ||||||
| @ -81,7 +81,7 @@ | |||||||
|                             <div slot="modal"></div> |                             <div slot="modal"></div> | ||||||
|                         </ak-modal-button> |                         </ak-modal-button> | ||||||
|                         <ak-modal-button href="{% url 'authentik_admin:policy-test' pk=policy.pk %}"> |                         <ak-modal-button href="{% url 'authentik_admin:policy-test' pk=policy.pk %}"> | ||||||
|                             <ak-spinner-button slot="trigger" class="pf-m-tertiary"> |                             <ak-spinner-button slot="trigger" class="pf-m-secondary"> | ||||||
|                                 {% trans 'Test' %} |                                 {% trans 'Test' %} | ||||||
|                             </ak-spinner-button> |                             </ak-spinner-button> | ||||||
|                             <div slot="modal"></div> |                             <div slot="modal"></div> | ||||||
|  | |||||||
| @ -41,6 +41,17 @@ | |||||||
|                                 </ak-modal-button> |                                 </ak-modal-button> | ||||||
|                             </li> |                             </li> | ||||||
|                             {% endfor %} |                             {% endfor %} | ||||||
|  |                             <li> | ||||||
|  |                                 <ak-modal-button href="{% url 'authentik_admin:provider-saml-from-metadata' %}"> | ||||||
|  |                                     <button slot="trigger" class="pf-c-dropdown__menu-item"> | ||||||
|  |                                         {% trans 'SAML Provider from Metadata' %}<br> | ||||||
|  |                                         <small> | ||||||
|  |                                             {% trans "Create a SAML Provider by importing its Metadata." %} | ||||||
|  |                                         </small> | ||||||
|  |                                     </button> | ||||||
|  |                                     <div slot="modal"></div> | ||||||
|  |                                 </ak-modal-button> | ||||||
|  |                             </li> | ||||||
|                         </ul> |                         </ul> | ||||||
|                     </ak-dropdown> |                     </ak-dropdown> | ||||||
|                     <button role="ak-refresh" class="pf-c-button pf-m-primary"> |                     <button role="ak-refresh" class="pf-c-button pf-m-primary"> | ||||||
|  | |||||||
| @ -37,8 +37,9 @@ | |||||||
|         <table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid"> |         <table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid"> | ||||||
|             <thead> |             <thead> | ||||||
|                 <tr role="row"> |                 <tr role="row"> | ||||||
|  |                     <th role="columnheader" scope="col">{% trans 'ID' %}</th> | ||||||
|  |                     <th role="columnheader" scope="col">{% trans 'Created by' %}</th> | ||||||
|                     <th role="columnheader" scope="col">{% trans 'Expiry' %}</th> |                     <th role="columnheader" scope="col">{% trans 'Expiry' %}</th> | ||||||
|                     <th role="columnheader" scope="col">{% trans 'Link' %}</th> |  | ||||||
|                     <th role="cell"></th> |                     <th role="cell"></th> | ||||||
|                 </tr> |                 </tr> | ||||||
|             </thead> |             </thead> | ||||||
| @ -47,12 +48,17 @@ | |||||||
|                 <tr role="row"> |                 <tr role="row"> | ||||||
|                     <td role="cell"> |                     <td role="cell"> | ||||||
|                         <span> |                         <span> | ||||||
|                             {{ invitation.expiry }} |                             {{ invitation.invite_uuid }} | ||||||
|                         </span> |                         </span> | ||||||
|                     </td> |                     </td> | ||||||
|                     <td role="cell"> |                     <td role="cell"> | ||||||
|                         <span> |                         <span> | ||||||
|                             {{ invitation.Link }} |                             {{ invitation.created_by }} | ||||||
|  |                         </span> | ||||||
|  |                     </td> | ||||||
|  |                     <td role="cell"> | ||||||
|  |                         <span> | ||||||
|  |                             {{ invitation.expiry|default:"-" }} | ||||||
|                         </span> |                         </span> | ||||||
|                     </td> |                     </td> | ||||||
|                     <td> |                     <td> | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ from django.test import TestCase | |||||||
|  |  | ||||||
| from authentik import __version__ | from authentik import __version__ | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
|  | from authentik.core.tasks import clean_expired_models | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestAdminAPI(TestCase): | class TestAdminAPI(TestCase): | ||||||
| @ -19,19 +20,54 @@ class TestAdminAPI(TestCase): | |||||||
|         self.group.save() |         self.group.save() | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|  |  | ||||||
|     def test_overview(self): |     def test_tasks(self): | ||||||
|         """Test Overview API""" |         """Test Task API""" | ||||||
|         response = self.client.get(reverse("authentik_api:admin_overview-list")) |         clean_expired_models.delay() | ||||||
|  |         response = self.client.get(reverse("authentik_api:admin_system_tasks-list")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         body = loads(response.content) |         body = loads(response.content) | ||||||
|         self.assertEqual(body["version"], __version__) |         self.assertTrue( | ||||||
|  |             any([task["task_name"] == "clean_expired_models" for task in body]) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_tasks_retry(self): | ||||||
|  |         """Test Task API (retry)""" | ||||||
|  |         clean_expired_models.delay() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_api:admin_system_tasks-retry", | ||||||
|  |                 kwargs={"pk": "clean_expired_models"}, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         body = loads(response.content) | ||||||
|  |         self.assertTrue(body["successful"]) | ||||||
|  |  | ||||||
|  |     def test_tasks_retry_404(self): | ||||||
|  |         """Test Task API (retry, 404)""" | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_api:admin_system_tasks-retry", | ||||||
|  |                 kwargs={"pk": "qwerqewrqrqewrqewr"}, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|  |     def test_version(self): | ||||||
|  |         """Test Version API""" | ||||||
|  |         response = self.client.get(reverse("authentik_api:admin_version-list")) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         body = loads(response.content) | ||||||
|  |         self.assertEqual(body["version_current"], __version__) | ||||||
|  |  | ||||||
|  |     def test_workers(self): | ||||||
|  |         """Test Workers API""" | ||||||
|  |         response = self.client.get(reverse("authentik_api:admin_workers-list")) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         body = loads(response.content) | ||||||
|  |         self.assertEqual(body["pagination"]["count"], 0) | ||||||
|  |  | ||||||
|     def test_metrics(self): |     def test_metrics(self): | ||||||
|         """Test metrics API""" |         """Test metrics API""" | ||||||
|         response = self.client.get(reverse("authentik_api:admin_metrics-list")) |         response = self.client.get(reverse("authentik_api:admin_metrics-list")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|     def test_tasks(self): |  | ||||||
|         """Test tasks metrics API""" |  | ||||||
|         response = self.client.get(reverse("authentik_api:admin_system_tasks-list")) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|  | |||||||
							
								
								
									
										76
									
								
								authentik/admin/tests/test_tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								authentik/admin/tests/test_tasks.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,76 @@ | |||||||
|  | """test admin tasks""" | ||||||
|  | import json | ||||||
|  | from dataclasses import dataclass | ||||||
|  | from unittest.mock import Mock, patch | ||||||
|  |  | ||||||
|  | from django.core.cache import cache | ||||||
|  | from django.test import TestCase | ||||||
|  | from requests.exceptions import RequestException | ||||||
|  |  | ||||||
|  | from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class MockResponse: | ||||||
|  |     """Mock class to emulate the methods of requests's Response we need""" | ||||||
|  |  | ||||||
|  |     status_code: int | ||||||
|  |     response: str | ||||||
|  |  | ||||||
|  |     def json(self) -> dict: | ||||||
|  |         """Get json parsed response""" | ||||||
|  |         return json.loads(self.response) | ||||||
|  |  | ||||||
|  |     def raise_for_status(self): | ||||||
|  |         """raise RequestException if status code is 400 or more""" | ||||||
|  |         if self.status_code >= 400: | ||||||
|  |             raise RequestException | ||||||
|  |  | ||||||
|  |  | ||||||
|  | REQUEST_MOCK_VALID = Mock( | ||||||
|  |     return_value=MockResponse( | ||||||
|  |         200, | ||||||
|  |         """{ | ||||||
|  |             "tag_name": "version/1.2.3" | ||||||
|  |         }""", | ||||||
|  |     ) | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | REQUEST_MOCK_INVALID = Mock(return_value=MockResponse(400, "{}")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestAdminTasks(TestCase): | ||||||
|  |     """test admin tasks""" | ||||||
|  |  | ||||||
|  |     @patch("authentik.admin.tasks.get", REQUEST_MOCK_VALID) | ||||||
|  |     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.assertTrue( | ||||||
|  |             Event.objects.filter( | ||||||
|  |                 action=EventAction.UPDATE_AVAILABLE, context__new_version="1.2.3" | ||||||
|  |             ).exists() | ||||||
|  |         ) | ||||||
|  |         # test that a consecutive check doesn't create a duplicate event | ||||||
|  |         update_latest_version.delay().get() | ||||||
|  |         self.assertEqual( | ||||||
|  |             len( | ||||||
|  |                 Event.objects.filter( | ||||||
|  |                     action=EventAction.UPDATE_AVAILABLE, context__new_version="1.2.3" | ||||||
|  |                 ) | ||||||
|  |             ), | ||||||
|  |             1, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     @patch("authentik.admin.tasks.get", REQUEST_MOCK_INVALID) | ||||||
|  |     def test_version_error(self): | ||||||
|  |         """Test Update checker with invalid response""" | ||||||
|  |         update_latest_version.delay().get() | ||||||
|  |         self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0") | ||||||
|  |         self.assertFalse( | ||||||
|  |             Event.objects.filter( | ||||||
|  |                 action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0" | ||||||
|  |             ).exists() | ||||||
|  |         ) | ||||||
| @ -22,6 +22,7 @@ from authentik.admin.views import ( | |||||||
|     tokens, |     tokens, | ||||||
|     users, |     users, | ||||||
| ) | ) | ||||||
|  | from authentik.providers.saml.views import MetadataImportView | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     path( |     path( | ||||||
| @ -34,11 +35,7 @@ urlpatterns = [ | |||||||
|         overview.PolicyCacheClearView.as_view(), |         overview.PolicyCacheClearView.as_view(), | ||||||
|         name="overview-clear-policy-cache", |         name="overview-clear-policy-cache", | ||||||
|     ), |     ), | ||||||
|     path("overview/", overview.AdministrationOverviewView.as_view(), name="overview"), |  | ||||||
|     # Applications |     # Applications | ||||||
|     path( |  | ||||||
|         "applications/", applications.ApplicationListView.as_view(), name="applications" |  | ||||||
|     ), |  | ||||||
|     path( |     path( | ||||||
|         "applications/create/", |         "applications/create/", | ||||||
|         applications.ApplicationCreateView.as_view(), |         applications.ApplicationCreateView.as_view(), | ||||||
| @ -120,6 +117,11 @@ urlpatterns = [ | |||||||
|         providers.ProviderCreateView.as_view(), |         providers.ProviderCreateView.as_view(), | ||||||
|         name="provider-create", |         name="provider-create", | ||||||
|     ), |     ), | ||||||
|  |     path( | ||||||
|  |         "providers/create/saml/from-metadata/", | ||||||
|  |         MetadataImportView.as_view(), | ||||||
|  |         name="provider-saml-from-metadata", | ||||||
|  |     ), | ||||||
|     path( |     path( | ||||||
|         "providers/<int:pk>/update/", |         "providers/<int:pk>/update/", | ||||||
|         providers.ProviderUpdateView.as_view(), |         providers.ProviderUpdateView.as_view(), | ||||||
|  | |||||||
| @ -6,44 +6,15 @@ from django.contrib.auth.mixins import ( | |||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import ListView, UpdateView | from django.views.generic import UpdateView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionRequiredMixin | ||||||
|  |  | ||||||
| from authentik.admin.views.utils import ( | from authentik.admin.views.utils import BackSuccessUrlMixin, DeleteMessageView | ||||||
|     BackSuccessUrlMixin, |  | ||||||
|     DeleteMessageView, |  | ||||||
|     SearchListMixin, |  | ||||||
|     UserPaginateListMixin, |  | ||||||
| ) |  | ||||||
| from authentik.core.forms.applications import ApplicationForm | from authentik.core.forms.applications import ApplicationForm | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
| from authentik.lib.views import CreateAssignPermView | from authentik.lib.views import CreateAssignPermView | ||||||
|  |  | ||||||
|  |  | ||||||
| class ApplicationListView( |  | ||||||
|     LoginRequiredMixin, |  | ||||||
|     PermissionListMixin, |  | ||||||
|     UserPaginateListMixin, |  | ||||||
|     SearchListMixin, |  | ||||||
|     ListView, |  | ||||||
| ): |  | ||||||
|     """Show list of all applications""" |  | ||||||
|  |  | ||||||
|     model = Application |  | ||||||
|     permission_required = "authentik_core.view_application" |  | ||||||
|     ordering = "name" |  | ||||||
|     template_name = "administration/application/list.html" |  | ||||||
|  |  | ||||||
|     search_fields = [ |  | ||||||
|         "name", |  | ||||||
|         "slug", |  | ||||||
|         "meta_launch_url", |  | ||||||
|         "meta_icon_url", |  | ||||||
|         "meta_description", |  | ||||||
|         "meta_publisher", |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ApplicationCreateView( | class ApplicationCreateView( | ||||||
|     SuccessMessageMixin, |     SuccessMessageMixin, | ||||||
|     BackSuccessUrlMixin, |     BackSuccessUrlMixin, | ||||||
| @ -58,7 +29,7 @@ class ApplicationCreateView( | |||||||
|     permission_required = "authentik_core.add_application" |     permission_required = "authentik_core.add_application" | ||||||
|  |  | ||||||
|     template_name = "generic/create.html" |     template_name = "generic/create.html" | ||||||
|     success_url = reverse_lazy("authentik_admin:applications") |     success_url = reverse_lazy("authentik_core:shell") | ||||||
|     success_message = _("Successfully created Application") |     success_message = _("Successfully created Application") | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -76,7 +47,7 @@ class ApplicationUpdateView( | |||||||
|     permission_required = "authentik_core.change_application" |     permission_required = "authentik_core.change_application" | ||||||
|  |  | ||||||
|     template_name = "generic/update.html" |     template_name = "generic/update.html" | ||||||
|     success_url = reverse_lazy("authentik_admin:applications") |     success_url = reverse_lazy("authentik_core:shell") | ||||||
|     success_message = _("Successfully updated Application") |     success_message = _("Successfully updated Application") | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -89,5 +60,5 @@ class ApplicationDeleteView( | |||||||
|     permission_required = "authentik_core.delete_application" |     permission_required = "authentik_core.delete_application" | ||||||
|  |  | ||||||
|     template_name = "generic/delete.html" |     template_name = "generic/delete.html" | ||||||
|     success_url = reverse_lazy("authentik_admin:applications") |     success_url = reverse_lazy("authentik_core:shell") | ||||||
|     success_message = _("Successfully deleted Application") |     success_message = _("Successfully deleted Application") | ||||||
|  | |||||||
| @ -1,65 +1,25 @@ | |||||||
| """authentik administration overview""" | """authentik administration overview""" | ||||||
| from typing import Union |  | ||||||
|  |  | ||||||
| from django.conf import settings |  | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
| from django.http.response import HttpResponse | from django.http.response import HttpResponse | ||||||
| from django.urls import reverse_lazy |  | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import FormView, TemplateView | from django.views.generic import FormView | ||||||
| from packaging.version import LegacyVersion, Version, parse |  | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
| from authentik import __version__ |  | ||||||
| from authentik.admin.forms.overview import FlowCacheClearForm, PolicyCacheClearForm | from authentik.admin.forms.overview import FlowCacheClearForm, PolicyCacheClearForm | ||||||
| from authentik.admin.mixins import AdminRequiredMixin | from authentik.admin.mixins import AdminRequiredMixin | ||||||
| from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version |  | ||||||
| from authentik.core.models import Provider, User |  | ||||||
| from authentik.policies.models import Policy |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| class AdministrationOverviewView(AdminRequiredMixin, TemplateView): |  | ||||||
|     """Overview View""" |  | ||||||
|  |  | ||||||
|     template_name = "administration/overview.html" |  | ||||||
|  |  | ||||||
|     def get_latest_version(self) -> Union[LegacyVersion, Version]: |  | ||||||
|         """Get latest version from cache""" |  | ||||||
|         version_in_cache = cache.get(VERSION_CACHE_KEY) |  | ||||||
|         if not version_in_cache: |  | ||||||
|             if not settings.DEBUG: |  | ||||||
|                 update_latest_version.delay() |  | ||||||
|             return parse(__version__) |  | ||||||
|         return parse(version_in_cache) |  | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs): |  | ||||||
|         kwargs["policy_count"] = len(Policy.objects.all()) |  | ||||||
|         kwargs["user_count"] = len(User.objects.all()) - 1  # Remove anonymous user |  | ||||||
|         kwargs["provider_count"] = len(Provider.objects.all()) |  | ||||||
|         kwargs["version"] = parse(__version__) |  | ||||||
|         kwargs["version_latest"] = self.get_latest_version() |  | ||||||
|         kwargs["providers_without_application"] = Provider.objects.filter( |  | ||||||
|             application=None |  | ||||||
|         ) |  | ||||||
|         kwargs["policies_without_binding"] = len( |  | ||||||
|             Policy.objects.filter(bindings__isnull=True, promptstage__isnull=True) |  | ||||||
|         ) |  | ||||||
|         kwargs["cached_policies"] = len(cache.keys("policy_*")) |  | ||||||
|         kwargs["cached_flows"] = len(cache.keys("flow_*")) |  | ||||||
|         return super().get_context_data(**kwargs) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class PolicyCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView): | class PolicyCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView): | ||||||
|     """View to clear Policy cache""" |     """View to clear Policy cache""" | ||||||
|  |  | ||||||
|     form_class = PolicyCacheClearForm |     form_class = PolicyCacheClearForm | ||||||
|  |  | ||||||
|     template_name = "generic/form_non_model.html" |     template_name = "generic/form_non_model.html" | ||||||
|     success_url = reverse_lazy("authentik_admin:overview") |     success_url = "/" | ||||||
|     success_message = _("Successfully cleared Policy cache") |     success_message = _("Successfully cleared Policy cache") | ||||||
|  |  | ||||||
|     def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
| @ -75,7 +35,7 @@ class FlowCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView): | |||||||
|     form_class = FlowCacheClearForm |     form_class = FlowCacheClearForm | ||||||
|  |  | ||||||
|     template_name = "generic/form_non_model.html" |     template_name = "generic/form_non_model.html" | ||||||
|     success_url = reverse_lazy("authentik_admin:overview") |     success_url = "/" | ||||||
|     success_message = _("Successfully cleared Flow cache") |     success_message = _("Successfully cleared Flow cache") | ||||||
|  |  | ||||||
|     def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|  | |||||||
| @ -19,7 +19,6 @@ from authentik.admin.views.utils import ( | |||||||
| from authentik.lib.views import CreateAssignPermView | from authentik.lib.views import CreateAssignPermView | ||||||
| from authentik.stages.invitation.forms import InvitationForm | from authentik.stages.invitation.forms import InvitationForm | ||||||
| from authentik.stages.invitation.models import Invitation | from authentik.stages.invitation.models import Invitation | ||||||
| from authentik.stages.invitation.signals import invitation_created |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class InvitationListView( | class InvitationListView( | ||||||
| @ -59,7 +58,6 @@ class InvitationCreateView( | |||||||
|         obj = form.save(commit=False) |         obj = form.save(commit=False) | ||||||
|         obj.created_by = self.request.user |         obj.created_by = self.request.user | ||||||
|         obj.save() |         obj.save() | ||||||
|         invitation_created.send(sender=self, request=self.request, invitation=obj) |  | ||||||
|         return HttpResponseRedirect(self.success_url) |         return HttpResponseRedirect(self.success_url) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| """core Configs API""" | """core Configs API""" | ||||||
|  | from django.db.models import Model | ||||||
| from drf_yasg2.utils import swagger_auto_schema | from drf_yasg2.utils import swagger_auto_schema | ||||||
| from rest_framework.permissions import AllowAny | from rest_framework.permissions import AllowAny | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -19,10 +20,10 @@ class ConfigSerializer(Serializer): | |||||||
|     error_reporting_environment = ReadOnlyField() |     error_reporting_environment = ReadOnlyField() | ||||||
|     error_reporting_send_pii = ReadOnlyField() |     error_reporting_send_pii = ReadOnlyField() | ||||||
|  |  | ||||||
|     def create(self, request: Request) -> Response: |     def create(self, validated_data: dict) -> Model: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     def update(self, request: Request) -> Response: |     def update(self, instance: Model, validated_data: dict) -> Model: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| """core messages API""" | """core messages API""" | ||||||
| from django.contrib.messages import get_messages | from django.contrib.messages import get_messages | ||||||
|  | from django.db.models import Model | ||||||
| from drf_yasg2.utils import swagger_auto_schema | from drf_yasg2.utils import swagger_auto_schema | ||||||
| from rest_framework.permissions import AllowAny | from rest_framework.permissions import AllowAny | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -17,10 +18,10 @@ class MessageSerializer(Serializer): | |||||||
|     extra_tags = ReadOnlyField() |     extra_tags = ReadOnlyField() | ||||||
|     level_tag = ReadOnlyField() |     level_tag = ReadOnlyField() | ||||||
|  |  | ||||||
|     def create(self, request: Request) -> Response: |     def create(self, validated_data: dict) -> Model: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     def update(self, request: Request) -> Response: |     def update(self, instance: Model, validated_data: dict) -> Model: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,12 +5,12 @@ from drf_yasg2.views import get_schema_view | |||||||
| from rest_framework import routers | from rest_framework import routers | ||||||
| from rest_framework.permissions import AllowAny | from rest_framework.permissions import AllowAny | ||||||
|  |  | ||||||
| from authentik.admin.api.overview import AdministrationOverviewViewSet | from authentik.admin.api.metrics import AdministrationMetricsViewSet | ||||||
| from authentik.admin.api.overview_metrics import AdministrationMetricsViewSet |  | ||||||
| from authentik.admin.api.tasks import TaskViewSet | from authentik.admin.api.tasks import TaskViewSet | ||||||
|  | from authentik.admin.api.version import VersionViewSet | ||||||
|  | from authentik.admin.api.workers import WorkerViewSet | ||||||
| from authentik.api.v2.config import ConfigsViewSet | from authentik.api.v2.config import ConfigsViewSet | ||||||
| from authentik.api.v2.messages import MessagesViewSet | from authentik.api.v2.messages import MessagesViewSet | ||||||
| from authentik.audit.api import EventViewSet |  | ||||||
| from authentik.core.api.applications import ApplicationViewSet | from authentik.core.api.applications import ApplicationViewSet | ||||||
| from authentik.core.api.groups import GroupViewSet | from authentik.core.api.groups import GroupViewSet | ||||||
| from authentik.core.api.propertymappings import PropertyMappingViewSet | from authentik.core.api.propertymappings import PropertyMappingViewSet | ||||||
| @ -19,13 +19,23 @@ from authentik.core.api.sources import SourceViewSet | |||||||
| from authentik.core.api.tokens import TokenViewSet | from authentik.core.api.tokens import TokenViewSet | ||||||
| from authentik.core.api.users import UserViewSet | from authentik.core.api.users import UserViewSet | ||||||
| from authentik.crypto.api import CertificateKeyPairViewSet | from authentik.crypto.api import CertificateKeyPairViewSet | ||||||
| from authentik.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet | from authentik.events.api import EventViewSet | ||||||
|  | from authentik.flows.api import ( | ||||||
|  |     FlowCacheViewSet, | ||||||
|  |     FlowStageBindingViewSet, | ||||||
|  |     FlowViewSet, | ||||||
|  |     StageViewSet, | ||||||
|  | ) | ||||||
| from authentik.outposts.api import ( | from authentik.outposts.api import ( | ||||||
|     DockerServiceConnectionViewSet, |     DockerServiceConnectionViewSet, | ||||||
|     KubernetesServiceConnectionViewSet, |     KubernetesServiceConnectionViewSet, | ||||||
|     OutpostViewSet, |     OutpostViewSet, | ||||||
| ) | ) | ||||||
| from authentik.policies.api import PolicyBindingViewSet, PolicyViewSet | from authentik.policies.api import ( | ||||||
|  |     PolicyBindingViewSet, | ||||||
|  |     PolicyCacheViewSet, | ||||||
|  |     PolicyViewSet, | ||||||
|  | ) | ||||||
| from authentik.policies.dummy.api import DummyPolicyViewSet | from authentik.policies.dummy.api import DummyPolicyViewSet | ||||||
| from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet | from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet | ||||||
| from authentik.policies.expression.api import ExpressionPolicyViewSet | from authentik.policies.expression.api import ExpressionPolicyViewSet | ||||||
| @ -63,9 +73,8 @@ router = routers.DefaultRouter() | |||||||
| router.register("root/messages", MessagesViewSet, basename="messages") | router.register("root/messages", MessagesViewSet, basename="messages") | ||||||
| router.register("root/config", ConfigsViewSet, basename="configs") | router.register("root/config", ConfigsViewSet, basename="configs") | ||||||
|  |  | ||||||
| router.register( | router.register("admin/version", VersionViewSet, basename="admin_version") | ||||||
|     "admin/overview", AdministrationOverviewViewSet, basename="admin_overview" | router.register("admin/workers", WorkerViewSet, basename="admin_workers") | ||||||
| ) |  | ||||||
| router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics") | router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics") | ||||||
| router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") | router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") | ||||||
|  |  | ||||||
| @ -82,11 +91,12 @@ router.register( | |||||||
| router.register("outposts/proxy", ProxyOutpostConfigViewSet) | router.register("outposts/proxy", ProxyOutpostConfigViewSet) | ||||||
|  |  | ||||||
| router.register("flows/instances", FlowViewSet) | router.register("flows/instances", FlowViewSet) | ||||||
|  | router.register("flows/cached", FlowCacheViewSet, basename="flows_cache") | ||||||
| router.register("flows/bindings", FlowStageBindingViewSet) | router.register("flows/bindings", FlowStageBindingViewSet) | ||||||
|  |  | ||||||
| router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet) | router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet) | ||||||
|  |  | ||||||
| router.register("audit/events", EventViewSet) | router.register("events/events", EventViewSet) | ||||||
|  |  | ||||||
| router.register("sources/all", SourceViewSet) | router.register("sources/all", SourceViewSet) | ||||||
| router.register("sources/ldap", LDAPSourceViewSet) | router.register("sources/ldap", LDAPSourceViewSet) | ||||||
| @ -94,6 +104,7 @@ router.register("sources/saml", SAMLSourceViewSet) | |||||||
| router.register("sources/oauth", OAuthSourceViewSet) | router.register("sources/oauth", OAuthSourceViewSet) | ||||||
|  |  | ||||||
| router.register("policies/all", PolicyViewSet) | router.register("policies/all", PolicyViewSet) | ||||||
|  | router.register("policies/cached", PolicyCacheViewSet, basename="policies_cache") | ||||||
| router.register("policies/bindings", PolicyBindingViewSet) | router.register("policies/bindings", PolicyBindingViewSet) | ||||||
| router.register("policies/expression", ExpressionPolicyViewSet) | router.register("policies/expression", ExpressionPolicyViewSet) | ||||||
| router.register("policies/group_membership", GroupMembershipPolicyViewSet) | router.register("policies/group_membership", GroupMembershipPolicyViewSet) | ||||||
| @ -137,7 +148,9 @@ info = openapi.Info( | |||||||
|     title="authentik API", |     title="authentik API", | ||||||
|     default_version="v2", |     default_version="v2", | ||||||
|     contact=openapi.Contact(email="hello@beryju.org"), |     contact=openapi.Contact(email="hello@beryju.org"), | ||||||
|     license=openapi.License(name="MIT License"), |     license=openapi.License( | ||||||
|  |         name="GNU GPLv3", url="https://github.com/BeryJu/authentik/blob/master/LICENSE" | ||||||
|  |     ), | ||||||
| ) | ) | ||||||
| SchemaView = get_schema_view( | SchemaView = get_schema_view( | ||||||
|     info, |     info, | ||||||
|  | |||||||
| @ -1,16 +0,0 @@ | |||||||
| """authentik audit app""" |  | ||||||
| from importlib import import_module |  | ||||||
|  |  | ||||||
| from django.apps import AppConfig |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikAuditConfig(AppConfig): |  | ||||||
|     """authentik audit app""" |  | ||||||
|  |  | ||||||
|     name = "authentik.audit" |  | ||||||
|     label = "authentik_audit" |  | ||||||
|     verbose_name = "authentik Audit" |  | ||||||
|     mountpoint = "audit/" |  | ||||||
|  |  | ||||||
|     def ready(self): |  | ||||||
|         import_module("authentik.audit.signals") |  | ||||||
| @ -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 'Audit 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 %} |  | ||||||
| @ -1,9 +0,0 @@ | |||||||
| """authentik audit urls""" |  | ||||||
| from django.urls import path |  | ||||||
|  |  | ||||||
| from authentik.audit.views import EventListView |  | ||||||
|  |  | ||||||
| urlpatterns = [ |  | ||||||
|     # Audit Log |  | ||||||
|     path("audit/", EventListView.as_view(), name="log"), |  | ||||||
| ] |  | ||||||
| @ -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.audit.models import Event |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class EventListView( |  | ||||||
|     PermissionListMixin, |  | ||||||
|     LoginRequiredMixin, |  | ||||||
|     SearchListMixin, |  | ||||||
|     UserPaginateListMixin, |  | ||||||
|     ListView, |  | ||||||
| ): |  | ||||||
|     """Show list of all invitations""" |  | ||||||
|  |  | ||||||
|     model = Event |  | ||||||
|     template_name = "audit/list.html" |  | ||||||
|     permission_required = "authentik_audit.view_event" |  | ||||||
|     ordering = "-created" |  | ||||||
|  |  | ||||||
|     search_fields = [ |  | ||||||
|         "user", |  | ||||||
|         "action", |  | ||||||
|         "app", |  | ||||||
|         "context", |  | ||||||
|         "client_ip", |  | ||||||
|     ] |  | ||||||
| @ -11,9 +11,10 @@ from rest_framework.serializers import ModelSerializer | |||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||||
|  |  | ||||||
| from authentik.admin.api.overview_metrics import get_events_per_1h | from authentik.admin.api.metrics import get_events_per_1h | ||||||
| from authentik.audit.models import EventAction | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
|  | from authentik.events.models import EventAction | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -21,6 +22,7 @@ class ApplicationSerializer(ModelSerializer): | |||||||
|     """Application Serializer""" |     """Application Serializer""" | ||||||
|  |  | ||||||
|     launch_url = SerializerMethodField() |     launch_url = SerializerMethodField() | ||||||
|  |     provider = ProviderSerializer(source="get_provider", required=False) | ||||||
|  |  | ||||||
|     def get_launch_url(self, instance: Application) -> str: |     def get_launch_url(self, instance: Application) -> str: | ||||||
|         """Get generated launch URL""" |         """Get generated launch URL""" | ||||||
| @ -48,7 +50,15 @@ class ApplicationViewSet(ModelViewSet): | |||||||
|  |  | ||||||
|     queryset = Application.objects.all() |     queryset = Application.objects.all() | ||||||
|     serializer_class = ApplicationSerializer |     serializer_class = ApplicationSerializer | ||||||
|  |     search_fields = [ | ||||||
|  |         "name", | ||||||
|  |         "slug", | ||||||
|  |         "meta_launch_url", | ||||||
|  |         "meta_description", | ||||||
|  |         "meta_publisher", | ||||||
|  |     ] | ||||||
|     lookup_field = "slug" |     lookup_field = "slug" | ||||||
|  |     ordering = ["name"] | ||||||
|  |  | ||||||
|     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: |     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: | ||||||
|         """Custom filter_queryset method which ignores guardian, but still supports sorting""" |         """Custom filter_queryset method which ignores guardian, but still supports sorting""" | ||||||
| @ -63,7 +73,7 @@ class ApplicationViewSet(ModelViewSet): | |||||||
|         queryset = self._filter_queryset_for_list(self.get_queryset()) |         queryset = self._filter_queryset_for_list(self.get_queryset()) | ||||||
|         self.paginate_queryset(queryset) |         self.paginate_queryset(queryset) | ||||||
|         allowed_applications = [] |         allowed_applications = [] | ||||||
|         for application in queryset.order_by("name"): |         for application in queryset: | ||||||
|             engine = PolicyEngine(application, self.request.user, self.request) |             engine = PolicyEngine(application, self.request.user, self.request) | ||||||
|             engine.build() |             engine.build() | ||||||
|             if engine.passing: |             if engine.passing: | ||||||
| @ -78,7 +88,7 @@ class ApplicationViewSet(ModelViewSet): | |||||||
|             get_objects_for_user(request.user, "authentik_core.view_application"), |             get_objects_for_user(request.user, "authentik_core.view_application"), | ||||||
|             slug=slug, |             slug=slug, | ||||||
|         ) |         ) | ||||||
|         if not request.user.has_perm("authentik_audit.view_event"): |         if not request.user.has_perm("authentik_events.view_event"): | ||||||
|             raise Http404 |             raise Http404 | ||||||
|         return Response( |         return Response( | ||||||
|             get_events_per_1h( |             get_events_per_1h( | ||||||
|  | |||||||
| @ -1,30 +1,49 @@ | |||||||
| """Provider API Views""" | """Provider API Views""" | ||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField | from rest_framework.serializers import ModelSerializer, SerializerMethodField | ||||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
|  | from authentik.core.api.utils import MetaNameSerializer | ||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
|  |  | ||||||
|  |  | ||||||
| class ProviderSerializer(ModelSerializer): | class ProviderSerializer(ModelSerializer, MetaNameSerializer): | ||||||
|     """Provider Serializer""" |     """Provider Serializer""" | ||||||
|  |  | ||||||
|     __type__ = SerializerMethodField(method_name="get_type") |     object_type = SerializerMethodField() | ||||||
|  |  | ||||||
|     def get_type(self, obj): |     def get_object_type(self, obj): | ||||||
|         """Get object type so that we know which API Endpoint to use to get the full object""" |         """Get object type so that we know which API Endpoint to use to get the full object""" | ||||||
|         return obj._meta.object_name.lower().replace("provider", "") |         return obj._meta.object_name.lower().replace("provider", "") | ||||||
|  |  | ||||||
|  |     def to_representation(self, instance: Provider): | ||||||
|  |         # pyright: reportGeneralTypeIssues=false | ||||||
|  |         if instance.__class__ == Provider: | ||||||
|  |             return super().to_representation(instance) | ||||||
|  |         return instance.serializer(instance=instance).data | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = Provider |         model = Provider | ||||||
|         fields = ["pk", "name", "authorization_flow", "property_mappings", "__type__"] |         fields = [ | ||||||
|  |             "pk", | ||||||
|  |             "name", | ||||||
|  |             "application", | ||||||
|  |             "authorization_flow", | ||||||
|  |             "property_mappings", | ||||||
|  |             "object_type", | ||||||
|  |             "verbose_name", | ||||||
|  |             "verbose_name_plural", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class ProviderViewSet(ReadOnlyModelViewSet): | class ProviderViewSet(ModelViewSet): | ||||||
|     """Provider Viewset""" |     """Provider Viewset""" | ||||||
|  |  | ||||||
|     queryset = Provider.objects.all() |     queryset = Provider.objects.all() | ||||||
|     serializer_class = ProviderSerializer |     serializer_class = ProviderSerializer | ||||||
|  |     filterset_fields = { | ||||||
|  |         "application": ["isnull"], | ||||||
|  |     } | ||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|         return Provider.objects.select_subclasses() |         return Provider.objects.select_subclasses() | ||||||
|  | |||||||
| @ -3,10 +3,11 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField | |||||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | from rest_framework.viewsets import ReadOnlyModelViewSet | ||||||
|  |  | ||||||
| from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS | from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS | ||||||
|  | from authentik.core.api.utils import MetaNameSerializer | ||||||
| from authentik.core.models import Source | from authentik.core.models import Source | ||||||
|  |  | ||||||
|  |  | ||||||
| class SourceSerializer(ModelSerializer): | class SourceSerializer(ModelSerializer, MetaNameSerializer): | ||||||
|     """Source Serializer""" |     """Source Serializer""" | ||||||
|  |  | ||||||
|     __type__ = SerializerMethodField(method_name="get_type") |     __type__ = SerializerMethodField(method_name="get_type") | ||||||
|  | |||||||
| @ -6,8 +6,8 @@ from rest_framework.response import Response | |||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.audit.models import Event, EventAction |  | ||||||
| from authentik.core.models import Token | from authentik.core.models import Token | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|  |  | ||||||
| class TokenSerializer(ModelSerializer): | class TokenSerializer(ModelSerializer): | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| """User API Views""" | """User API Views""" | ||||||
| from drf_yasg2.utils import swagger_auto_schema | from drf_yasg2.utils import swagger_auto_schema | ||||||
|  | from guardian.utils import get_anonymous_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| @ -36,6 +37,9 @@ class UserViewSet(ModelViewSet): | |||||||
|     queryset = User.objects.all() |     queryset = User.objects.all() | ||||||
|     serializer_class = UserSerializer |     serializer_class = UserSerializer | ||||||
|  |  | ||||||
|  |     def get_queryset(self): | ||||||
|  |         return User.objects.all().exclude(pk=get_anonymous_user().pk) | ||||||
|  |  | ||||||
|     @swagger_auto_schema(responses={200: UserSerializer(many=False)}) |     @swagger_auto_schema(responses={200: UserSerializer(many=False)}) | ||||||
|     @action(detail=False) |     @action(detail=False) | ||||||
|     # pylint: disable=invalid-name |     # pylint: disable=invalid-name | ||||||
|  | |||||||
							
								
								
									
										24
									
								
								authentik/core/api/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								authentik/core/api/utils.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | |||||||
|  | """API Utilities""" | ||||||
|  | from django.db.models import Model | ||||||
|  | from rest_framework.serializers import Serializer, SerializerMethodField | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MetaNameSerializer(Serializer): | ||||||
|  |     """Add verbose names to response""" | ||||||
|  |  | ||||||
|  |     verbose_name = SerializerMethodField() | ||||||
|  |     verbose_name_plural = SerializerMethodField() | ||||||
|  |  | ||||||
|  |     def create(self, validated_data: dict) -> Model: | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     def update(self, instance: Model, validated_data: dict) -> Model: | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     def get_verbose_name(self, obj: Model) -> str: | ||||||
|  |         """Return object's verbose_name""" | ||||||
|  |         return obj._meta.verbose_name | ||||||
|  |  | ||||||
|  |     def get_verbose_name_plural(self, obj: Model) -> str: | ||||||
|  |         """Return object's plural verbose_name""" | ||||||
|  |         return obj._meta.verbose_name_plural | ||||||
| @ -1,9 +1,11 @@ | |||||||
| """Property Mapping Evaluator""" | """Property Mapping Evaluator""" | ||||||
|  | from traceback import format_tb | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.expression.evaluator import BaseEvaluator | from authentik.lib.expression.evaluator import BaseEvaluator | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -19,3 +21,18 @@ class PropertyMappingEvaluator(BaseEvaluator): | |||||||
|         if request: |         if request: | ||||||
|             self._context["request"] = request |             self._context["request"] = request | ||||||
|         self._context.update(**kwargs) |         self._context.update(**kwargs) | ||||||
|  |  | ||||||
|  |     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.PROPERTY_MAPPING_EXCEPTION, | ||||||
|  |             expression=expression_source, | ||||||
|  |             error=error_string, | ||||||
|  |         ) | ||||||
|  |         if "user" in self._context: | ||||||
|  |             event.set_user(self._context["user"]) | ||||||
|  |         if "request" in self._context: | ||||||
|  |             event.from_http(self._context["request"]) | ||||||
|  |             return | ||||||
|  |         event.save() | ||||||
|  | |||||||
| @ -14,6 +14,7 @@ from django.utils.timezone import now | |||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from guardian.mixins import GuardianUserMixin | from guardian.mixins import GuardianUserMixin | ||||||
| from model_utils.managers import InheritanceManager | from model_utils.managers import InheritanceManager | ||||||
|  | from rest_framework.serializers import Serializer | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
| from authentik.core.exceptions import PropertyMappingExpressionException | from authentik.core.exceptions import PropertyMappingExpressionException | ||||||
| @ -127,7 +128,7 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|         verbose_name_plural = _("Users") |         verbose_name_plural = _("Users") | ||||||
|  |  | ||||||
|  |  | ||||||
| class Provider(models.Model): | class Provider(SerializerModel): | ||||||
|     """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" |     """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" | ||||||
|  |  | ||||||
|     name = models.TextField() |     name = models.TextField() | ||||||
| @ -156,6 +157,11 @@ class Provider(models.Model): | |||||||
|         """Return Form class used to edit this object""" |         """Return Form class used to edit this object""" | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def serializer(self) -> Type[Serializer]: | ||||||
|  |         """Get serializer for this model""" | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return self.name |         return self.name | ||||||
|  |  | ||||||
|  | |||||||
| @ -3,6 +3,15 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
| {% load authentik_utils %} | {% load authentik_utils %} | ||||||
|  |  | ||||||
|  | {% block head %} | ||||||
|  | {{ block.super }} | ||||||
|  | <style> | ||||||
|  |     .pf-c-empty-state { | ||||||
|  |         height: 100vh; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <section class="pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | <section class="pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||||
|     <div class="pf-c-empty-state"> |     <div class="pf-c-empty-state"> | ||||||
|  | |||||||
| @ -31,7 +31,7 @@ | |||||||
|             <p class="pf-c-form__helper-text">{{ field.help_text }}</p> |             <p class="pf-c-form__helper-text">{{ field.help_text }}</p> | ||||||
|             {% endif %} |             {% endif %} | ||||||
|         </div> |         </div> | ||||||
|     {% elif field.field.widget|fieldtype == 'Select' %} |     {% elif field.field.widget|fieldtype == 'Select' or field.field.widget|fieldtype == "SelectMultiple" %} | ||||||
|         <div class="pf-c-form__group-label"> |         <div class="pf-c-form__group-label"> | ||||||
|             <label class="pf-c-form__label" for="{{ field.name }}-{{ forloop.counter0 }}"> |             <label class="pf-c-form__label" for="{{ field.name }}-{{ forloop.counter0 }}"> | ||||||
|                 <span class="pf-c-form__label-text">{{ field.label }}</span> |                 <span class="pf-c-form__label-text">{{ field.label }}</span> | ||||||
| @ -46,6 +46,9 @@ | |||||||
|                 {% if field.help_text %} |                 {% if field.help_text %} | ||||||
|                 <p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p> |                 <p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p> | ||||||
|                 {% endif %} |                 {% endif %} | ||||||
|  |                 {% if field.field.widget|fieldtype == 'SelectMultiple' %} | ||||||
|  |                 <p class="pf-c-form__helper-text">{% trans 'Hold control/command to select multiple items.' %}</p> | ||||||
|  |                 {% endif %} | ||||||
|             </div> |             </div> | ||||||
|         </div> |         </div> | ||||||
|     {% elif field.field.widget|fieldtype == 'CheckboxInput' %} |     {% elif field.field.widget|fieldtype == 'CheckboxInput' %} | ||||||
|  | |||||||
							
								
								
									
										56
									
								
								authentik/core/tests/test_property_mapping.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								authentik/core/tests/test_property_mapping.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,56 @@ | |||||||
|  | """authentik core property mapping tests""" | ||||||
|  | from django.test import RequestFactory, TestCase | ||||||
|  | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
|  | from authentik.core.exceptions import PropertyMappingExpressionException | ||||||
|  | from authentik.core.models import PropertyMapping | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestPropertyMappings(TestCase): | ||||||
|  |     """authentik core property mapping tests""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         super().setUp() | ||||||
|  |         self.factory = RequestFactory() | ||||||
|  |  | ||||||
|  |     def test_expression(self): | ||||||
|  |         """Test expression""" | ||||||
|  |         mapping = PropertyMapping.objects.create( | ||||||
|  |             name="test", expression="return 'test'" | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(mapping.evaluate(None, None), "test") | ||||||
|  |  | ||||||
|  |     def test_expression_syntax(self): | ||||||
|  |         """Test expression syntax error""" | ||||||
|  |         mapping = PropertyMapping.objects.create(name="test", expression="-") | ||||||
|  |         with self.assertRaises(PropertyMappingExpressionException): | ||||||
|  |             mapping.evaluate(None, None) | ||||||
|  |  | ||||||
|  |     def test_expression_error_general(self): | ||||||
|  |         """Test expression error""" | ||||||
|  |         expr = "return aaa" | ||||||
|  |         mapping = PropertyMapping.objects.create(name="test", expression=expr) | ||||||
|  |         with self.assertRaises(NameError): | ||||||
|  |             mapping.evaluate(None, None) | ||||||
|  |         events = Event.objects.filter( | ||||||
|  |             action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr | ||||||
|  |         ) | ||||||
|  |         self.assertTrue(events.exists()) | ||||||
|  |         self.assertEqual(len(events), 1) | ||||||
|  |  | ||||||
|  |     def test_expression_error_extended(self): | ||||||
|  |         """Test expression error (with user and http request""" | ||||||
|  |         expr = "return aaa" | ||||||
|  |         request = self.factory.get("/") | ||||||
|  |         mapping = PropertyMapping.objects.create(name="test", expression=expr) | ||||||
|  |         with self.assertRaises(NameError): | ||||||
|  |             mapping.evaluate(get_anonymous_user(), request) | ||||||
|  |         events = Event.objects.filter( | ||||||
|  |             action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr | ||||||
|  |         ) | ||||||
|  |         self.assertTrue(events.exists()) | ||||||
|  |         self.assertEqual(len(events), 1) | ||||||
|  |         event = events.first() | ||||||
|  |         self.assertEqual(event.user["username"], "AnonymousUser") | ||||||
|  |         self.assertEqual(event.client_ip, "127.0.0.1") | ||||||
| @ -25,7 +25,7 @@ urlpatterns = [ | |||||||
|         name="user-tokens-delete", |         name="user-tokens-delete", | ||||||
|     ), |     ), | ||||||
|     # Libray |     # Libray | ||||||
|     path("library/", library.LibraryView.as_view(), name="overview"), |     path("library", library.LibraryView.as_view(), name="overview"), | ||||||
|     # Impersonation |     # Impersonation | ||||||
|     path( |     path( | ||||||
|         "-/impersonation/<int:user_id>/", |         "-/impersonation/<int:user_id>/", | ||||||
|  | |||||||
| @ -5,12 +5,12 @@ from django.shortcuts import get_object_or_404, redirect | |||||||
| from django.views import View | from django.views import View | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
| from authentik.audit.models import Event, EventAction |  | ||||||
| from authentik.core.middleware import ( | from authentik.core.middleware import ( | ||||||
|     SESSION_IMPERSONATE_ORIGINAL_USER, |     SESSION_IMPERSONATE_ORIGINAL_USER, | ||||||
|     SESSION_IMPERSONATE_USER, |     SESSION_IMPERSONATE_USER, | ||||||
| ) | ) | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  | |||||||
| @ -94,11 +94,6 @@ class TokenCreateView( | |||||||
|     success_url = reverse_lazy("authentik_core:user-tokens") |     success_url = reverse_lazy("authentik_core:user-tokens") | ||||||
|     success_message = _("Successfully created Token") |     success_message = _("Successfully created Token") | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: |  | ||||||
|         kwargs = super().get_context_data(**kwargs) |  | ||||||
|         kwargs["container_template"] = "user/base.html" |  | ||||||
|         return kwargs |  | ||||||
|  |  | ||||||
|     def form_valid(self, form: UserTokenForm) -> HttpResponse: |     def form_valid(self, form: UserTokenForm) -> HttpResponse: | ||||||
|         form.instance.user = self.request.user |         form.instance.user = self.request.user | ||||||
|         form.instance.intent = TokenIntents.INTENT_API |         form.instance.intent = TokenIntents.INTENT_API | ||||||
| @ -112,21 +107,20 @@ class TokenUpdateView( | |||||||
|  |  | ||||||
|     model = Token |     model = Token | ||||||
|     form_class = UserTokenForm |     form_class = UserTokenForm | ||||||
|     permission_required = "authentik_core.update_token" |     permission_required = "authentik_core.change_token" | ||||||
|     template_name = "generic/update.html" |     template_name = "generic/update.html" | ||||||
|     success_url = reverse_lazy("authentik_core:user-tokens") |     success_url = reverse_lazy("authentik_core:user-tokens") | ||||||
|     success_message = _("Successfully updated Token") |     success_message = _("Successfully updated Token") | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: |  | ||||||
|         kwargs = super().get_context_data(**kwargs) |  | ||||||
|         kwargs["container_template"] = "user/base.html" |  | ||||||
|         return kwargs |  | ||||||
|  |  | ||||||
|     def get_object(self) -> Token: |     def get_object(self) -> Token: | ||||||
|         identifier = self.kwargs.get("identifier") |         identifier = self.kwargs.get("identifier") | ||||||
|         return get_objects_for_user( |         return ( | ||||||
|             self.request.user, "authentik_core.update_token", self.model |             get_objects_for_user( | ||||||
|         ).filter(intent=TokenIntents.INTENT_API, identifier=identifier) |                 self.request.user, self.permission_required, self.model | ||||||
|  |             ) | ||||||
|  |             .filter(intent=TokenIntents.INTENT_API, identifier=identifier) | ||||||
|  |             .first() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): | class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): | ||||||
| @ -138,7 +132,12 @@ class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessage | |||||||
|     success_url = reverse_lazy("authentik_core:user-tokens") |     success_url = reverse_lazy("authentik_core:user-tokens") | ||||||
|     success_message = _("Successfully deleted Token") |     success_message = _("Successfully deleted Token") | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: |     def get_object(self) -> Token: | ||||||
|         kwargs = super().get_context_data(**kwargs) |         identifier = self.kwargs.get("identifier") | ||||||
|         kwargs["container_template"] = "user/base.html" |         return ( | ||||||
|         return kwargs |             get_objects_for_user( | ||||||
|  |                 self.request.user, self.permission_required, self.model | ||||||
|  |             ) | ||||||
|  |             .filter(intent=TokenIntents.INTENT_API, identifier=identifier) | ||||||
|  |             .first() | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| """Audit API Views""" | """Events API Views""" | ||||||
| from django.db.models.aggregates import Count | from django.db.models.aggregates import Count | ||||||
| from django.db.models.fields.json import KeyTextTransform | from django.db.models.fields.json import KeyTextTransform | ||||||
| from drf_yasg2.utils import swagger_auto_schema | from drf_yasg2.utils import swagger_auto_schema | ||||||
| @ -9,7 +9,7 @@ from rest_framework.response import Response | |||||||
| from rest_framework.serializers import ModelSerializer, Serializer | from rest_framework.serializers import ModelSerializer, Serializer | ||||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | from rest_framework.viewsets import ReadOnlyModelViewSet | ||||||
| 
 | 
 | ||||||
| from authentik.audit.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class EventSerializer(ModelSerializer): | class EventSerializer(ModelSerializer): | ||||||
| @ -48,6 +48,15 @@ class EventViewSet(ReadOnlyModelViewSet): | |||||||
| 
 | 
 | ||||||
|     queryset = Event.objects.all() |     queryset = Event.objects.all() | ||||||
|     serializer_class = EventSerializer |     serializer_class = EventSerializer | ||||||
|  |     ordering = ["-created"] | ||||||
|  |     search_fields = [ | ||||||
|  |         "user", | ||||||
|  |         "action", | ||||||
|  |         "app", | ||||||
|  |         "context", | ||||||
|  |         "client_ip", | ||||||
|  |     ] | ||||||
|  |     filterset_fields = ["action"] | ||||||
| 
 | 
 | ||||||
|     @swagger_auto_schema( |     @swagger_auto_schema( | ||||||
|         method="GET", responses={200: EventTopPerUserSerialier(many=True)} |         method="GET", responses={200: EventTopPerUserSerialier(many=True)} | ||||||
							
								
								
									
										15
									
								
								authentik/events/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								authentik/events/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | |||||||
|  | """authentik events app""" | ||||||
|  | from importlib import import_module | ||||||
|  |  | ||||||
|  | from django.apps import AppConfig | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AuthentikEventsConfig(AppConfig): | ||||||
|  |     """authentik events app""" | ||||||
|  |  | ||||||
|  |     name = "authentik.events" | ||||||
|  |     label = "authentik_events" | ||||||
|  |     verbose_name = "authentik Events" | ||||||
|  |  | ||||||
|  |     def ready(self): | ||||||
|  |         import_module("authentik.events.signals") | ||||||
| @ -1,4 +1,4 @@ | |||||||
| """Audit middleware""" | """Events middleware""" | ||||||
| from functools import partial | from functools import partial | ||||||
| from typing import Callable | from typing import Callable | ||||||
| 
 | 
 | ||||||
| @ -7,9 +7,10 @@ from django.db.models import Model | |||||||
| from django.db.models.signals import post_save, pre_delete | from django.db.models.signals import post_save, pre_delete | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| 
 | 
 | ||||||
| from authentik.audit.models import Event, EventAction, model_to_dict |  | ||||||
| from authentik.audit.signals import EventNewThread |  | ||||||
| from authentik.core.middleware import LOCAL | from authentik.core.middleware import LOCAL | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
|  | from authentik.events.signals import EventNewThread | ||||||
|  | from authentik.events.utils import model_to_dict | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class AuditMiddleware: | class AuditMiddleware: | ||||||
| @ -63,8 +63,8 @@ class Migration(migrations.Migration): | |||||||
|                 ), |                 ), | ||||||
|             ], |             ], | ||||||
|             options={ |             options={ | ||||||
|                 "verbose_name": "Audit Event", |                 "verbose_name": "Event", | ||||||
|                 "verbose_name_plural": "Audit Events", |                 "verbose_name_plural": "Events", | ||||||
|             }, |             }, | ||||||
|         ), |         ), | ||||||
|     ] |     ] | ||||||
| @ -6,7 +6,7 @@ from django.db import migrations, models | |||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ("authentik_audit", "0001_initial"), |         ("authentik_events", "0001_initial"), | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
| @ -3,11 +3,11 @@ from django.apps.registry import Apps | |||||||
| from django.db import migrations, models | from django.db import migrations, models | ||||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor | from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||||
| 
 | 
 | ||||||
| import authentik.audit.models | import authentik.events.models | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     Event = apps.get_model("authentik_audit", "Event") |     Event = apps.get_model("authentik_events", "Event") | ||||||
| 
 | 
 | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|     for event in Event.objects.all(): |     for event in Event.objects.all(): | ||||||
| @ -15,7 +15,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|         # Because event objects cannot be updated, we have to re-create them |         # Because event objects cannot be updated, we have to re-create them | ||||||
|         event.pk = None |         event.pk = None | ||||||
|         event.user_json = ( |         event.user_json = ( | ||||||
|             authentik.audit.models.get_user(event.user) if event.user else {} |             authentik.events.models.get_user(event.user) if event.user else {} | ||||||
|         ) |         ) | ||||||
|         event._state.adding = True |         event._state.adding = True | ||||||
|         event.save() |         event.save() | ||||||
| @ -24,7 +24,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ("authentik_audit", "0002_auto_20200918_2116"), |         ("authentik_events", "0002_auto_20200918_2116"), | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
| @ -6,7 +6,7 @@ from django.db import migrations, models | |||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ("authentik_audit", "0003_auto_20200917_1155"), |         ("authentik_events", "0003_auto_20200917_1155"), | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
| @ -6,7 +6,7 @@ from django.db import migrations, models | |||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ("authentik_audit", "0004_auto_20200921_1829"), |         ("authentik_events", "0004_auto_20200921_1829"), | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
| @ -6,7 +6,7 @@ from django.db import migrations, models | |||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ("authentik_audit", "0005_auto_20201005_2139"), |         ("authentik_events", "0005_auto_20201005_2139"), | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
							
								
								
									
										41
									
								
								authentik/events/migrations/0007_auto_20201215_0939.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								authentik/events/migrations/0007_auto_20201215_0939.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,41 @@ | |||||||
|  | # Generated by Django 3.1.4 on 2020-12-15 09:39 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_events", "0006_auto_20201017_2024"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="event", | ||||||
|  |             name="action", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 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"), | ||||||
|  |                     ("custom_", "Custom Prefix"), | ||||||
|  |                 ] | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										42
									
								
								authentik/events/migrations/0008_auto_20201220_1651.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								authentik/events/migrations/0008_auto_20201220_1651.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,42 @@ | |||||||
|  | # Generated by Django 3.1.4 on 2020-12-20 16:51 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_events", "0007_auto_20201215_0939"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="event", | ||||||
|  |             name="action", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 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"), | ||||||
|  |                 ] | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										42
									
								
								authentik/events/migrations/0009_auto_20201227_1210.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								authentik/events/migrations/0009_auto_20201227_1210.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,42 @@ | |||||||
|  | # Generated by Django 3.1.4 on 2020-12-27 12:10 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_events", "0008_auto_20201220_1651"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="event", | ||||||
|  |             name="action", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 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"), | ||||||
|  |                 ] | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -1,17 +1,14 @@ | |||||||
| """authentik audit models""" | """authentik events models""" | ||||||
|  | 
 | ||||||
| from inspect import getmodule, stack | from inspect import getmodule, stack | ||||||
| from typing import Any, Dict, Optional, Union | from typing import Optional, Union | ||||||
| from uuid import UUID, uuid4 | from uuid import uuid4 | ||||||
| 
 | 
 | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth.models import AnonymousUser |  | ||||||
| from django.core.exceptions import ValidationError | from django.core.exceptions import ValidationError | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.db.models.base import Model |  | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.debug import SafeExceptionReporterFilter |  | ||||||
| from guardian.utils import get_anonymous_user |  | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
| 
 | 
 | ||||||
| from authentik.core.middleware import ( | from authentik.core.middleware import ( | ||||||
| @ -19,78 +16,14 @@ from authentik.core.middleware import ( | |||||||
|     SESSION_IMPERSONATE_USER, |     SESSION_IMPERSONATE_USER, | ||||||
| ) | ) | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
|  | from authentik.events.utils import cleanse_dict, get_user, sanitize_dict | ||||||
| from authentik.lib.utils.http import get_client_ip | from authentik.lib.utils.http import get_client_ip | ||||||
| 
 | 
 | ||||||
| LOGGER = get_logger("authentik.audit") | LOGGER = get_logger("authentik.events") | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: |  | ||||||
|     """Cleanse a dictionary, recursively""" |  | ||||||
|     final_dict = {} |  | ||||||
|     for key, value in source.items(): |  | ||||||
|         try: |  | ||||||
|             if SafeExceptionReporterFilter.hidden_settings.search(key): |  | ||||||
|                 final_dict[key] = SafeExceptionReporterFilter.cleansed_substitute |  | ||||||
|             else: |  | ||||||
|                 final_dict[key] = value |  | ||||||
|         except TypeError: |  | ||||||
|             final_dict[key] = value |  | ||||||
|         if isinstance(value, dict): |  | ||||||
|             final_dict[key] = cleanse_dict(value) |  | ||||||
|     return final_dict |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def model_to_dict(model: Model) -> Dict[str, Any]: |  | ||||||
|     """Convert model to dict""" |  | ||||||
|     name = str(model) |  | ||||||
|     if hasattr(model, "name"): |  | ||||||
|         name = model.name |  | ||||||
|     return { |  | ||||||
|         "app": model._meta.app_label, |  | ||||||
|         "model_name": model._meta.model_name, |  | ||||||
|         "pk": model.pk, |  | ||||||
|         "name": name, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]: |  | ||||||
|     """Convert user object to dictionary, optionally including the original user""" |  | ||||||
|     if isinstance(user, AnonymousUser): |  | ||||||
|         user = get_anonymous_user() |  | ||||||
|     user_data = { |  | ||||||
|         "username": user.username, |  | ||||||
|         "pk": user.pk, |  | ||||||
|         "email": user.email, |  | ||||||
|     } |  | ||||||
|     if original_user: |  | ||||||
|         original_data = get_user(original_user) |  | ||||||
|         original_data["on_behalf_of"] = user_data |  | ||||||
|         return original_data |  | ||||||
|     return user_data |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: |  | ||||||
|     """clean source of all Models that would interfere with the JSONField. |  | ||||||
|     Models are replaced with a dictionary of { |  | ||||||
|         app: str, |  | ||||||
|         name: str, |  | ||||||
|         pk: Any |  | ||||||
|     }""" |  | ||||||
|     final_dict = {} |  | ||||||
|     for key, value in source.items(): |  | ||||||
|         if isinstance(value, dict): |  | ||||||
|             final_dict[key] = sanitize_dict(value) |  | ||||||
|         elif isinstance(value, models.Model): |  | ||||||
|             final_dict[key] = sanitize_dict(model_to_dict(value)) |  | ||||||
|         elif isinstance(value, UUID): |  | ||||||
|             final_dict[key] = value.hex |  | ||||||
|         else: |  | ||||||
|             final_dict[key] = value |  | ||||||
|     return final_dict |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class EventAction(models.TextChoices): | class EventAction(models.TextChoices): | ||||||
|     """All possible actions to save into the audit log""" |     """All possible actions to save into the events log""" | ||||||
| 
 | 
 | ||||||
|     LOGIN = "login" |     LOGIN = "login" | ||||||
|     LOGIN_FAILED = "login_failed" |     LOGIN_FAILED = "login_failed" | ||||||
| @ -102,7 +35,6 @@ class EventAction(models.TextChoices): | |||||||
| 
 | 
 | ||||||
|     TOKEN_VIEW = "token_view"  # nosec |     TOKEN_VIEW = "token_view"  # nosec | ||||||
| 
 | 
 | ||||||
|     INVITE_CREATED = "invitation_created" |  | ||||||
|     INVITE_USED = "invitation_used" |     INVITE_USED = "invitation_used" | ||||||
| 
 | 
 | ||||||
|     AUTHORIZE_APPLICATION = "authorize_application" |     AUTHORIZE_APPLICATION = "authorize_application" | ||||||
| @ -111,15 +43,23 @@ class EventAction(models.TextChoices): | |||||||
|     IMPERSONATION_STARTED = "impersonation_started" |     IMPERSONATION_STARTED = "impersonation_started" | ||||||
|     IMPERSONATION_ENDED = "impersonation_ended" |     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_CREATED = "model_created" | ||||||
|     MODEL_UPDATED = "model_updated" |     MODEL_UPDATED = "model_updated" | ||||||
|     MODEL_DELETED = "model_deleted" |     MODEL_DELETED = "model_deleted" | ||||||
| 
 | 
 | ||||||
|  |     UPDATE_AVAILABLE = "update_available" | ||||||
|  | 
 | ||||||
|     CUSTOM_PREFIX = "custom_" |     CUSTOM_PREFIX = "custom_" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class Event(models.Model): | class Event(models.Model): | ||||||
|     """An individual audit log event""" |     """An individual Audit/Metrics/Notification/Error Event""" | ||||||
| 
 | 
 | ||||||
|     event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) |     event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||||
|     user = models.JSONField(default=dict) |     user = models.JSONField(default=dict) | ||||||
| @ -151,6 +91,12 @@ class Event(models.Model): | |||||||
|         event = Event(action=action, app=app, context=cleaned_kwargs) |         event = Event(action=action, app=app, context=cleaned_kwargs) | ||||||
|         return event |         return event | ||||||
| 
 | 
 | ||||||
|  |     def set_user(self, user: User) -> "Event": | ||||||
|  |         """Set `.user` based on user, ensuring the correct attributes are copied. | ||||||
|  |         This should only be used when self.from_http is *not* used.""" | ||||||
|  |         self.user = get_user(user) | ||||||
|  |         return self | ||||||
|  | 
 | ||||||
|     def from_http( |     def from_http( | ||||||
|         self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None |         self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None | ||||||
|     ) -> "Event": |     ) -> "Event": | ||||||
| @ -185,7 +131,7 @@ class Event(models.Model): | |||||||
|                 "you may not edit an existing %s" % self._meta.model_name |                 "you may not edit an existing %s" % self._meta.model_name | ||||||
|             ) |             ) | ||||||
|         LOGGER.debug( |         LOGGER.debug( | ||||||
|             "Created Audit event", |             "Created Event", | ||||||
|             action=self.action, |             action=self.action, | ||||||
|             context=self.context, |             context=self.context, | ||||||
|             client_ip=self.client_ip, |             client_ip=self.client_ip, | ||||||
| @ -195,5 +141,5 @@ class Event(models.Model): | |||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
| 
 | 
 | ||||||
|         verbose_name = _("Audit Event") |         verbose_name = _("Event") | ||||||
|         verbose_name_plural = _("Audit Events") |         verbose_name_plural = _("Events") | ||||||
| @ -1,4 +1,4 @@ | |||||||
| """authentik audit signal listener""" | """authentik events signal listener""" | ||||||
| from threading import Thread | from threading import Thread | ||||||
| from typing import Any, Dict, Optional | from typing import Any, Dict, Optional | ||||||
| 
 | 
 | ||||||
| @ -10,11 +10,11 @@ from django.contrib.auth.signals import ( | |||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| 
 | 
 | ||||||
| from authentik.audit.models import Event, EventAction |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.core.signals import password_changed | from authentik.core.signals import password_changed | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.stages.invitation.models import Invitation | from authentik.stages.invitation.models import Invitation | ||||||
| from authentik.stages.invitation.signals import invitation_created, invitation_used | from authentik.stages.invitation.signals import invitation_used | ||||||
| from authentik.stages.user_write.signals import user_write | from authentik.stages.user_write.signals import user_write | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -79,16 +79,6 @@ def on_user_login_failed( | |||||||
|     thread.run() |     thread.run() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @receiver(invitation_created) |  | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def on_invitation_created(sender, request: HttpRequest, invitation: Invitation, **_): |  | ||||||
|     """Log Invitation creation""" |  | ||||||
|     thread = EventNewThread( |  | ||||||
|         EventAction.INVITE_CREATED, request, invitation_uuid=invitation.invite_uuid.hex |  | ||||||
|     ) |  | ||||||
|     thread.run() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @receiver(invitation_used) | @receiver(invitation_used) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_): | def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_): | ||||||
| @ -1,15 +1,15 @@ | |||||||
| """audit event tests""" | """events event tests""" | ||||||
| 
 | 
 | ||||||
| from django.contrib.contenttypes.models import ContentType | from django.contrib.contenttypes.models import ContentType | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
| 
 | 
 | ||||||
| from authentik.audit.models import Event | from authentik.events.models import Event | ||||||
| from authentik.policies.dummy.models import DummyPolicy | from authentik.policies.dummy.models import DummyPolicy | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TestAuditEvent(TestCase): | class TestEvents(TestCase): | ||||||
|     """Test Audit Event""" |     """Test Event""" | ||||||
| 
 | 
 | ||||||
|     def test_new_with_model(self): |     def test_new_with_model(self): | ||||||
|         """Create a new Event passing a model as kwarg""" |         """Create a new Event passing a model as kwarg""" | ||||||
							
								
								
									
										92
									
								
								authentik/events/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								authentik/events/utils.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,92 @@ | |||||||
|  | """event utilities""" | ||||||
|  | import re | ||||||
|  | from dataclasses import asdict, is_dataclass | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from uuid import UUID | ||||||
|  |  | ||||||
|  | from django.contrib.auth.models import AnonymousUser | ||||||
|  | from django.db import models | ||||||
|  | from django.db.models.base import Model | ||||||
|  | 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 | ||||||
|  | ALLOWED_SPECIAL_KEYS = re.compile("passing", flags=re.I) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: | ||||||
|  |     """Cleanse a dictionary, recursively""" | ||||||
|  |     final_dict = {} | ||||||
|  |     for key, value in source.items(): | ||||||
|  |         try: | ||||||
|  |             if SafeExceptionReporterFilter.hidden_settings.search( | ||||||
|  |                 key | ||||||
|  |             ) and not ALLOWED_SPECIAL_KEYS.search(key): | ||||||
|  |                 final_dict[key] = SafeExceptionReporterFilter.cleansed_substitute | ||||||
|  |             else: | ||||||
|  |                 final_dict[key] = value | ||||||
|  |         except TypeError:  # pragma: no cover | ||||||
|  |             final_dict[key] = value | ||||||
|  |         if isinstance(value, dict): | ||||||
|  |             final_dict[key] = cleanse_dict(value) | ||||||
|  |     return final_dict | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def model_to_dict(model: Model) -> Dict[str, Any]: | ||||||
|  |     """Convert model to dict""" | ||||||
|  |     name = str(model) | ||||||
|  |     if hasattr(model, "name"): | ||||||
|  |         name = model.name | ||||||
|  |     return { | ||||||
|  |         "app": model._meta.app_label, | ||||||
|  |         "model_name": model._meta.model_name, | ||||||
|  |         "pk": model.pk, | ||||||
|  |         "name": name, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]: | ||||||
|  |     """Convert user object to dictionary, optionally including the original user""" | ||||||
|  |     if isinstance(user, AnonymousUser): | ||||||
|  |         user = get_anonymous_user() | ||||||
|  |     user_data = { | ||||||
|  |         "username": user.username, | ||||||
|  |         "pk": user.pk, | ||||||
|  |         "email": user.email, | ||||||
|  |     } | ||||||
|  |     if original_user: | ||||||
|  |         original_data = get_user(original_user) | ||||||
|  |         original_data["on_behalf_of"] = user_data | ||||||
|  |         return original_data | ||||||
|  |     return user_data | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: | ||||||
|  |     """clean source of all Models that would interfere with the JSONField. | ||||||
|  |     Models are replaced with a dictionary of { | ||||||
|  |         app: str, | ||||||
|  |         name: str, | ||||||
|  |         pk: 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, models.Model): | ||||||
|  |             final_dict[key] = sanitize_dict(model_to_dict(value)) | ||||||
|  |         elif isinstance(value, UUID): | ||||||
|  |             final_dict[key] = value.hex | ||||||
|  |         else: | ||||||
|  |             final_dict[key] = value | ||||||
|  |     return final_dict | ||||||
| @ -1,7 +1,22 @@ | |||||||
| """Flow API Views""" | """Flow API Views""" | ||||||
|  | from dataclasses import dataclass | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField | from django.db.models import Model | ||||||
| from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | from django.shortcuts import get_object_or_404 | ||||||
|  | from drf_yasg2.utils import swagger_auto_schema | ||||||
|  | from guardian.shortcuts import get_objects_for_user | ||||||
|  | from rest_framework.decorators import action | ||||||
|  | from rest_framework.mixins import ListModelMixin | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  | from rest_framework.serializers import ( | ||||||
|  |     CharField, | ||||||
|  |     ModelSerializer, | ||||||
|  |     Serializer, | ||||||
|  |     SerializerMethodField, | ||||||
|  | ) | ||||||
|  | from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet | ||||||
|  |  | ||||||
| from authentik.flows.models import Flow, FlowStageBinding, Stage | from authentik.flows.models import Flow, FlowStageBinding, Stage | ||||||
| from authentik.flows.planner import cache_key | from authentik.flows.planner import cache_key | ||||||
| @ -33,12 +48,110 @@ class FlowSerializer(ModelSerializer): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FlowDiagramSerializer(Serializer): | ||||||
|  |     """response of the flow's /diagram/ action""" | ||||||
|  |  | ||||||
|  |     diagram = CharField(read_only=True) | ||||||
|  |  | ||||||
|  |     def create(self, validated_data: dict) -> Model: | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     def update(self, instance: Model, validated_data: dict) -> Model: | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class DiagramElement: | ||||||
|  |     """Single element used in a diagram""" | ||||||
|  |  | ||||||
|  |     identifier: str | ||||||
|  |     type: str | ||||||
|  |     rest: str | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"{self.identifier}=>{self.type}: {self.rest}" | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowViewSet(ModelViewSet): | class FlowViewSet(ModelViewSet): | ||||||
|     """Flow Viewset""" |     """Flow Viewset""" | ||||||
|  |  | ||||||
|     queryset = Flow.objects.all() |     queryset = Flow.objects.all() | ||||||
|     serializer_class = FlowSerializer |     serializer_class = FlowSerializer | ||||||
|     lookup_field = "slug" |     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"]) | ||||||
|  |     def diagram(self, request: Request, slug: str) -> Response: | ||||||
|  |         """Return diagram for flow with slug `slug`, in the format used by flowchart.js""" | ||||||
|  |         flow = get_object_or_404( | ||||||
|  |             get_objects_for_user(request.user, "authentik_flows.view_flow").filter( | ||||||
|  |                 slug=slug | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         header = [ | ||||||
|  |             DiagramElement("st", "start", "Start"), | ||||||
|  |         ] | ||||||
|  |         body: list[DiagramElement] = [] | ||||||
|  |         footer = [] | ||||||
|  |         # First, collect all elements we need | ||||||
|  |         for s_index, stage_binding in enumerate( | ||||||
|  |             get_objects_for_user(request.user, "authentik_flows.view_flowstagebinding") | ||||||
|  |             .filter(target=flow) | ||||||
|  |             .order_by("order") | ||||||
|  |         ): | ||||||
|  |             body.append( | ||||||
|  |                 DiagramElement( | ||||||
|  |                     f"stage_{s_index}", | ||||||
|  |                     "operation", | ||||||
|  |                     f"Stage\n{stage_binding.stage.name}", | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             for p_index, policy_binding in enumerate( | ||||||
|  |                 get_objects_for_user( | ||||||
|  |                     request.user, "authentik_policies.view_policybinding" | ||||||
|  |                 ) | ||||||
|  |                 .filter(target=stage_binding) | ||||||
|  |                 .order_by("order") | ||||||
|  |             ): | ||||||
|  |                 body.append( | ||||||
|  |                     DiagramElement( | ||||||
|  |                         f"stage_{s_index}_policy_{p_index}", | ||||||
|  |                         "condition", | ||||||
|  |                         f"Policy\n{policy_binding.policy.name}", | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |         # If the 2nd last element is a policy, we need to have an item to point to | ||||||
|  |         # for a negative case | ||||||
|  |         body.append( | ||||||
|  |             DiagramElement("e", "end", "End|future"), | ||||||
|  |         ) | ||||||
|  |         if len(body) == 1: | ||||||
|  |             footer.append("st(right)->e") | ||||||
|  |         else: | ||||||
|  |             # Actual diagram flow | ||||||
|  |             footer.append(f"st(right)->{body[0].identifier}") | ||||||
|  |             for index in range(len(body) - 1): | ||||||
|  |                 element: DiagramElement = body[index] | ||||||
|  |                 if element.type == "condition": | ||||||
|  |                     # Policy passes, link policy yes to next stage | ||||||
|  |                     footer.append( | ||||||
|  |                         f"{element.identifier}(yes, right)->{body[index + 1].identifier}" | ||||||
|  |                     ) | ||||||
|  |                     # Policy doesn't pass, go to stage after next stage | ||||||
|  |                     no_element = body[index + 1] | ||||||
|  |                     if no_element.type != "end": | ||||||
|  |                         no_element = body[index + 2] | ||||||
|  |                     footer.append( | ||||||
|  |                         f"{element.identifier}(no, bottom)->{no_element.identifier}" | ||||||
|  |                     ) | ||||||
|  |                 elif element.type == "operation": | ||||||
|  |                     footer.append( | ||||||
|  |                         f"{element.identifier}(bottom)->{body[index + 1].identifier}" | ||||||
|  |                     ) | ||||||
|  |         diagram = "\n".join([str(x) for x in header + body + footer]) | ||||||
|  |         return Response({"diagram": diagram}) | ||||||
|  |  | ||||||
|  |  | ||||||
| class StageSerializer(ModelSerializer): | class StageSerializer(ModelSerializer): | ||||||
| @ -98,3 +211,14 @@ class FlowStageBindingViewSet(ModelViewSet): | |||||||
|     queryset = FlowStageBinding.objects.all() |     queryset = FlowStageBinding.objects.all() | ||||||
|     serializer_class = FlowStageBindingSerializer |     serializer_class = FlowStageBindingSerializer | ||||||
|     filterset_fields = "__all__" |     filterset_fields = "__all__" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FlowCacheViewSet(ListModelMixin, GenericViewSet): | ||||||
|  |     """Info about cached flows""" | ||||||
|  |  | ||||||
|  |     queryset = Flow.objects.none() | ||||||
|  |     serializer_class = Serializer | ||||||
|  |  | ||||||
|  |     def list(self, request: Request) -> Response: | ||||||
|  |         """Info about cached flows""" | ||||||
|  |         return Response(data={"pagination": {"count": len(cache.keys("flow_*"))}}) | ||||||
|  | |||||||
| @ -8,8 +8,8 @@ from sentry_sdk.hub import Hub | |||||||
| from sentry_sdk.tracing import Span | from sentry_sdk.tracing import Span | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
| from authentik.audit.models import cleanse_dict |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
|  | from authentik.events.models import cleanse_dict | ||||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||||
| from authentik.flows.models import Flow, FlowStageBinding, Stage | from authentik.flows.models import Flow, FlowStageBinding, Stage | ||||||
| @ -19,6 +19,7 @@ LOGGER = get_logger() | |||||||
|  |  | ||||||
| PLAN_CONTEXT_PENDING_USER = "pending_user" | PLAN_CONTEXT_PENDING_USER = "pending_user" | ||||||
| PLAN_CONTEXT_SSO = "is_sso" | PLAN_CONTEXT_SSO = "is_sso" | ||||||
|  | PLAN_CONTEXT_REDIRECT = "redirect" | ||||||
| PLAN_CONTEXT_APPLICATION = "application" | PLAN_CONTEXT_APPLICATION = "application" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										92
									
								
								authentik/flows/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								authentik/flows/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,92 @@ | |||||||
|  | """API flow tests""" | ||||||
|  | from django.shortcuts import reverse | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import User | ||||||
|  | from authentik.flows.api import StageSerializer, StageViewSet | ||||||
|  | from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage | ||||||
|  | from authentik.policies.dummy.models import DummyPolicy | ||||||
|  | from authentik.policies.models import PolicyBinding | ||||||
|  | from authentik.stages.dummy.models import DummyStage | ||||||
|  |  | ||||||
|  | DIAGRAM_EXPECTED = """st=>start: Start | ||||||
|  | stage_0=>operation: Stage | ||||||
|  | dummy1 | ||||||
|  | stage_1=>operation: Stage | ||||||
|  | dummy2 | ||||||
|  | stage_1_policy_0=>condition: Policy | ||||||
|  | None | ||||||
|  | e=>end: End|future | ||||||
|  | st(right)->stage_0 | ||||||
|  | stage_0(bottom)->stage_1 | ||||||
|  | stage_1(bottom)->stage_1_policy_0 | ||||||
|  | stage_1_policy_0(yes, right)->e | ||||||
|  | stage_1_policy_0(no, bottom)->e""" | ||||||
|  | DIAGRAM_SHORT_EXPECTED = """st=>start: Start | ||||||
|  | e=>end: End|future | ||||||
|  | st(right)->e""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestFlowsAPI(APITestCase): | ||||||
|  |     """API tests""" | ||||||
|  |  | ||||||
|  |     def test_models(self): | ||||||
|  |         """Test that ui_user_settings returns none""" | ||||||
|  |         self.assertIsNone(Stage().ui_user_settings) | ||||||
|  |  | ||||||
|  |     def test_api_serializer(self): | ||||||
|  |         """Test that stage serializer returns the correct type""" | ||||||
|  |         obj = DummyStage() | ||||||
|  |         self.assertEqual(StageSerializer().get_type(obj), "dummy") | ||||||
|  |         self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage") | ||||||
|  |  | ||||||
|  |     def test_api_viewset(self): | ||||||
|  |         """Test that stage serializer returns the correct type""" | ||||||
|  |         dummy = DummyStage.objects.create() | ||||||
|  |         self.assertIn(dummy, StageViewSet().get_queryset()) | ||||||
|  |  | ||||||
|  |     def test_api_diagram(self): | ||||||
|  |         """Test flow diagram.""" | ||||||
|  |         user = User.objects.get(username="akadmin") | ||||||
|  |         self.client.force_login(user) | ||||||
|  |  | ||||||
|  |         flow = Flow.objects.create( | ||||||
|  |             name="test-default-context", | ||||||
|  |             slug="test-default-context", | ||||||
|  |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|  |         ) | ||||||
|  |         false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) | ||||||
|  |  | ||||||
|  |         FlowStageBinding.objects.create( | ||||||
|  |             target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 | ||||||
|  |         ) | ||||||
|  |         binding2 = FlowStageBinding.objects.create( | ||||||
|  |             target=flow, | ||||||
|  |             stage=DummyStage.objects.create(name="dummy2"), | ||||||
|  |             order=1, | ||||||
|  |             re_evaluate_policies=True, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) | ||||||
|  |  | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-diagram", kwargs={"slug": flow.slug}) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertJSONEqual(response.content, {"diagram": DIAGRAM_EXPECTED}) | ||||||
|  |  | ||||||
|  |     def test_api_diagram_no_stages(self): | ||||||
|  |         """Test flow diagram with no stages.""" | ||||||
|  |         user = User.objects.get(username="akadmin") | ||||||
|  |         self.client.force_login(user) | ||||||
|  |  | ||||||
|  |         flow = Flow.objects.create( | ||||||
|  |             name="test-default-context", | ||||||
|  |             slug="test-default-context", | ||||||
|  |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|  |         ) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-diagram", kwargs={"slug": flow.slug}) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertJSONEqual(response.content, {"diagram": DIAGRAM_SHORT_EXPECTED}) | ||||||
| @ -1,25 +0,0 @@ | |||||||
| """miscellaneous flow tests""" |  | ||||||
| from django.test import TestCase |  | ||||||
|  |  | ||||||
| from authentik.flows.api import StageSerializer, StageViewSet |  | ||||||
| from authentik.flows.models import Stage |  | ||||||
| from authentik.stages.dummy.models import DummyStage |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestFlowsMisc(TestCase): |  | ||||||
|     """miscellaneous tests""" |  | ||||||
|  |  | ||||||
|     def test_models(self): |  | ||||||
|         """Test that ui_user_settings returns none""" |  | ||||||
|         self.assertIsNone(Stage().ui_user_settings) |  | ||||||
|  |  | ||||||
|     def test_api_serializer(self): |  | ||||||
|         """Test that stage serializer returns the correct type""" |  | ||||||
|         obj = DummyStage() |  | ||||||
|         self.assertEqual(StageSerializer().get_type(obj), "dummy") |  | ||||||
|         self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage") |  | ||||||
|  |  | ||||||
|     def test_api_viewset(self): |  | ||||||
|         """Test that stage serializer returns the correct type""" |  | ||||||
|         dummy = DummyStage.objects.create() |  | ||||||
|         self.assertIn(dummy, StageViewSet().get_queryset()) |  | ||||||
| @ -8,7 +8,7 @@ from django.test.client import RequestFactory | |||||||
| from django.utils.encoding import force_str | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||||
| from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding | from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding | ||||||
| from authentik.flows.planner import FlowPlan, FlowPlanner | from authentik.flows.planner import FlowPlan, FlowPlanner | ||||||
| @ -40,6 +40,10 @@ class TestFlowExecutor(TestCase): | |||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.request_factory = RequestFactory() |         self.request_factory = RequestFactory() | ||||||
|  |  | ||||||
|  |     @patch( | ||||||
|  |         "authentik.flows.views.to_stage_response", | ||||||
|  |         TO_STAGE_RESPONSE_MOCK, | ||||||
|  |     ) | ||||||
|     def test_existing_plan_diff_flow(self): |     def test_existing_plan_diff_flow(self): | ||||||
|         """Check that a plan for a different flow cancels the current plan""" |         """Check that a plan for a different flow cancels the current plan""" | ||||||
|         flow = Flow.objects.create( |         flow = Flow.objects.create( | ||||||
| @ -62,7 +66,7 @@ class TestFlowExecutor(TestCase): | |||||||
|                     "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug} |                     "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug} | ||||||
|                 ), |                 ), | ||||||
|             ) |             ) | ||||||
|             self.assertEqual(response.status_code, 200) |             self.assertEqual(response.status_code, 302) | ||||||
|             self.assertEqual(cancel_mock.call_count, 2) |             self.assertEqual(cancel_mock.call_count, 2) | ||||||
|  |  | ||||||
|     @patch( |     @patch( | ||||||
| @ -105,10 +109,13 @@ class TestFlowExecutor(TestCase): | |||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), |             reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertIsInstance(response, AccessDeniedResponse) |         self.assertEqual(response.url, reverse("authentik_core:shell")) | ||||||
|         self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content) |  | ||||||
|  |  | ||||||
|  |     @patch( | ||||||
|  |         "authentik.flows.views.to_stage_response", | ||||||
|  |         TO_STAGE_RESPONSE_MOCK, | ||||||
|  |     ) | ||||||
|     def test_invalid_flow_redirect(self): |     def test_invalid_flow_redirect(self): | ||||||
|         """Tests that an invalid flow still redirects""" |         """Tests that an invalid flow still redirects""" | ||||||
|         flow = Flow.objects.create( |         flow = Flow.objects.create( | ||||||
| @ -121,11 +128,8 @@ class TestFlowExecutor(TestCase): | |||||||
|         dest = "/unique-string" |         dest = "/unique-string" | ||||||
|         url = reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}) |         url = reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}) | ||||||
|         response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}") |         response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}") | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertJSONEqual( |         self.assertEqual(response.url, reverse("authentik_core:shell")) | ||||||
|             force_str(response.content), |  | ||||||
|             {"type": "redirect", "to": dest}, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_multi_stage_flow(self): |     def test_multi_stage_flow(self): | ||||||
|         """Test a full flow with multiple stages""" |         """Test a full flow with multiple stages""" | ||||||
| @ -161,6 +165,10 @@ class TestFlowExecutor(TestCase): | |||||||
|         plan: FlowPlan = session[SESSION_KEY_PLAN] |         plan: FlowPlan = session[SESSION_KEY_PLAN] | ||||||
|         self.assertEqual(len(plan.stages), 1) |         self.assertEqual(len(plan.stages), 1) | ||||||
|  |  | ||||||
|  |     @patch( | ||||||
|  |         "authentik.flows.views.to_stage_response", | ||||||
|  |         TO_STAGE_RESPONSE_MOCK, | ||||||
|  |     ) | ||||||
|     def test_reevaluate_remove_last(self): |     def test_reevaluate_remove_last(self): | ||||||
|         """Test planner with re-evaluate (last stage is removed)""" |         """Test planner with re-evaluate (last stage is removed)""" | ||||||
|         flow = Flow.objects.create( |         flow = Flow.objects.create( | ||||||
|  | |||||||
| @ -17,11 +17,16 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin | |||||||
| from django.views.generic import TemplateView, View | from django.views.generic import TemplateView, View | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
| from authentik.audit.models import cleanse_dict |  | ||||||
| from authentik.core.models import USER_ATTRIBUTE_DEBUG | from authentik.core.models import USER_ATTRIBUTE_DEBUG | ||||||
|  | from authentik.events.models import cleanse_dict | ||||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage | from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan, FlowPlanner | from authentik.flows.planner import ( | ||||||
|  |     PLAN_CONTEXT_PENDING_USER, | ||||||
|  |     PLAN_CONTEXT_REDIRECT, | ||||||
|  |     FlowPlan, | ||||||
|  |     FlowPlanner, | ||||||
|  | ) | ||||||
| from authentik.lib.utils.reflection import class_to_path | from authentik.lib.utils.reflection import class_to_path | ||||||
| from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs | from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs | ||||||
| from authentik.policies.http import AccessDeniedResponse | from authentik.policies.http import AccessDeniedResponse | ||||||
| @ -83,7 +88,9 @@ class FlowExecutorView(View): | |||||||
|                 return to_stage_response(self.request, self.handle_invalid_flow(exc)) |                 return to_stage_response(self.request, self.handle_invalid_flow(exc)) | ||||||
|             except EmptyFlowException as exc: |             except EmptyFlowException as exc: | ||||||
|                 LOGGER.warning("f(exec): Flow is empty", exc=exc) |                 LOGGER.warning("f(exec): Flow is empty", exc=exc) | ||||||
|                 return to_stage_response(self.request, self.handle_invalid_flow(exc)) |                 # To match behaviour with loading an empty flow plan from cache, | ||||||
|  |                 # we don't show an error message here, but rather call _flow_done() | ||||||
|  |                 return self._flow_done() | ||||||
|         # We don't save the Plan after getting the next stage |         # We don't save the Plan after getting the next stage | ||||||
|         # as it hasn't been successfully passed yet |         # as it hasn't been successfully passed yet | ||||||
|         next_stage = self.plan.next(self.request) |         next_stage = self.plan.next(self.request) | ||||||
| @ -143,11 +150,15 @@ class FlowExecutorView(View): | |||||||
|         """User Successfully passed all stages""" |         """User Successfully passed all stages""" | ||||||
|         # Since this is wrapped by the ExecutorShell, the next argument is saved in the session |         # Since this is wrapped by the ExecutorShell, the next argument is saved in the session | ||||||
|         # extract the next param before cancel as that cleans it |         # extract the next param before cancel as that cleans it | ||||||
|  |         next_param = None | ||||||
|  |         if self.plan: | ||||||
|  |             next_param = self.plan.context.get(PLAN_CONTEXT_REDIRECT) | ||||||
|  |         if not next_param: | ||||||
|             next_param = self.request.session.get(SESSION_KEY_GET, {}).get( |             next_param = self.request.session.get(SESSION_KEY_GET, {}).get( | ||||||
|                 NEXT_ARG_NAME, "authentik_core:shell" |                 NEXT_ARG_NAME, "authentik_core:shell" | ||||||
|             ) |             ) | ||||||
|         self.cancel() |         self.cancel() | ||||||
|         return redirect_with_qs(next_param) |         return to_stage_response(self.request, redirect_with_qs(next_param)) | ||||||
|  |  | ||||||
|     def stage_ok(self) -> HttpResponse: |     def stage_ok(self) -> HttpResponse: | ||||||
|         """Callback called by stages upon successful completion. |         """Callback called by stages upon successful completion. | ||||||
|  | |||||||
| @ -80,11 +80,15 @@ class BaseEvaluator: | |||||||
|             span: Span |             span: Span | ||||||
|             span.set_data("expression", expression_source) |             span.set_data("expression", expression_source) | ||||||
|             param_keys = self._context.keys() |             param_keys = self._context.keys() | ||||||
|  |             try: | ||||||
|                 ast_obj = compile( |                 ast_obj = compile( | ||||||
|                     self.wrap_expression(expression_source, param_keys), |                     self.wrap_expression(expression_source, param_keys), | ||||||
|                     self._filename, |                     self._filename, | ||||||
|                     "exec", |                     "exec", | ||||||
|                 ) |                 ) | ||||||
|  |             except (SyntaxError, ValueError) as exc: | ||||||
|  |                 self.handle_error(exc, expression_source) | ||||||
|  |                 raise exc | ||||||
|             try: |             try: | ||||||
|                 _locals = self._context |                 _locals = self._context | ||||||
|                 # Yes this is an exec, yes it is potentially bad. Since we limit what variables are |                 # Yes this is an exec, yes it is potentially bad. Since we limit what variables are | ||||||
| @ -94,10 +98,15 @@ class BaseEvaluator: | |||||||
|                 exec(ast_obj, self._globals, _locals)  # nosec # noqa |                 exec(ast_obj, self._globals, _locals)  # nosec # noqa | ||||||
|                 result = _locals["result"] |                 result = _locals["result"] | ||||||
|             except Exception as exc: |             except Exception as exc: | ||||||
|                 LOGGER.warning("Expression error", exc=exc) |                 self.handle_error(exc, expression_source) | ||||||
|                 raise |                 raise exc | ||||||
|             return result |             return result | ||||||
|  |  | ||||||
|  |     # pylint: disable=unused-argument | ||||||
|  |     def handle_error(self, exc: Exception, expression_source: str):  # pragma: no cover | ||||||
|  |         """Exception Handler""" | ||||||
|  |         LOGGER.warning("Expression error", exc=exc) | ||||||
|  |  | ||||||
|     def validate(self, expression: str) -> bool: |     def validate(self, expression: str) -> bool: | ||||||
|         """Validate expression's syntax, raise ValidationError if Syntax is invalid""" |         """Validate expression's syntax, raise ValidationError if Syntax is invalid""" | ||||||
|         param_keys = self._context.keys() |         param_keys = self._context.keys() | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """Base Controller""" | """Base Controller""" | ||||||
| from typing import Dict, List | from dataclasses import dataclass | ||||||
|  |  | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
| from structlog.testing import capture_logs | from structlog.testing import capture_logs | ||||||
| @ -7,15 +7,26 @@ from structlog.testing import capture_logs | |||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.outposts.models import Outpost, OutpostServiceConnection | from authentik.outposts.models import Outpost, OutpostServiceConnection | ||||||
|  |  | ||||||
|  | FIELD_MANAGER = "goauthentik.io" | ||||||
|  |  | ||||||
|  |  | ||||||
| class ControllerException(SentryIgnoredException): | class ControllerException(SentryIgnoredException): | ||||||
|     """Exception raised when anything fails during controller run""" |     """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: | class BaseController: | ||||||
|     """Base Outpost deployment controller""" |     """Base Outpost deployment controller""" | ||||||
|  |  | ||||||
|     deployment_ports: Dict[str, int] |     deployment_ports: list[DeploymentPort] | ||||||
|  |  | ||||||
|     outpost: Outpost |     outpost: Outpost | ||||||
|     connection: OutpostServiceConnection |     connection: OutpostServiceConnection | ||||||
| @ -24,14 +35,14 @@ class BaseController: | |||||||
|         self.outpost = outpost |         self.outpost = outpost | ||||||
|         self.connection = connection |         self.connection = connection | ||||||
|         self.logger = get_logger() |         self.logger = get_logger() | ||||||
|         self.deployment_ports = {} |         self.deployment_ports = [] | ||||||
|  |  | ||||||
|     # pylint: disable=invalid-name |     # pylint: disable=invalid-name | ||||||
|     def up(self): |     def up(self): | ||||||
|         """Called by scheduled task to reconcile deployment/service/etc""" |         """Called by scheduled task to reconcile deployment/service/etc""" | ||||||
|         raise NotImplementedError |         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.""" |         """Call .up() but capture all log output and return it.""" | ||||||
|         with capture_logs() as logs: |         with capture_logs() as logs: | ||||||
|             self.up() |             self.up() | ||||||
|  | |||||||
| @ -68,7 +68,10 @@ class DockerController(BaseController): | |||||||
|                 "image": image_name, |                 "image": image_name, | ||||||
|                 "name": f"authentik-proxy-{self.outpost.uuid.hex}", |                 "name": f"authentik-proxy-{self.outpost.uuid.hex}", | ||||||
|                 "detach": True, |                 "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(), |                 "environment": self._get_env(), | ||||||
|                 "labels": self._get_labels(), |                 "labels": self._get_labels(), | ||||||
|             } |             } | ||||||
| @ -139,7 +142,10 @@ class DockerController(BaseController): | |||||||
|  |  | ||||||
|     def get_static_deployment(self) -> str: |     def get_static_deployment(self) -> str: | ||||||
|         """Generate docker-compose yaml for proxy, version 3.5""" |         """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") |         image_prefix = CONFIG.y("outposts.docker_image_base") | ||||||
|         compose = { |         compose = { | ||||||
|             "version": "3.5", |             "version": "3.5", | ||||||
| @ -154,6 +160,7 @@ class DockerController(BaseController): | |||||||
|                         ), |                         ), | ||||||
|                         "AUTHENTIK_TOKEN": self.outpost.token.key, |                         "AUTHENTIK_TOKEN": self.outpost.token.key, | ||||||
|                     }, |                     }, | ||||||
|  |                     "labels": self._get_labels(), | ||||||
|                 } |                 } | ||||||
|             }, |             }, | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -93,7 +93,8 @@ class KubernetesObjectReconciler(Generic[T]): | |||||||
|     def reconcile(self, current: T, reference: T): |     def reconcile(self, current: T, reference: T): | ||||||
|         """Check what operations should be done, should be raised as |         """Check what operations should be done, should be raised as | ||||||
|         ReconcileTrigger""" |         ReconcileTrigger""" | ||||||
|         raise NotImplementedError |         if current.metadata.annotations != reference.metadata.annotations: | ||||||
|  |             raise NeedsUpdate() | ||||||
|  |  | ||||||
|     def create(self, reference: T): |     def create(self, reference: T): | ||||||
|         """API Wrapper to create object""" |         """API Wrapper to create object""" | ||||||
|  | |||||||
| @ -18,6 +18,7 @@ from kubernetes.client import ( | |||||||
|  |  | ||||||
| from authentik import __version__ | from authentik import __version__ | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
|  | from authentik.outposts.controllers.base import FIELD_MANAGER | ||||||
| from authentik.outposts.controllers.k8s.base import ( | from authentik.outposts.controllers.k8s.base import ( | ||||||
|     KubernetesObjectReconciler, |     KubernetesObjectReconciler, | ||||||
|     NeedsUpdate, |     NeedsUpdate, | ||||||
| @ -43,6 +44,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | |||||||
|         return f"authentik-outpost-{self.controller.outpost.uuid.hex}" |         return f"authentik-outpost-{self.controller.outpost.uuid.hex}" | ||||||
|  |  | ||||||
|     def reconcile(self, current: V1Deployment, reference: V1Deployment): |     def reconcile(self, current: V1Deployment, reference: V1Deployment): | ||||||
|  |         super().reconcile(current, reference) | ||||||
|         if current.spec.replicas != reference.spec.replicas: |         if current.spec.replicas != reference.spec.replicas: | ||||||
|             raise NeedsUpdate() |             raise NeedsUpdate() | ||||||
|         if ( |         if ( | ||||||
| @ -63,8 +65,14 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | |||||||
|         """Get deployment object for outpost""" |         """Get deployment object for outpost""" | ||||||
|         # Generate V1ContainerPort objects |         # Generate V1ContainerPort objects | ||||||
|         container_ports = [] |         container_ports = [] | ||||||
|         for port_name, port in self.controller.deployment_ports.items(): |         for port in self.controller.deployment_ports: | ||||||
|             container_ports.append(V1ContainerPort(container_port=port, name=port_name)) |             container_ports.append( | ||||||
|  |                 V1ContainerPort( | ||||||
|  |                     container_port=port.port, | ||||||
|  |                     name=port.name, | ||||||
|  |                     protocol=port.protocol.upper(), | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|         meta = self.get_object_meta(name=self.name) |         meta = self.get_object_meta(name=self.name) | ||||||
|         secret_name = f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" |         secret_name = f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" | ||||||
|         image_prefix = CONFIG.y("outposts.docker_image_base") |         image_prefix = CONFIG.y("outposts.docker_image_base") | ||||||
| @ -118,7 +126,9 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def create(self, reference: 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): |     def delete(self, reference: V1Deployment): | ||||||
|         return self.api.delete_namespaced_deployment( |         return self.api.delete_namespaced_deployment( | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ from typing import TYPE_CHECKING | |||||||
|  |  | ||||||
| from kubernetes.client import CoreV1Api, V1Secret | from kubernetes.client import CoreV1Api, V1Secret | ||||||
|  |  | ||||||
|  | from authentik.outposts.controllers.base import FIELD_MANAGER | ||||||
| from authentik.outposts.controllers.k8s.base import ( | from authentik.outposts.controllers.k8s.base import ( | ||||||
|     KubernetesObjectReconciler, |     KubernetesObjectReconciler, | ||||||
|     NeedsUpdate, |     NeedsUpdate, | ||||||
| @ -30,6 +31,7 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): | |||||||
|         return f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" |         return f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" | ||||||
|  |  | ||||||
|     def reconcile(self, current: V1Secret, reference: V1Secret): |     def reconcile(self, current: V1Secret, reference: V1Secret): | ||||||
|  |         super().reconcile(current, reference) | ||||||
|         for key in reference.data.keys(): |         for key in reference.data.keys(): | ||||||
|             if current.data[key] != reference.data[key]: |             if current.data[key] != reference.data[key]: | ||||||
|                 raise NeedsUpdate() |                 raise NeedsUpdate() | ||||||
| @ -51,7 +53,9 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def create(self, reference: 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): |     def delete(self, reference: V1Secret): | ||||||
|         return self.api.delete_namespaced_secret( |         return self.api.delete_namespaced_secret( | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from typing import TYPE_CHECKING | |||||||
|  |  | ||||||
| from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec | from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec | ||||||
|  |  | ||||||
|  | from authentik.outposts.controllers.base import FIELD_MANAGER | ||||||
| from authentik.outposts.controllers.k8s.base import ( | from authentik.outposts.controllers.k8s.base import ( | ||||||
|     KubernetesObjectReconciler, |     KubernetesObjectReconciler, | ||||||
|     NeedsUpdate, |     NeedsUpdate, | ||||||
| @ -25,6 +26,7 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | |||||||
|         return f"authentik-outpost-{self.controller.outpost.uuid.hex}" |         return f"authentik-outpost-{self.controller.outpost.uuid.hex}" | ||||||
|  |  | ||||||
|     def reconcile(self, current: V1Service, reference: V1Service): |     def reconcile(self, current: V1Service, reference: V1Service): | ||||||
|  |         super().reconcile(current, reference) | ||||||
|         if len(current.spec.ports) != len(reference.spec.ports): |         if len(current.spec.ports) != len(reference.spec.ports): | ||||||
|             raise NeedsUpdate() |             raise NeedsUpdate() | ||||||
|         for port in reference.spec.ports: |         for port in reference.spec.ports: | ||||||
| @ -35,8 +37,15 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | |||||||
|         """Get deployment object for outpost""" |         """Get deployment object for outpost""" | ||||||
|         meta = self.get_object_meta(name=self.name) |         meta = self.get_object_meta(name=self.name) | ||||||
|         ports = [] |         ports = [] | ||||||
|         for port_name, port in self.controller.deployment_ports.items(): |         for port in self.controller.deployment_ports: | ||||||
|             ports.append(V1ServicePort(name=port_name, port=port)) |             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() |         selector_labels = DeploymentReconciler(self.controller).get_pod_meta() | ||||||
|         return V1Service( |         return V1Service( | ||||||
|             metadata=meta, |             metadata=meta, | ||||||
| @ -44,7 +53,9 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def create(self, reference: 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): |     def delete(self, reference: V1Service): | ||||||
|         return self.api.delete_namespaced_service( |         return self.api.delete_namespaced_service( | ||||||
|  | |||||||
| @ -1,7 +1,11 @@ | |||||||
| """Outpost forms""" | """Outpost forms""" | ||||||
|  |  | ||||||
| from django import forms | from django import forms | ||||||
|  | from django.core.exceptions import ValidationError | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
|  | from kubernetes.client.configuration import Configuration | ||||||
|  | from kubernetes.config.config_exception import ConfigException | ||||||
|  | from kubernetes.config.kube_config import load_kube_config_from_dict | ||||||
|  |  | ||||||
| from authentik.admin.fields import CodeMirrorWidget, YAMLField | from authentik.admin.fields import CodeMirrorWidget, YAMLField | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| @ -71,6 +75,23 @@ class DockerServiceConnectionForm(forms.ModelForm): | |||||||
| class KubernetesServiceConnectionForm(forms.ModelForm): | class KubernetesServiceConnectionForm(forms.ModelForm): | ||||||
|     """Kubernetes service-connection form""" |     """Kubernetes service-connection form""" | ||||||
|  |  | ||||||
|  |     def clean_kubeconfig(self): | ||||||
|  |         """Validate kubeconfig by attempting to load it""" | ||||||
|  |         kubeconfig = self.cleaned_data["kubeconfig"] | ||||||
|  |         if kubeconfig == {}: | ||||||
|  |             if not self.cleaned_data["local"]: | ||||||
|  |                 raise ValidationError( | ||||||
|  |                     _("You can only use an empty kubeconfig when local is enabled.") | ||||||
|  |                 ) | ||||||
|  |             # Empty kubeconfig is valid | ||||||
|  |             return kubeconfig | ||||||
|  |         config = Configuration() | ||||||
|  |         try: | ||||||
|  |             load_kube_config_from_dict(kubeconfig, client_configuration=config) | ||||||
|  |         except ConfigException: | ||||||
|  |             raise ValidationError(_("Invalid kubeconfig")) | ||||||
|  |         return kubeconfig | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = KubernetesServiceConnection |         model = KubernetesServiceConnection | ||||||
|  | |||||||
							
								
								
									
										21
									
								
								authentik/outposts/migrations/0015_auto_20201224_1206.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								authentik/outposts/migrations/0015_auto_20201224_1206.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | |||||||
|  | # Generated by Django 3.1.4 on 2020-12-24 12:06 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_outposts", "0014_auto_20201213_1407"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="kubernetesserviceconnection", | ||||||
|  |             name="kubeconfig", | ||||||
|  |             field=models.JSONField( | ||||||
|  |                 blank=True, | ||||||
|  |                 help_text="Paste your kubeconfig here. authentik will automatically use the currently selected context.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -113,17 +113,24 @@ class OutpostServiceConnection(models.Model): | |||||||
|  |  | ||||||
|     objects = InheritanceManager() |     objects = InheritanceManager() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def state_key(self) -> str: | ||||||
|  |         """Key used to save connection state in cache""" | ||||||
|  |         return f"outpost_service_connection_{self.pk.hex}" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def state(self) -> OutpostServiceConnectionState: |     def state(self) -> OutpostServiceConnectionState: | ||||||
|         """Get state of service connection""" |         """Get state of service connection""" | ||||||
|         state_key = f"outpost_service_connection_{self.pk.hex}" |         from authentik.outposts.tasks import outpost_service_connection_state | ||||||
|         state = cache.get(state_key, None) |  | ||||||
|  |         state = cache.get(self.state_key, None) | ||||||
|         if not state: |         if not state: | ||||||
|             state = self._get_state() |             outpost_service_connection_state.delay(self.pk) | ||||||
|             cache.set(state_key, state, timeout=0) |             return OutpostServiceConnectionState("", False) | ||||||
|         return state |         return state | ||||||
|  |  | ||||||
|     def _get_state(self) -> OutpostServiceConnectionState: |     def fetch_state(self) -> OutpostServiceConnectionState: | ||||||
|  |         """Fetch current Service Connection state""" | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @ -203,7 +210,7 @@ class DockerServiceConnection(OutpostServiceConnection): | |||||||
|             raise ServiceConnectionInvalid from exc |             raise ServiceConnectionInvalid from exc | ||||||
|         return client |         return client | ||||||
|  |  | ||||||
|     def _get_state(self) -> OutpostServiceConnectionState: |     def fetch_state(self) -> OutpostServiceConnectionState: | ||||||
|         try: |         try: | ||||||
|             client = self.client() |             client = self.client() | ||||||
|             return OutpostServiceConnectionState( |             return OutpostServiceConnectionState( | ||||||
| @ -227,7 +234,8 @@ class KubernetesServiceConnection(OutpostServiceConnection): | |||||||
|                 "Paste your kubeconfig here. authentik will automatically use " |                 "Paste your kubeconfig here. authentik will automatically use " | ||||||
|                 "the currently selected context." |                 "the currently selected context." | ||||||
|             ) |             ) | ||||||
|         ) |         ), | ||||||
|  |         blank=True, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @ -239,7 +247,7 @@ class KubernetesServiceConnection(OutpostServiceConnection): | |||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"Kubernetes Service-Connection {self.name}" |         return f"Kubernetes Service-Connection {self.name}" | ||||||
|  |  | ||||||
|     def _get_state(self) -> OutpostServiceConnectionState: |     def fetch_state(self) -> OutpostServiceConnectionState: | ||||||
|         try: |         try: | ||||||
|             client = self.client() |             client = self.client() | ||||||
|             api_instance = VersionApi(client) |             api_instance = VersionApi(client) | ||||||
| @ -247,7 +255,7 @@ class KubernetesServiceConnection(OutpostServiceConnection): | |||||||
|             return OutpostServiceConnectionState( |             return OutpostServiceConnectionState( | ||||||
|                 version=version.git_version, healthy=True |                 version=version.git_version, healthy=True | ||||||
|             ) |             ) | ||||||
|         except (OpenApiException, HTTPError): |         except (OpenApiException, HTTPError, ServiceConnectionInvalid): | ||||||
|             return OutpostServiceConnectionState(version="", healthy=False) |             return OutpostServiceConnectionState(version="", healthy=False) | ||||||
|  |  | ||||||
|     def client(self) -> ApiClient: |     def client(self) -> ApiClient: | ||||||
|  | |||||||
| @ -35,21 +35,22 @@ def outpost_controller_all(): | |||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task() | @CELERY_APP.task() | ||||||
| def outpost_service_connection_state(state_pk: Any): | def outpost_service_connection_state(connection_pk: Any): | ||||||
|     """Update cached state of a service connection""" |     """Update cached state of a service connection""" | ||||||
|     connection: OutpostServiceConnection = ( |     connection: OutpostServiceConnection = ( | ||||||
|         OutpostServiceConnection.objects.filter(pk=state_pk).select_subclasses().first() |         OutpostServiceConnection.objects.filter(pk=connection_pk) | ||||||
|  |         .select_subclasses() | ||||||
|  |         .first() | ||||||
|     ) |     ) | ||||||
|     cache.delete(f"outpost_service_connection_{connection.pk.hex}") |     state = connection.fetch_state() | ||||||
|     _ = connection.state |     cache.set(connection.state_key, state, timeout=None) | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True, base=MonitoredTask) | @CELERY_APP.task(bind=True, base=MonitoredTask) | ||||||
| def outpost_service_connection_monitor(self: MonitoredTask): | def outpost_service_connection_monitor(self: MonitoredTask): | ||||||
|     """Regularly check the state of Outpost Service Connections""" |     """Regularly check the state of Outpost Service Connections""" | ||||||
|     for connection in OutpostServiceConnection.objects.select_subclasses(): |     for connection in OutpostServiceConnection.objects.all(): | ||||||
|         cache.delete(f"outpost_service_connection_{connection.pk.hex}") |         outpost_service_connection_state.delay(connection.pk) | ||||||
|         _ = connection.state |  | ||||||
|     self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL)) |     self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,11 +1,16 @@ | |||||||
| """policy API Views""" | """policy API Views""" | ||||||
|  | from django.core.cache import cache | ||||||
| from django.core.exceptions import ObjectDoesNotExist | from django.core.exceptions import ObjectDoesNotExist | ||||||
|  | from rest_framework.mixins import ListModelMixin | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ( | from rest_framework.serializers import ( | ||||||
|     ModelSerializer, |     ModelSerializer, | ||||||
|     PrimaryKeyRelatedField, |     PrimaryKeyRelatedField, | ||||||
|  |     Serializer, | ||||||
|     SerializerMethodField, |     SerializerMethodField, | ||||||
| ) | ) | ||||||
| from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet | ||||||
|  |  | ||||||
| from authentik.policies.forms import GENERAL_FIELDS | from authentik.policies.forms import GENERAL_FIELDS | ||||||
| from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel | from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel | ||||||
| @ -68,6 +73,10 @@ class PolicyViewSet(ReadOnlyModelViewSet): | |||||||
|  |  | ||||||
|     queryset = Policy.objects.all() |     queryset = Policy.objects.all() | ||||||
|     serializer_class = PolicySerializer |     serializer_class = PolicySerializer | ||||||
|  |     filterset_fields = { | ||||||
|  |         "bindings": ["isnull"], | ||||||
|  |         "promptstage": ["isnull"], | ||||||
|  |     } | ||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|         return Policy.objects.select_subclasses() |         return Policy.objects.select_subclasses() | ||||||
| @ -98,3 +107,14 @@ class PolicyBindingViewSet(ModelViewSet): | |||||||
|     serializer_class = PolicyBindingSerializer |     serializer_class = PolicyBindingSerializer | ||||||
|     filterset_fields = ["policy", "target", "enabled", "order", "timeout"] |     filterset_fields = ["policy", "target", "enabled", "order", "timeout"] | ||||||
|     search_fields = ["policy__name"] |     search_fields = ["policy__name"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PolicyCacheViewSet(ListModelMixin, GenericViewSet): | ||||||
|  |     """Info about cached policies""" | ||||||
|  |  | ||||||
|  |     queryset = Policy.objects.none() | ||||||
|  |     serializer_class = Serializer | ||||||
|  |  | ||||||
|  |     def list(self, request: Request) -> Response: | ||||||
|  |         """Info about cached policies""" | ||||||
|  |         return Response(data={"pagination": {"count": len(cache.keys("policy_*"))}}) | ||||||
|  | |||||||
| @ -47,6 +47,8 @@ class PolicyEngine: | |||||||
|     __cached_policies: List[PolicyResult] |     __cached_policies: List[PolicyResult] | ||||||
|     __processes: List[PolicyProcessInfo] |     __processes: List[PolicyProcessInfo] | ||||||
|  |  | ||||||
|  |     __expected_result_count: int | ||||||
|  |  | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None |         self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None | ||||||
|     ): |     ): | ||||||
| @ -54,11 +56,13 @@ class PolicyEngine: | |||||||
|             raise ValueError(f"{pbm} is not instance of PolicyBindingModel") |             raise ValueError(f"{pbm} is not instance of PolicyBindingModel") | ||||||
|         self.__pbm = pbm |         self.__pbm = pbm | ||||||
|         self.request = PolicyRequest(user) |         self.request = PolicyRequest(user) | ||||||
|  |         self.request.obj = pbm | ||||||
|         if request: |         if request: | ||||||
|             self.request.http_request = request |             self.request.http_request = request | ||||||
|         self.__cached_policies = [] |         self.__cached_policies = [] | ||||||
|         self.__processes = [] |         self.__processes = [] | ||||||
|         self.use_cache = True |         self.use_cache = True | ||||||
|  |         self.__expected_result_count = 0 | ||||||
|  |  | ||||||
|     def _iter_bindings(self) -> Iterator[PolicyBinding]: |     def _iter_bindings(self) -> Iterator[PolicyBinding]: | ||||||
|         """Make sure all Policies are their respective classes""" |         """Make sure all Policies are their respective classes""" | ||||||
| @ -79,6 +83,8 @@ class PolicyEngine: | |||||||
|             span.set_data("pbm", self.__pbm) |             span.set_data("pbm", self.__pbm) | ||||||
|             span.set_data("request", self.request) |             span.set_data("request", self.request) | ||||||
|             for binding in self._iter_bindings(): |             for binding in self._iter_bindings(): | ||||||
|  |                 self.__expected_result_count += 1 | ||||||
|  |  | ||||||
|                 self._check_policy_type(binding.policy) |                 self._check_policy_type(binding.policy) | ||||||
|                 key = cache_key(binding, self.request) |                 key = cache_key(binding, self.request) | ||||||
|                 cached_policy = cache.get(key, None) |                 cached_policy = cache.get(key, None) | ||||||
| @ -112,10 +118,13 @@ class PolicyEngine: | |||||||
|         process_results: List[PolicyResult] = [ |         process_results: List[PolicyResult] = [ | ||||||
|             x.result for x in self.__processes if x.result |             x.result for x in self.__processes if x.result | ||||||
|         ] |         ] | ||||||
|  |         all_results = list(process_results + self.__cached_policies) | ||||||
|         final_result = PolicyResult(False) |         final_result = PolicyResult(False) | ||||||
|         final_result.messages = [] |         final_result.messages = [] | ||||||
|         final_result.source_results = list(process_results + self.__cached_policies) |         final_result.source_results = all_results | ||||||
|         for result in process_results + self.__cached_policies: |         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( |             LOGGER.debug( | ||||||
|                 "P_ENG: result", passing=result.passing, messages=result.messages |                 "P_ENG: result", passing=result.passing, messages=result.messages | ||||||
|             ) |             ) | ||||||
|  | |||||||
| @ -1,16 +1,21 @@ | |||||||
| """authentik expression policy evaluator""" | """authentik expression policy evaluator""" | ||||||
| from ipaddress import ip_address, ip_network | from ipaddress import ip_address, ip_network | ||||||
| from typing import List | from traceback import format_tb | ||||||
|  | from typing import TYPE_CHECKING, List, Optional | ||||||
|  |  | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from structlog import get_logger | from structlog 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.flows.planner import PLAN_CONTEXT_SSO | ||||||
| from authentik.lib.expression.evaluator import BaseEvaluator | from authentik.lib.expression.evaluator import BaseEvaluator | ||||||
| from authentik.lib.utils.http import get_client_ip | from authentik.lib.utils.http import get_client_ip | ||||||
| from authentik.policies.types import PolicyRequest, PolicyResult | from authentik.policies.types import PolicyRequest, PolicyResult | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from authentik.policies.expression.models import ExpressionPolicy | ||||||
|  |  | ||||||
|  |  | ||||||
| class PolicyEvaluator(BaseEvaluator): | class PolicyEvaluator(BaseEvaluator): | ||||||
| @ -18,9 +23,12 @@ class PolicyEvaluator(BaseEvaluator): | |||||||
|  |  | ||||||
|     _messages: List[str] |     _messages: List[str] | ||||||
|  |  | ||||||
|  |     policy: Optional["ExpressionPolicy"] = None | ||||||
|  |  | ||||||
|     def __init__(self, policy_name: str): |     def __init__(self, policy_name: str): | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self._messages = [] |         self._messages = [] | ||||||
|  |         self._context["ak_logger"] = get_logger(policy_name) | ||||||
|         self._context["ak_message"] = self.expr_func_message |         self._context["ak_message"] = self.expr_func_message | ||||||
|         self._context["ip_address"] = ip_address |         self._context["ip_address"] = ip_address | ||||||
|         self._context["ip_network"] = ip_network |         self._context["ip_network"] = ip_network | ||||||
| @ -45,15 +53,30 @@ class PolicyEvaluator(BaseEvaluator): | |||||||
|         self._context["ak_client_ip"] = ip_address( |         self._context["ak_client_ip"] = ip_address( | ||||||
|             get_client_ip(request) or "255.255.255.255" |             get_client_ip(request) or "255.255.255.255" | ||||||
|         ) |         ) | ||||||
|         self._context["request"] = request |         self._context["http_request"] = request | ||||||
|  |  | ||||||
|  |     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() | ||||||
|  |  | ||||||
|     def evaluate(self, expression_source: str) -> PolicyResult: |     def evaluate(self, expression_source: str) -> PolicyResult: | ||||||
|         """Parse and evaluate expression. Policy is expected to return a truthy object. |         """Parse and evaluate expression. Policy is expected to return a truthy object. | ||||||
|         Messages can be added using 'do ak_message()'.""" |         Messages can be added using 'do ak_message()'.""" | ||||||
|         try: |         try: | ||||||
|             result = super().evaluate(expression_source) |             result = super().evaluate(expression_source) | ||||||
|         except (ValueError, SyntaxError) as exc: |  | ||||||
|             return PolicyResult(False, str(exc)) |  | ||||||
|         except Exception as exc:  # pylint: disable=broad-except |         except Exception as exc:  # pylint: disable=broad-except | ||||||
|             LOGGER.warning("Expression error", exc=exc) |             LOGGER.warning("Expression error", exc=exc) | ||||||
|             return PolicyResult(False, str(exc)) |             return PolicyResult(False, str(exc)) | ||||||
|  | |||||||
| @ -31,11 +31,14 @@ class ExpressionPolicy(Policy): | |||||||
|     def passes(self, request: PolicyRequest) -> PolicyResult: |     def passes(self, request: PolicyRequest) -> PolicyResult: | ||||||
|         """Evaluate and render expression. Returns PolicyResult(false) on error.""" |         """Evaluate and render expression. Returns PolicyResult(false) on error.""" | ||||||
|         evaluator = PolicyEvaluator(self.name) |         evaluator = PolicyEvaluator(self.name) | ||||||
|  |         evaluator.policy = self | ||||||
|         evaluator.set_policy_request(request) |         evaluator.set_policy_request(request) | ||||||
|         return evaluator.evaluate(self.expression) |         return evaluator.evaluate(self.expression) | ||||||
|  |  | ||||||
|     def save(self, *args, **kwargs): |     def save(self, *args, **kwargs): | ||||||
|         PolicyEvaluator(self.name).validate(self.expression) |         evaluator = PolicyEvaluator(self.name) | ||||||
|  |         evaluator.policy = self | ||||||
|  |         evaluator.validate(self.expression) | ||||||
|         return super().save(*args, **kwargs) |         return super().save(*args, **kwargs) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  | |||||||
| @ -3,7 +3,9 @@ from django.core.exceptions import ValidationError | |||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.policies.expression.evaluator import PolicyEvaluator | from authentik.policies.expression.evaluator import PolicyEvaluator | ||||||
|  | from authentik.policies.expression.models import ExpressionPolicy | ||||||
| from authentik.policies.types import PolicyRequest | from authentik.policies.types import PolicyRequest | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -13,6 +15,14 @@ class TestEvaluator(TestCase): | |||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.request = PolicyRequest(user=get_anonymous_user()) |         self.request = PolicyRequest(user=get_anonymous_user()) | ||||||
|  |  | ||||||
|  |     def test_full(self): | ||||||
|  |         """Test full with Policy instance""" | ||||||
|  |         policy = ExpressionPolicy(name="test", expression="return 'test'") | ||||||
|  |         policy.save() | ||||||
|  |         request = PolicyRequest(get_anonymous_user()) | ||||||
|  |         result = policy.passes(request) | ||||||
|  |         self.assertTrue(result.passing) | ||||||
|  |  | ||||||
|     def test_valid(self): |     def test_valid(self): | ||||||
|         """test simple value expression""" |         """test simple value expression""" | ||||||
|         template = "return True" |         template = "return True" | ||||||
| @ -37,6 +47,12 @@ class TestEvaluator(TestCase): | |||||||
|         result = evaluator.evaluate(template) |         result = evaluator.evaluate(template) | ||||||
|         self.assertEqual(result.passing, False) |         self.assertEqual(result.passing, False) | ||||||
|         self.assertEqual(result.messages, ("invalid syntax (test, line 3)",)) |         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): |     def test_undefined(self): | ||||||
|         """test undefined result""" |         """test undefined result""" | ||||||
| @ -46,6 +62,12 @@ class TestEvaluator(TestCase): | |||||||
|         result = evaluator.evaluate(template) |         result = evaluator.evaluate(template) | ||||||
|         self.assertEqual(result.passing, False) |         self.assertEqual(result.passing, False) | ||||||
|         self.assertEqual(result.messages, ("name 'foo' is not defined",)) |         self.assertEqual(result.messages, ("name 'foo' is not defined",)) | ||||||
|  |         self.assertTrue( | ||||||
|  |             Event.objects.filter( | ||||||
|  |                 action=EventAction.POLICY_EXCEPTION, | ||||||
|  |                 context__expression=template, | ||||||
|  |             ).exists() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_validate(self): |     def test_validate(self): | ||||||
|         """test validate""" |         """test validate""" | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django import forms | |||||||
| from authentik.lib.widgets import GroupedModelChoiceField | from authentik.lib.widgets import GroupedModelChoiceField | ||||||
| from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel | from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel | ||||||
|  |  | ||||||
| GENERAL_FIELDS = ["name"] | GENERAL_FIELDS = ["name", "execution_logging"] | ||||||
| GENERAL_SERIALIZER_FIELDS = ["pk", "name"] | GENERAL_SERIALIZER_FIELDS = ["pk", "name"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -0,0 +1,21 @@ | |||||||
|  | # Generated by Django 3.1.4 on 2020-12-15 09:41 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_policies", "0003_auto_20200908_1542"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="policy", | ||||||
|  |             name="execution_logging", | ||||||
|  |             field=models.BooleanField( | ||||||
|  |                 default=False, | ||||||
|  |                 help_text="When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -81,6 +81,16 @@ class Policy(SerializerModel, CreatedUpdatedModel): | |||||||
|  |  | ||||||
|     name = models.TextField(blank=True, null=True) |     name = models.TextField(blank=True, null=True) | ||||||
|  |  | ||||||
|  |     execution_logging = models.BooleanField( | ||||||
|  |         default=False, | ||||||
|  |         help_text=_( | ||||||
|  |             ( | ||||||
|  |                 "When this option is enabled, all executions of this policy will be logged. " | ||||||
|  |                 "By default, only execution errors are logged." | ||||||
|  |             ) | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     objects = InheritanceAutoManager() |     objects = InheritanceAutoManager() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ from sentry_sdk.hub import Hub | |||||||
| from sentry_sdk.tracing import Span | from sentry_sdk.tracing import Span | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.policies.exceptions import PolicyException | from authentik.policies.exceptions import PolicyException | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
| from authentik.policies.types import PolicyRequest, PolicyResult | from authentik.policies.types import PolicyRequest, PolicyResult | ||||||
| @ -48,12 +49,6 @@ class PolicyProcess(Process): | |||||||
|  |  | ||||||
|     def execute(self) -> PolicyResult: |     def execute(self) -> PolicyResult: | ||||||
|         """Run actual policy, returns result""" |         """Run actual policy, returns result""" | ||||||
|         with Hub.current.start_span( |  | ||||||
|             op="policy.process.execute", |  | ||||||
|         ) as span: |  | ||||||
|             span: Span |  | ||||||
|             span.set_data("policy", self.binding.policy) |  | ||||||
|             span.set_data("request", self.request) |  | ||||||
|         LOGGER.debug( |         LOGGER.debug( | ||||||
|             "P_ENG(proc): Running policy", |             "P_ENG(proc): Running policy", | ||||||
|             policy=self.binding.policy, |             policy=self.binding.policy, | ||||||
| @ -62,6 +57,14 @@ class PolicyProcess(Process): | |||||||
|         ) |         ) | ||||||
|         try: |         try: | ||||||
|             policy_result = self.binding.policy.passes(self.request) |             policy_result = self.binding.policy.passes(self.request) | ||||||
|  |             if self.binding.policy.execution_logging: | ||||||
|  |                 event = Event.new( | ||||||
|  |                     EventAction.POLICY_EXECUTION, | ||||||
|  |                     request=self.request, | ||||||
|  |                     result=policy_result, | ||||||
|  |                 ) | ||||||
|  |                 event.set_user(self.request.user) | ||||||
|  |                 event.save() | ||||||
|         except PolicyException as exc: |         except PolicyException as exc: | ||||||
|             LOGGER.debug("P_ENG(proc): error", exc=exc) |             LOGGER.debug("P_ENG(proc): error", exc=exc) | ||||||
|             policy_result = PolicyResult(False, str(exc)) |             policy_result = PolicyResult(False, str(exc)) | ||||||
| @ -82,6 +85,16 @@ class PolicyProcess(Process): | |||||||
|         LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key) |         LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key) | ||||||
|         return policy_result |         return policy_result | ||||||
|  |  | ||||||
|     def run(self): |     def run(self):  # pragma: no cover | ||||||
|         """Task wrapper to run policy checking""" |         """Task wrapper to run policy checking""" | ||||||
|  |         with Hub.current.start_span( | ||||||
|  |             op="policy.process.execute", | ||||||
|  |         ) as span: | ||||||
|  |             span: Span | ||||||
|  |             span.set_data("policy", self.binding.policy) | ||||||
|  |             span.set_data("request", self.request) | ||||||
|  |             try: | ||||||
|                 self.connection.send(self.execute()) |                 self.connection.send(self.execute()) | ||||||
|  |             except Exception as exc:  # pylint: disable=broad-except | ||||||
|  |                 LOGGER.warning(exc) | ||||||
|  |                 self.connection.send(PolicyResult(False, str(exc))) | ||||||
|  | |||||||
| @ -7,13 +7,14 @@ from authentik.policies.dummy.models import DummyPolicy | |||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.policies.expression.models import ExpressionPolicy | from authentik.policies.expression.models import ExpressionPolicy | ||||||
| from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel | from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel | ||||||
|  | from authentik.policies.tests.test_process import clear_policy_cache | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestPolicyEngine(TestCase): | class TestPolicyEngine(TestCase): | ||||||
|     """PolicyEngine tests""" |     """PolicyEngine tests""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         cache.clear() |         clear_policy_cache() | ||||||
|         self.user = User.objects.create_user(username="policyuser") |         self.user = User.objects.create_user(username="policyuser") | ||||||
|         self.policy_false = DummyPolicy.objects.create( |         self.policy_false = DummyPolicy.objects.create( | ||||||
|             result=False, wait_min=0, wait_max=1 |             result=False, wait_min=0, wait_max=1 | ||||||
| @ -34,6 +35,15 @@ class TestPolicyEngine(TestCase): | |||||||
|         self.assertEqual(result.passing, True) |         self.assertEqual(result.passing, True) | ||||||
|         self.assertEqual(result.messages, ()) |         self.assertEqual(result.messages, ()) | ||||||
|  |  | ||||||
|  |     def test_engine_simple(self): | ||||||
|  |         """Ensure simplest use-case""" | ||||||
|  |         pbm = PolicyBindingModel.objects.create() | ||||||
|  |         PolicyBinding.objects.create(target=pbm, policy=self.policy_true, order=0) | ||||||
|  |         engine = PolicyEngine(pbm, self.user) | ||||||
|  |         result = engine.build().result | ||||||
|  |         self.assertEqual(result.passing, True) | ||||||
|  |         self.assertEqual(result.messages, ("dummy",)) | ||||||
|  |  | ||||||
|     def test_engine(self): |     def test_engine(self): | ||||||
|         """Ensure all policies passes (Mix of false and true -> false)""" |         """Ensure all policies passes (Mix of false and true -> false)""" | ||||||
|         pbm = PolicyBindingModel.objects.create() |         pbm = PolicyBindingModel.objects.create() | ||||||
| @ -75,10 +85,18 @@ class TestPolicyEngine(TestCase): | |||||||
|     def test_engine_cache(self): |     def test_engine_cache(self): | ||||||
|         """Ensure empty policy list passes""" |         """Ensure empty policy list passes""" | ||||||
|         pbm = PolicyBindingModel.objects.create() |         pbm = PolicyBindingModel.objects.create() | ||||||
|         PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0) |         binding = PolicyBinding.objects.create( | ||||||
|  |             target=pbm, policy=self.policy_false, order=0 | ||||||
|  |         ) | ||||||
|         engine = PolicyEngine(pbm, self.user) |         engine = PolicyEngine(pbm, self.user) | ||||||
|         self.assertEqual(len(cache.keys("policy_*")), 0) |         self.assertEqual( | ||||||
|  |             len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 0 | ||||||
|  |         ) | ||||||
|         self.assertEqual(engine.build().passing, False) |         self.assertEqual(engine.build().passing, False) | ||||||
|         self.assertEqual(len(cache.keys("policy_*")), 1) |         self.assertEqual( | ||||||
|  |             len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 1 | ||||||
|  |         ) | ||||||
|         self.assertEqual(engine.build().passing, False) |         self.assertEqual(engine.build().passing, False) | ||||||
|         self.assertEqual(len(cache.keys("policy_*")), 1) |         self.assertEqual( | ||||||
|  |             len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 1 | ||||||
|  |         ) | ||||||
|  | |||||||
							
								
								
									
										105
									
								
								authentik/policies/tests/test_process.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								authentik/policies/tests/test_process.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,105 @@ | |||||||
|  | """policy process tests""" | ||||||
|  | from django.core.cache import cache | ||||||
|  | from django.test import TestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import User | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
|  | from authentik.policies.dummy.models import DummyPolicy | ||||||
|  | from authentik.policies.expression.models import ExpressionPolicy | ||||||
|  | from authentik.policies.models import Policy, PolicyBinding | ||||||
|  | from authentik.policies.process import PolicyProcess | ||||||
|  | from authentik.policies.types import PolicyRequest | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def clear_policy_cache(): | ||||||
|  |     """Ensure no policy-related keys are stil cached""" | ||||||
|  |     keys = cache.keys("policy_*") | ||||||
|  |     cache.delete(keys) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestPolicyProcess(TestCase): | ||||||
|  |     """Policy Process tests""" | ||||||
|  |  | ||||||
|  |     def setUp(self): | ||||||
|  |         clear_policy_cache() | ||||||
|  |         self.user = User.objects.create_user(username="policyuser") | ||||||
|  |  | ||||||
|  |     def test_invalid(self): | ||||||
|  |         """Test Process with invalid arguments""" | ||||||
|  |         policy = DummyPolicy.objects.create(result=True, wait_min=0, wait_max=1) | ||||||
|  |         binding = PolicyBinding(policy=policy) | ||||||
|  |         with self.assertRaises(ValueError): | ||||||
|  |             PolicyProcess(binding, None, None)  # type: ignore | ||||||
|  |  | ||||||
|  |     def test_true(self): | ||||||
|  |         """Test policy execution""" | ||||||
|  |         policy = DummyPolicy.objects.create(result=True, wait_min=0, wait_max=1) | ||||||
|  |         binding = PolicyBinding(policy=policy) | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, True) | ||||||
|  |         self.assertEqual(response.messages, ("dummy",)) | ||||||
|  |  | ||||||
|  |     def test_false(self): | ||||||
|  |         """Test policy execution""" | ||||||
|  |         policy = DummyPolicy.objects.create(result=False, wait_min=0, wait_max=1) | ||||||
|  |         binding = PolicyBinding(policy=policy) | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, False) | ||||||
|  |         self.assertEqual(response.messages, ("dummy",)) | ||||||
|  |  | ||||||
|  |     def test_negate(self): | ||||||
|  |         """Test policy execution""" | ||||||
|  |         policy = DummyPolicy.objects.create(result=False, wait_min=0, wait_max=1) | ||||||
|  |         binding = PolicyBinding(policy=policy, negate=True) | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, True) | ||||||
|  |         self.assertEqual(response.messages, ("dummy",)) | ||||||
|  |  | ||||||
|  |     def test_exception(self): | ||||||
|  |         """Test policy execution""" | ||||||
|  |         policy = Policy.objects.create() | ||||||
|  |         binding = PolicyBinding(policy=policy) | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, False) | ||||||
|  |  | ||||||
|  |     def test_execution_logging(self): | ||||||
|  |         """Test policy execution creates event""" | ||||||
|  |         policy = DummyPolicy.objects.create( | ||||||
|  |             result=False, wait_min=0, wait_max=1, execution_logging=True | ||||||
|  |         ) | ||||||
|  |         binding = PolicyBinding(policy=policy) | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, False) | ||||||
|  |         self.assertEqual(response.messages, ("dummy",)) | ||||||
|  |  | ||||||
|  |         events = Event.objects.filter( | ||||||
|  |             action=EventAction.POLICY_EXECUTION, | ||||||
|  |         ) | ||||||
|  |         self.assertTrue(events.exists()) | ||||||
|  |         self.assertEqual(len(events), 1) | ||||||
|  |         event = events.first() | ||||||
|  |         self.assertEqual(event.context["result"]["passing"], False) | ||||||
|  |         self.assertEqual(event.context["result"]["messages"], ["dummy"]) | ||||||
|  |  | ||||||
|  |     def test_raises(self): | ||||||
|  |         """Test policy that raises error""" | ||||||
|  |         policy_raises = ExpressionPolicy.objects.create( | ||||||
|  |             name="raises", expression="{{ 0/0 }}" | ||||||
|  |         ) | ||||||
|  |         binding = PolicyBinding(policy=policy_raises) | ||||||
|  |  | ||||||
|  |         request = PolicyRequest(self.user) | ||||||
|  |         response = PolicyProcess(binding, request, None).execute() | ||||||
|  |         self.assertEqual(response.passing, False) | ||||||
|  |         self.assertEqual(response.messages, ("division by zero",)) | ||||||
|  |         # self.assert | ||||||
| @ -1,6 +1,7 @@ | |||||||
| """policy structures""" | """policy structures""" | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from dataclasses import dataclass | ||||||
| from typing import TYPE_CHECKING, Dict, List, Optional, Tuple | from typing import TYPE_CHECKING, Dict, List, Optional, Tuple | ||||||
|  |  | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| @ -11,6 +12,7 @@ if TYPE_CHECKING: | |||||||
|     from authentik.policies.models import Policy |     from authentik.policies.models import Policy | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
| class PolicyRequest: | class PolicyRequest: | ||||||
|     """Data-class to hold policy request data""" |     """Data-class to hold policy request data""" | ||||||
|  |  | ||||||
| @ -20,6 +22,7 @@ class PolicyRequest: | |||||||
|     context: Dict[str, str] |     context: Dict[str, str] | ||||||
|  |  | ||||||
|     def __init__(self, user: User): |     def __init__(self, user: User): | ||||||
|  |         super().__init__() | ||||||
|         self.user = user |         self.user = user | ||||||
|         self.http_request = None |         self.http_request = None | ||||||
|         self.obj = None |         self.obj = None | ||||||
| @ -29,6 +32,7 @@ class PolicyRequest: | |||||||
|         return f"<PolicyRequest user={self.user}>" |         return f"<PolicyRequest user={self.user}>" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
| class PolicyResult: | class PolicyResult: | ||||||
|     """Small data-class to hold policy results""" |     """Small data-class to hold policy results""" | ||||||
|  |  | ||||||
| @ -39,6 +43,7 @@ class PolicyResult: | |||||||
|     source_results: Optional[List["PolicyResult"]] |     source_results: Optional[List["PolicyResult"]] | ||||||
|  |  | ||||||
|     def __init__(self, passing: bool, *messages: str): |     def __init__(self, passing: bool, *messages: str): | ||||||
|  |         super().__init__() | ||||||
|         self.passing = passing |         self.passing = passing | ||||||
|         self.messages = messages |         self.messages = messages | ||||||
|         self.source_policy = None |         self.source_policy = None | ||||||
| @ -49,5 +54,5 @@ class PolicyResult: | |||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         if self.messages: |         if self.messages: | ||||||
|             return f"PolicyResult passing={self.passing} messages={self.messages}" |             return f"<PolicyResult passing={self.passing} messages={self.messages}>" | ||||||
|         return f"PolicyResult passing={self.passing}" |         return f"<PolicyResult passing={self.passing}>" | ||||||
|  | |||||||
| @ -11,6 +11,7 @@ from structlog import get_logger | |||||||
|  |  | ||||||
| from authentik.core.models import Application, Provider, User | from authentik.core.models import Application, Provider, User | ||||||
| from authentik.flows.views import SESSION_KEY_APPLICATION_PRE | from authentik.flows.views import SESSION_KEY_APPLICATION_PRE | ||||||
|  | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.policies.http import AccessDeniedResponse | from authentik.policies.http import AccessDeniedResponse | ||||||
| from authentik.policies.types import PolicyResult | from authentik.policies.types import PolicyResult | ||||||
| @ -18,6 +19,17 @@ from authentik.policies.types import PolicyResult | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class RequestValidationError(SentryIgnoredException): | ||||||
|  |     """Error raised in pre_permission_check, when a request is invalid.""" | ||||||
|  |  | ||||||
|  |     response: Optional[HttpResponse] | ||||||
|  |  | ||||||
|  |     def __init__(self, response: Optional[HttpResponse] = None): | ||||||
|  |         super().__init__() | ||||||
|  |         if response: | ||||||
|  |             self.response = response | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseMixin: | class BaseMixin: | ||||||
|     """Base Mixin class, used to annotate View Member variables""" |     """Base Mixin class, used to annotate View Member variables""" | ||||||
|  |  | ||||||
| @ -31,6 +43,10 @@ class PolicyAccessView(AccessMixin, View): | |||||||
|     provider: Provider |     provider: Provider | ||||||
|     application: Application |     application: Application | ||||||
|  |  | ||||||
|  |     def pre_permission_check(self): | ||||||
|  |         """Optionally hook in before permission check to check if a request is valid. | ||||||
|  |         Can raise `RequestValidationError` to return a response.""" | ||||||
|  |  | ||||||
|     def resolve_provider_application(self): |     def resolve_provider_application(self): | ||||||
|         """Resolve self.provider and self.application. *.DoesNotExist Exceptions cause a normal |         """Resolve self.provider and self.application. *.DoesNotExist Exceptions cause a normal | ||||||
|         AccessDenied view to be shown. An Http404 exception |         AccessDenied view to be shown. An Http404 exception | ||||||
| @ -38,6 +54,12 @@ class PolicyAccessView(AccessMixin, View): | |||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: |     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: | ||||||
|  |         try: | ||||||
|  |             self.pre_permission_check() | ||||||
|  |         except RequestValidationError as exc: | ||||||
|  |             if exc.response: | ||||||
|  |                 return exc.response | ||||||
|  |             return self.handle_no_permission() | ||||||
|         try: |         try: | ||||||
|             self.resolve_provider_application() |             self.resolve_provider_application() | ||||||
|         except (Application.DoesNotExist, Provider.DoesNotExist): |         except (Application.DoesNotExist, Provider.DoesNotExist): | ||||||
| @ -82,7 +104,7 @@ class PolicyAccessView(AccessMixin, View): | |||||||
|         policy_engine.build() |         policy_engine.build() | ||||||
|         result = policy_engine.result |         result = policy_engine.result | ||||||
|         LOGGER.debug( |         LOGGER.debug( | ||||||
|             "AccessMixin user_has_access", |             "PolicyAccessView user_has_access", | ||||||
|             user=user, |             user=user, | ||||||
|             app=self.application, |             app=self.application, | ||||||
|             result=result, |             result=result, | ||||||
|  | |||||||
| @ -2,10 +2,11 @@ | |||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
|  | from authentik.core.api.utils import MetaNameSerializer | ||||||
| from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping | from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping | ||||||
|  |  | ||||||
|  |  | ||||||
| class OAuth2ProviderSerializer(ModelSerializer): | class OAuth2ProviderSerializer(ModelSerializer, MetaNameSerializer): | ||||||
|     """OAuth2Provider Serializer""" |     """OAuth2Provider Serializer""" | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
| @ -19,12 +20,15 @@ class OAuth2ProviderSerializer(ModelSerializer): | |||||||
|             "client_id", |             "client_id", | ||||||
|             "client_secret", |             "client_secret", | ||||||
|             "token_validity", |             "token_validity", | ||||||
|             "response_type", |             "include_claims_in_id_token", | ||||||
|             "jwt_alg", |             "jwt_alg", | ||||||
|             "rsa_key", |             "rsa_key", | ||||||
|             "redirect_uris", |             "redirect_uris", | ||||||
|             "sub_mode", |             "sub_mode", | ||||||
|             "property_mappings", |             "property_mappings", | ||||||
|  |             "issuer_mode", | ||||||
|  |             "verbose_name", | ||||||
|  |             "verbose_name_plural", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" | |||||||
| GRANT_TYPE_REFRESH_TOKEN = "refresh_token"  # nosec | GRANT_TYPE_REFRESH_TOKEN = "refresh_token"  # nosec | ||||||
| PROMPT_NONE = "none" | PROMPT_NONE = "none" | ||||||
| PROMPT_CONSNET = "consent" | PROMPT_CONSNET = "consent" | ||||||
|  | PROMPT_LOGIN = "login" | ||||||
| SCOPE_OPENID = "openid" | SCOPE_OPENID = "openid" | ||||||
| SCOPE_OPENID_PROFILE = "profile" | SCOPE_OPENID_PROFILE = "profile" | ||||||
| SCOPE_OPENID_EMAIL = "email" | SCOPE_OPENID_EMAIL = "email" | ||||||
| @ -16,3 +17,5 @@ SCOPE_GITHUB_USER_READ = "read:user" | |||||||
| SCOPE_GITHUB_USER_EMAIL = "user:email" | SCOPE_GITHUB_USER_EMAIL = "user:email" | ||||||
| # Read info about teams | # Read info about teams | ||||||
| SCOPE_GITHUB_ORG_READ = "read:org" | SCOPE_GITHUB_ORG_READ = "read:org" | ||||||
|  |  | ||||||
|  | ACR_AUTHENTIK_DEFAULT = "goauthentik.io/providers/oauth2/default" | ||||||
|  | |||||||
| @ -1,8 +1,11 @@ | |||||||
| """OAuth errors""" | """OAuth errors""" | ||||||
| from urllib.parse import quote | from urllib.parse import quote | ||||||
|  |  | ||||||
|  | from authentik.lib.sentry import SentryIgnoredException | ||||||
|  | from authentik.providers.oauth2.models import GrantTypes | ||||||
|  |  | ||||||
| class OAuth2Error(Exception): |  | ||||||
|  | class OAuth2Error(SentryIgnoredException): | ||||||
|     """Base class for all OAuth2 Errors""" |     """Base class for all OAuth2 Errors""" | ||||||
|  |  | ||||||
|     error: str |     error: str | ||||||
| @ -96,27 +99,34 @@ class AuthorizeError(OAuth2Error): | |||||||
|         "the registration parameter", |         "the registration parameter", | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     def __init__(self, redirect_uri, error, grant_type): |     def __init__( | ||||||
|  |         self, | ||||||
|  |         redirect_uri: str, | ||||||
|  |         error: str, | ||||||
|  |         grant_type: str, | ||||||
|  |         state: str, | ||||||
|  |     ): | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.error = error |         self.error = error | ||||||
|         self.description = self._errors[error] |         self.description = self._errors[error] | ||||||
|         self.redirect_uri = redirect_uri |         self.redirect_uri = redirect_uri | ||||||
|         self.grant_type = grant_type |         self.grant_type = grant_type | ||||||
|  |         self.state = state | ||||||
|  |  | ||||||
|     def create_uri(self, redirect_uri: str, state: str) -> str: |     def create_uri(self) -> str: | ||||||
|         """Get a redirect URI with the error message""" |         """Get a redirect URI with the error message""" | ||||||
|         description = quote(str(self.description)) |         description = quote(str(self.description)) | ||||||
|  |  | ||||||
|         # See: |         # See: | ||||||
|         # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError |         # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError | ||||||
|         hash_or_question = "#" if self.grant_type == "implicit" else "?" |         hash_or_question = "#" if self.grant_type == GrantTypes.IMPLICIT else "?" | ||||||
|  |  | ||||||
|         uri = "{0}{1}error={2}&error_description={3}".format( |         uri = "{0}{1}error={2}&error_description={3}".format( | ||||||
|             redirect_uri, hash_or_question, self.error, description |             self.redirect_uri, hash_or_question, self.error, description | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Add state if present. |         # Add state if present. | ||||||
|         uri = uri + ("&state={0}".format(state) if state else "") |         uri = uri + ("&state={0}".format(self.state) if self.state else "") | ||||||
|  |  | ||||||
|         return uri |         return uri | ||||||
|  |  | ||||||
|  | |||||||
| @ -53,13 +53,14 @@ class OAuth2ProviderForm(forms.ModelForm): | |||||||
|             "client_type", |             "client_type", | ||||||
|             "client_id", |             "client_id", | ||||||
|             "client_secret", |             "client_secret", | ||||||
|             "response_type", |  | ||||||
|             "token_validity", |             "token_validity", | ||||||
|             "jwt_alg", |             "jwt_alg", | ||||||
|  |             "property_mappings", | ||||||
|             "rsa_key", |             "rsa_key", | ||||||
|             "redirect_uris", |             "redirect_uris", | ||||||
|             "sub_mode", |             "sub_mode", | ||||||
|             "property_mappings", |             "include_claims_in_id_token", | ||||||
|  |             "issuer_mode", | ||||||
|         ] |         ] | ||||||
|         widgets = { |         widgets = { | ||||||
|             "name": forms.TextInput(), |             "name": forms.TextInput(), | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	