Compare commits
	
		
			252 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 | |||
| 9f2e9e8444 | |||
| a3d361f500 | |||
| e9bb583b32 | |||
| efccf47c83 | |||
| a5b144cf8f | |||
| afc5a17fc2 | |||
| b3e0884b2e | |||
| 078d648551 | |||
| 41f9097592 | |||
| 562175741c | |||
| 24e24cb97e | |||
| 69b0a23a7d | |||
| f0f3245388 | |||
| 99ca0d1f9f | |||
| c9f0d048a8 | |||
| 90a94b5e3e | |||
| ae1a8842db | |||
| a3b17d1ed4 | |||
| 41576e27be | |||
| 07082cb3aa | |||
| 426cb33fab | |||
| 9e4f840d2d | |||
| e120d274e9 | |||
| 977d3f6ef9 | |||
| ecdbc917a5 | |||
| 0083cd55df | |||
| d380194e13 | |||
| 32f5d5ba72 | |||
| e818416863 | |||
| 7eed70cfe9 | |||
| ea6ca23f57 | |||
| f056b026d6 | |||
| 1c0a6efeb1 | |||
| 17732eea08 | |||
| aa5381fd59 | |||
| ffee86fcf3 | |||
| 7ff7398aff | |||
| 67925a39f2 | |||
| 3b5e1c7b34 | |||
| 3e49acf7ae | |||
| 76764c4374 | |||
| 9f6f8e1b55 | |||
| 9590180c6c | |||
| aef5c60a7b | |||
| d4c9c667c9 | |||
| 96f0d582f0 | |||
| 7e8702a71e | |||
| 1524061480 | |||
| 434922f702 | |||
| d2862ddc93 | |||
| 6e55431d4c | |||
| 01548c5e9c | |||
| bf1dae2dbe | 
@ -1,5 +1,5 @@
 | 
			
		||||
[bumpversion]
 | 
			
		||||
current_version = 0.13.0-rc2
 | 
			
		||||
current_version = 0.14.2-stable
 | 
			
		||||
tag = True
 | 
			
		||||
commit = True
 | 
			
		||||
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
 | 
			
		||||
        run: docker build
 | 
			
		||||
          --no-cache
 | 
			
		||||
          -t beryju/authentik:0.13.0-rc2
 | 
			
		||||
          -t beryju/authentik:0.14.2-stable
 | 
			
		||||
          -t beryju/authentik:latest
 | 
			
		||||
          -f Dockerfile .
 | 
			
		||||
      - name: Push Docker Container to Registry (versioned)
 | 
			
		||||
        run: docker push beryju/authentik:0.13.0-rc2
 | 
			
		||||
        run: docker push beryju/authentik:0.14.2-stable
 | 
			
		||||
      - name: Push Docker Container to Registry (latest)
 | 
			
		||||
        run: docker push beryju/authentik:latest
 | 
			
		||||
  build-proxy:
 | 
			
		||||
@ -48,11 +48,11 @@ jobs:
 | 
			
		||||
          cd proxy/
 | 
			
		||||
          docker build \
 | 
			
		||||
          --no-cache \
 | 
			
		||||
          -t beryju/authentik-proxy:0.13.0-rc2 \
 | 
			
		||||
          -t beryju/authentik-proxy:0.14.2-stable \
 | 
			
		||||
          -t beryju/authentik-proxy:latest \
 | 
			
		||||
          -f Dockerfile .
 | 
			
		||||
      - name: Push Docker Container to Registry (versioned)
 | 
			
		||||
        run: docker push beryju/authentik-proxy:0.13.0-rc2
 | 
			
		||||
        run: docker push beryju/authentik-proxy:0.14.2-stable
 | 
			
		||||
      - name: Push Docker Container to Registry (latest)
 | 
			
		||||
        run: docker push beryju/authentik-proxy:latest
 | 
			
		||||
  build-static:
 | 
			
		||||
@ -69,11 +69,11 @@ jobs:
 | 
			
		||||
          cd web/
 | 
			
		||||
          docker build \
 | 
			
		||||
          --no-cache \
 | 
			
		||||
          -t beryju/authentik-static:0.13.0-rc2 \
 | 
			
		||||
          -t beryju/authentik-static:0.14.2-stable \
 | 
			
		||||
          -t beryju/authentik-static:latest \
 | 
			
		||||
          -f Dockerfile .
 | 
			
		||||
      - name: Push Docker Container to Registry (versioned)
 | 
			
		||||
        run: docker push beryju/authentik-static:0.13.0-rc2
 | 
			
		||||
        run: docker push beryju/authentik-static:0.14.2-stable
 | 
			
		||||
      - name: Push Docker Container to Registry (latest)
 | 
			
		||||
        run: docker push beryju/authentik-static:latest
 | 
			
		||||
  test-release:
 | 
			
		||||
@ -107,5 +107,5 @@ jobs:
 | 
			
		||||
          SENTRY_PROJECT: authentik
 | 
			
		||||
          SENTRY_URL: https://sentry.beryju.org
 | 
			
		||||
        with:
 | 
			
		||||
          tagName: 0.13.0-rc2
 | 
			
		||||
          tagName: 0.14.2-stable
 | 
			
		||||
          environment: beryjuorg-prod
 | 
			
		||||
 | 
			
		||||
@ -38,6 +38,7 @@ RUN apt-get update && \
 | 
			
		||||
 | 
			
		||||
COPY ./authentik/ /authentik
 | 
			
		||||
COPY ./pytest.ini /
 | 
			
		||||
COPY ./xml /xml
 | 
			
		||||
COPY ./manage.py /
 | 
			
		||||
COPY ./lifecycle/ /lifecycle
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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
 | 
			
		||||
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:
 | 
			
		||||
                            Preamble
 | 
			
		||||
 | 
			
		||||
The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
copies or substantial portions of the Software.
 | 
			
		||||
  The GNU General Public License is a free, copyleft license for
 | 
			
		||||
software and other kinds of works.
 | 
			
		||||
 | 
			
		||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
SOFTWARE.
 | 
			
		||||
  The licenses for most software and other practical works are designed
 | 
			
		||||
to take away your freedom to share and change the works.  By contrast,
 | 
			
		||||
the GNU General Public License is intended to guarantee your freedom to
 | 
			
		||||
share and change all versions of a program--to make sure it remains free
 | 
			
		||||
software for all its users.  We, the Free Software Foundation, use the
 | 
			
		||||
GNU General Public License for most of our software; it applies also to
 | 
			
		||||
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>.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								Makefile
									
									
									
									
									
								
							@ -1,5 +1,10 @@
 | 
			
		||||
all: lint-fix lint coverage gen
 | 
			
		||||
 | 
			
		||||
test-full:
 | 
			
		||||
	coverage run manage.py test --failfast -v 3 .
 | 
			
		||||
	coverage html
 | 
			
		||||
	coverage report
 | 
			
		||||
 | 
			
		||||
test-integration:
 | 
			
		||||
	k3d cluster create || exit 0
 | 
			
		||||
	k3d kubeconfig write -o ~/.kube/config --overwrite
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										317
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										317
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							@ -53,10 +53,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "autobahn": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:24ce276d313e84d68241c3aef30d484f352b90a40168981b3640312c821df77b",
 | 
			
		||||
                "sha256:86bbce30cdd407137c57670993a8f9bfdfe3f8e994b889181d85e844d5aa8dfb"
 | 
			
		||||
                "sha256:410a93e0e29882c8b5d5ab05d220b07609b886ef5f23c0b8d39153254ffd6895",
 | 
			
		||||
                "sha256:52ee4236ff9a1fcbbd9500439dcf3284284b37f8a6b31ecc8a36e00cf9f95049"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==20.7.1"
 | 
			
		||||
            "version": "==20.12.3"
 | 
			
		||||
        },
 | 
			
		||||
        "automat": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -74,18 +74,18 @@
 | 
			
		||||
        },
 | 
			
		||||
        "boto3": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:616cde1e326949020da85a5bacaa7ad287e9f117d10ac9c5bfb9150a98dfe1a7",
 | 
			
		||||
                "sha256:ddad9ada00eccae1fc2da28c69531ba202fead562994ddcd9a9a232e993cd8a2"
 | 
			
		||||
                "sha256:197926eaf0065c2c503914a15edc75f4ac259c1e5ae6d17eabd1ba5d8ebd1554",
 | 
			
		||||
                "sha256:d6991e6fd7d0f63bf94282687700a91f5299b807e544cb3367e9b2faeeaf8c62"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==1.16.34"
 | 
			
		||||
            "version": "==1.16.46"
 | 
			
		||||
        },
 | 
			
		||||
        "botocore": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:49f5e56a7382a65ee0873371edcd91bdba8fc3f70abe102ebc1a0da2e6fbed06",
 | 
			
		||||
                "sha256:4d81d92127ef646ae0f0ee84c9c220c92fa82312e765c29f8cb3b000fdbdd038"
 | 
			
		||||
                "sha256:85ca6915ad5471e7f6cd1b00610b74601d2970cbf8e9b1bf255697154cf621a3",
 | 
			
		||||
                "sha256:f7d365c689070368a5a0857aa35a81d7c950556189f23065f42798f810a59cae"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==1.19.34"
 | 
			
		||||
            "version": "==1.19.46"
 | 
			
		||||
        },
 | 
			
		||||
        "cachetools": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -96,11 +96,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "celery": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:45bb7909061862305cefec94289fabc1b89ac004680f4dc7d9dea642a2507e53",
 | 
			
		||||
                "sha256:533f3635065b7ed362ffc04228635b4c82d53a9ab812118ccdedb5eae281fb97"
 | 
			
		||||
                "sha256:5e8d364e058554e83bbb116e8377d90c79be254785f357cb2cec026e79febe13",
 | 
			
		||||
                "sha256:f4efebe6f8629b0da2b8e529424de376494f5b7a743c321c8a2ddc2b1414921c"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==5.0.4"
 | 
			
		||||
            "version": "==5.0.5"
 | 
			
		||||
        },
 | 
			
		||||
        "certifi": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -152,11 +152,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "channels": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:74db79c9eca616be69d38013b22083ab5d3f9ccda1ab5e69096b1bb7da2d9b18",
 | 
			
		||||
                "sha256:f50a6e79757a64c1e45e95e144a2ac5f1e99ee44a0718ab182c501f5e5abd268"
 | 
			
		||||
                "sha256:056b72e51080a517a0f33a0a30003e03833b551d75394d6636c885d4edb8188f",
 | 
			
		||||
                "sha256:3f15bdd2138bb4796e76ea588a0a344b12a7964ea9b2e456f992fddb988a4317"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==3.0.2"
 | 
			
		||||
            "version": "==3.0.3"
 | 
			
		||||
        },
 | 
			
		||||
        "channels-redis": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -168,10 +168,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "chardet": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
 | 
			
		||||
                "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
 | 
			
		||||
                "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
 | 
			
		||||
                "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==3.0.4"
 | 
			
		||||
            "version": "==4.0.0"
 | 
			
		||||
        },
 | 
			
		||||
        "click": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -343,11 +343,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "django-storages": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:12de8fb2605b9b57bfaf54b075280d7cbb3b3ee1ca4bc9b9add147af87fe3a2c",
 | 
			
		||||
                "sha256:652275ab7844538c462b62810276c0244866f345878256a9e0e86f5b1283ae18"
 | 
			
		||||
                "sha256:c823dbf56c9e35b0999a13d7e05062b837bae36c518a40255d522fbe3750fbb4",
 | 
			
		||||
                "sha256:f28765826d507a0309cfaa849bd084894bc71d81bf0d09479168d44785396f80"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==1.10.1"
 | 
			
		||||
            "version": "==1.11.1"
 | 
			
		||||
        },
 | 
			
		||||
        "djangorestframework": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -366,11 +366,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "docker": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:317e95a48c32de8c1aac92a48066a5b73e218ed096e03758bcdd799a7130a1a1",
 | 
			
		||||
                "sha256:cffc771d4ea1389fc66bc95cb72d304aa41d1a1563482a9a000fba3a84ed5071"
 | 
			
		||||
                "sha256:0604a74719d5d2de438753934b755bfcda6f62f49b8e4b30969a4b0a2a8a1220",
 | 
			
		||||
                "sha256:e455fa49aabd4f22da9f4e1c1f9d16308286adc60abaf64bf3e1feafaed81d06"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==4.4.0"
 | 
			
		||||
            "version": "==4.4.1"
 | 
			
		||||
        },
 | 
			
		||||
        "drf-yasg2": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -396,10 +396,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "google-auth": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:5176db85f1e7e837a646cd9cede72c3c404ccf2e3373d9ee14b2db88febad440",
 | 
			
		||||
                "sha256:b728625ff5dfce8f9e56a499c8a4eb51443a67f20f6d28b67d5774c310ec4b6b"
 | 
			
		||||
                "sha256:0b0e026b412a0ad096e753907559e4bdb180d9ba9f68dd9036164db4fdc4ad2e",
 | 
			
		||||
                "sha256:ce752cc51c31f479dbf9928435ef4b07514b20261b021c7383bee4bda646acb8"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==1.23.0"
 | 
			
		||||
            "version": "==1.24.0"
 | 
			
		||||
        },
 | 
			
		||||
        "gunicorn": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -646,26 +646,36 @@
 | 
			
		||||
        },
 | 
			
		||||
        "msgpack": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:002a0d813e1f7b60da599bdf969e632074f9eec1b96cbed8fb0973a63160a408",
 | 
			
		||||
                "sha256:25b3bc3190f3d9d965b818123b7752c5dfb953f0d774b454fd206c18fe384fb8",
 | 
			
		||||
                "sha256:271b489499a43af001a2e42f42d876bb98ccaa7e20512ff37ca78c8e12e68f84",
 | 
			
		||||
                "sha256:39c54fdebf5fa4dda733369012c59e7d085ebdfe35b6cf648f09d16708f1be5d",
 | 
			
		||||
                "sha256:4233b7f86c1208190c78a525cd3828ca1623359ef48f78a6fea4b91bb995775a",
 | 
			
		||||
                "sha256:5bea44181fc8e18eed1d0cd76e355073f00ce232ff9653a0ae88cb7d9e643322",
 | 
			
		||||
                "sha256:5dba6d074fac9b24f29aaf1d2d032306c27f04187651511257e7831733293ec2",
 | 
			
		||||
                "sha256:7a22c965588baeb07242cb561b63f309db27a07382825fc98aecaf0827c1538e",
 | 
			
		||||
                "sha256:908944e3f038bca67fcfedb7845c4a257c7749bf9818632586b53bcf06ba4b97",
 | 
			
		||||
                "sha256:9534d5cc480d4aff720233411a1f765be90885750b07df772380b34c10ecb5c0",
 | 
			
		||||
                "sha256:aa5c057eab4f40ec47ea6f5a9825846be2ff6bf34102c560bad5cad5a677c5be",
 | 
			
		||||
                "sha256:b3758dfd3423e358bbb18a7cccd1c74228dffa7a697e5be6cb9535de625c0dbf",
 | 
			
		||||
                "sha256:c901e8058dd6653307906c5f157f26ed09eb94a850dddd989621098d347926ab",
 | 
			
		||||
                "sha256:cec8bf10981ed70998d98431cd814db0ecf3384e6b113366e7f36af71a0fca08",
 | 
			
		||||
                "sha256:db685187a415f51d6b937257474ca72199f393dad89534ebbdd7d7a3b000080e",
 | 
			
		||||
                "sha256:e35b051077fc2f3ce12e7c6a34cf309680c63a842db3a0616ea6ed25ad20d272",
 | 
			
		||||
                "sha256:e7bbdd8e2b277b77782f3ce34734b0dfde6cbe94ddb74de8d733d603c7f9e2b1",
 | 
			
		||||
                "sha256:ea41c9219c597f1d2bf6b374d951d310d58684b5de9dc4bd2976db9e1e22c140"
 | 
			
		||||
                "sha256:0cb94ee48675a45d3b86e61d13c1e6f1696f0183f0715544976356ff86f741d9",
 | 
			
		||||
                "sha256:1026dcc10537d27dd2d26c327e552f05ce148977e9d7b9f1718748281b38c841",
 | 
			
		||||
                "sha256:26a1759f1a88df5f1d0b393eb582ec022326994e311ba9c5818adc5374736439",
 | 
			
		||||
                "sha256:2a5866bdc88d77f6e1370f82f2371c9bc6fc92fe898fa2dec0c5d4f5435a2694",
 | 
			
		||||
                "sha256:31c17bbf2ae5e29e48d794c693b7ca7a0c73bd4280976d408c53df421e838d2a",
 | 
			
		||||
                "sha256:497d2c12426adcd27ab83144057a705efb6acc7e85957a51d43cdcf7f258900f",
 | 
			
		||||
                "sha256:5a9ee2540c78659a1dd0b110f73773533ee3108d4e1219b5a15a8d635b7aca0e",
 | 
			
		||||
                "sha256:8521e5be9e3b93d4d5e07cb80b7e32353264d143c1f072309e1863174c6aadb1",
 | 
			
		||||
                "sha256:87869ba567fe371c4555d2e11e4948778ab6b59d6cc9d8460d543e4cfbbddd1c",
 | 
			
		||||
                "sha256:8ffb24a3b7518e843cd83538cf859e026d24ec41ac5721c18ed0c55101f9775b",
 | 
			
		||||
                "sha256:92be4b12de4806d3c36810b0fe2aeedd8d493db39e2eb90742b9c09299eb5759",
 | 
			
		||||
                "sha256:9ea52fff0473f9f3000987f313310208c879493491ef3ccf66268eff8d5a0326",
 | 
			
		||||
                "sha256:a4355d2193106c7aa77c98fc955252a737d8550320ecdb2e9ac701e15e2943bc",
 | 
			
		||||
                "sha256:a99b144475230982aee16b3d249170f1cccebf27fb0a08e9f603b69637a62192",
 | 
			
		||||
                "sha256:ac25f3e0513f6673e8b405c3a80500eb7be1cf8f57584be524c4fa78fe8e0c83",
 | 
			
		||||
                "sha256:b28c0876cce1466d7c2195d7658cf50e4730667196e2f1355c4209444717ee06",
 | 
			
		||||
                "sha256:b55f7db883530b74c857e50e149126b91bb75d35c08b28db12dcb0346f15e46e",
 | 
			
		||||
                "sha256:b6d9e2dae081aa35c44af9c4298de4ee72991305503442a5c74656d82b581fe9",
 | 
			
		||||
                "sha256:c747c0cc08bd6d72a586310bda6ea72eeb28e7505990f342552315b229a19b33",
 | 
			
		||||
                "sha256:d6c64601af8f3893d17ec233237030e3110f11b8a962cb66720bf70c0141aa54",
 | 
			
		||||
                "sha256:d8167b84af26654c1124857d71650404336f4eb5cc06900667a493fc619ddd9f",
 | 
			
		||||
                "sha256:de6bd7990a2c2dabe926b7e62a92886ccbf809425c347ae7de277067f97c2887",
 | 
			
		||||
                "sha256:e36a812ef4705a291cdb4a2fd352f013134f26c6ff63477f20235138d1d21009",
 | 
			
		||||
                "sha256:e89ec55871ed5473a041c0495b7b4e6099f6263438e0bd04ccd8418f92d5d7f2",
 | 
			
		||||
                "sha256:f3e6aaf217ac1c7ce1563cf52a2f4f5d5b1f64e8729d794165db71da57257f0c",
 | 
			
		||||
                "sha256:f484cd2dca68502de3704f056fa9b318c94b1539ed17a4c784266df5d6978c87",
 | 
			
		||||
                "sha256:fae04496f5bc150eefad4e9571d1a76c55d021325dcd484ce45065ebbdd00984",
 | 
			
		||||
                "sha256:fe07bc6735d08e492a327f496b7850e98cb4d112c56df69b0c844dbebcbb47f6"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==1.0.0"
 | 
			
		||||
            "version": "==1.0.2"
 | 
			
		||||
        },
 | 
			
		||||
        "oauthlib": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -676,11 +686,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "packaging": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236",
 | 
			
		||||
                "sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"
 | 
			
		||||
                "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
 | 
			
		||||
                "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==20.7"
 | 
			
		||||
            "version": "==20.8"
 | 
			
		||||
        },
 | 
			
		||||
        "prometheus-client": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -855,10 +865,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "pyopenssl": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:898aefbde331ba718570244c3b01dcddb1b31a3b336613436a45e52e27d9a82d",
 | 
			
		||||
                "sha256:92f08eccbd73701cf744e8ffd6989aa7842d48cbe3fea8a7c031c5647f590ac5"
 | 
			
		||||
                "sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51",
 | 
			
		||||
                "sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==20.0.0"
 | 
			
		||||
            "version": "==20.0.1"
 | 
			
		||||
        },
 | 
			
		||||
        "pyparsing": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -889,10 +899,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "pytz": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
 | 
			
		||||
                "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
 | 
			
		||||
                "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
 | 
			
		||||
                "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==2020.4"
 | 
			
		||||
            "version": "==2020.5"
 | 
			
		||||
        },
 | 
			
		||||
        "pyyaml": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -930,10 +940,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "requests": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8",
 | 
			
		||||
                "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"
 | 
			
		||||
                "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
 | 
			
		||||
                "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==2.25.0"
 | 
			
		||||
            "version": "==2.25.1"
 | 
			
		||||
        },
 | 
			
		||||
        "requests-oauthlib": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -948,7 +958,7 @@
 | 
			
		||||
                "sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa",
 | 
			
		||||
                "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233"
 | 
			
		||||
            ],
 | 
			
		||||
            "markers": "python_version >= '3.5'",
 | 
			
		||||
            "markers": "python_version >= '3.6'",
 | 
			
		||||
            "version": "==4.6"
 | 
			
		||||
        },
 | 
			
		||||
        "ruamel.yaml": {
 | 
			
		||||
@ -1044,10 +1054,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "txaio": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:17938f2bca4a9cabce61346758e482ca4e600160cbc28e861493eac74a19539d",
 | 
			
		||||
                "sha256:38a469daf93c37e5527cb062653d6393ae11663147c42fab7ddc3f6d00d434ae"
 | 
			
		||||
                "sha256:1488d31d564a116538cc1265ac3f7979fb6223bb5a9e9f1479436ee2c17d8549",
 | 
			
		||||
                "sha256:a8676d6c68aea1f0e2548c4afdb8e6253873af3bc2659bb5bcd9f39dff7ff90f"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==20.4.1"
 | 
			
		||||
            "version": "==20.12.1"
 | 
			
		||||
        },
 | 
			
		||||
        "uritemplate": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1073,11 +1083,11 @@
 | 
			
		||||
                "standard"
 | 
			
		||||
            ],
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:28420526640d800aabe648038f8e2ea8ba2a8bdc363002eecd5dfc57a0f75ab7",
 | 
			
		||||
                "sha256:5123606e0f1d15ffbe0f63161c5078f7c28f350c5eb102435671eae58046db0f"
 | 
			
		||||
                "sha256:1079c50a06f6338095b4f203e7861dbff318dde5f22f3a324fc6e94c7654164c",
 | 
			
		||||
                "sha256:ef1e0bb5f7941c6fe324e06443ddac0331e1632a776175f87891c7bd02694355"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==0.13.0"
 | 
			
		||||
            "version": "==0.13.3"
 | 
			
		||||
        },
 | 
			
		||||
        "uvloop": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1263,11 +1273,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "bandit": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:2ff3fe35fe3212c0be5fc9c4899bd0108e2b5239c5ff62fb174639e4660fe958",
 | 
			
		||||
                "sha256:d02dfe250f4aa2d166c127ad81d192579e2bfcdb8501717c0e2005e35a6bcf60"
 | 
			
		||||
                "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07",
 | 
			
		||||
                "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==1.6.3"
 | 
			
		||||
            "version": "==1.7.0"
 | 
			
		||||
        },
 | 
			
		||||
        "black": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1308,43 +1318,58 @@
 | 
			
		||||
        },
 | 
			
		||||
        "coverage": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
 | 
			
		||||
                "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
 | 
			
		||||
                "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
 | 
			
		||||
                "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
 | 
			
		||||
                "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
 | 
			
		||||
                "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
 | 
			
		||||
                "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
 | 
			
		||||
                "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
 | 
			
		||||
                "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
 | 
			
		||||
                "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
 | 
			
		||||
                "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
 | 
			
		||||
                "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
 | 
			
		||||
                "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
 | 
			
		||||
                "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
 | 
			
		||||
                "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
 | 
			
		||||
                "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
 | 
			
		||||
                "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
 | 
			
		||||
                "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
 | 
			
		||||
                "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
 | 
			
		||||
                "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
 | 
			
		||||
                "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
 | 
			
		||||
                "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
 | 
			
		||||
                "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
 | 
			
		||||
                "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
 | 
			
		||||
                "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
 | 
			
		||||
                "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
 | 
			
		||||
                "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
 | 
			
		||||
                "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
 | 
			
		||||
                "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
 | 
			
		||||
                "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
 | 
			
		||||
                "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
 | 
			
		||||
                "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
 | 
			
		||||
                "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
 | 
			
		||||
                "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
 | 
			
		||||
                "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297",
 | 
			
		||||
                "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1",
 | 
			
		||||
                "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497",
 | 
			
		||||
                "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606",
 | 
			
		||||
                "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528",
 | 
			
		||||
                "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b",
 | 
			
		||||
                "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4",
 | 
			
		||||
                "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830",
 | 
			
		||||
                "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1",
 | 
			
		||||
                "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f",
 | 
			
		||||
                "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d",
 | 
			
		||||
                "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3",
 | 
			
		||||
                "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8",
 | 
			
		||||
                "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500",
 | 
			
		||||
                "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7",
 | 
			
		||||
                "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb",
 | 
			
		||||
                "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b",
 | 
			
		||||
                "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059",
 | 
			
		||||
                "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b",
 | 
			
		||||
                "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72",
 | 
			
		||||
                "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36",
 | 
			
		||||
                "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277",
 | 
			
		||||
                "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c",
 | 
			
		||||
                "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631",
 | 
			
		||||
                "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff",
 | 
			
		||||
                "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8",
 | 
			
		||||
                "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec",
 | 
			
		||||
                "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b",
 | 
			
		||||
                "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7",
 | 
			
		||||
                "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105",
 | 
			
		||||
                "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b",
 | 
			
		||||
                "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c",
 | 
			
		||||
                "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b",
 | 
			
		||||
                "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",
 | 
			
		||||
            "version": "==5.3"
 | 
			
		||||
            "version": "==5.3.1"
 | 
			
		||||
        },
 | 
			
		||||
        "django": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1453,11 +1478,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "packaging": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236",
 | 
			
		||||
                "sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"
 | 
			
		||||
                "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
 | 
			
		||||
                "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==20.7"
 | 
			
		||||
            "version": "==20.8"
 | 
			
		||||
        },
 | 
			
		||||
        "pathspec": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1496,10 +1521,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "py": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
 | 
			
		||||
                "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
 | 
			
		||||
                "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3",
 | 
			
		||||
                "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==1.9.0"
 | 
			
		||||
            "version": "==1.10.0"
 | 
			
		||||
        },
 | 
			
		||||
        "pycodestyle": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1566,11 +1591,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "pytest": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe",
 | 
			
		||||
                "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"
 | 
			
		||||
                "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8",
 | 
			
		||||
                "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==6.1.2"
 | 
			
		||||
            "version": "==6.2.1"
 | 
			
		||||
        },
 | 
			
		||||
        "pytest-django": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1582,10 +1607,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "pytz": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
 | 
			
		||||
                "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
 | 
			
		||||
                "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
 | 
			
		||||
                "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==2020.4"
 | 
			
		||||
            "version": "==2020.5"
 | 
			
		||||
        },
 | 
			
		||||
        "pyyaml": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1716,38 +1741,38 @@
 | 
			
		||||
        },
 | 
			
		||||
        "typed-ast": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
 | 
			
		||||
                "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
 | 
			
		||||
                "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d",
 | 
			
		||||
                "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
 | 
			
		||||
                "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
 | 
			
		||||
                "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
 | 
			
		||||
                "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c",
 | 
			
		||||
                "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
 | 
			
		||||
                "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
 | 
			
		||||
                "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
 | 
			
		||||
                "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
 | 
			
		||||
                "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
 | 
			
		||||
                "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
 | 
			
		||||
                "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d",
 | 
			
		||||
                "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
 | 
			
		||||
                "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
 | 
			
		||||
                "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c",
 | 
			
		||||
                "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
 | 
			
		||||
                "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395",
 | 
			
		||||
                "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
 | 
			
		||||
                "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
 | 
			
		||||
                "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
 | 
			
		||||
                "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
 | 
			
		||||
                "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
 | 
			
		||||
                "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072",
 | 
			
		||||
                "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298",
 | 
			
		||||
                "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91",
 | 
			
		||||
                "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
 | 
			
		||||
                "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f",
 | 
			
		||||
                "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
 | 
			
		||||
                "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1",
 | 
			
		||||
                "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d",
 | 
			
		||||
                "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6",
 | 
			
		||||
                "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd",
 | 
			
		||||
                "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37",
 | 
			
		||||
                "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151",
 | 
			
		||||
                "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07",
 | 
			
		||||
                "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440",
 | 
			
		||||
                "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70",
 | 
			
		||||
                "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496",
 | 
			
		||||
                "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea",
 | 
			
		||||
                "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400",
 | 
			
		||||
                "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc",
 | 
			
		||||
                "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606",
 | 
			
		||||
                "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc",
 | 
			
		||||
                "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581",
 | 
			
		||||
                "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412",
 | 
			
		||||
                "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a",
 | 
			
		||||
                "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2",
 | 
			
		||||
                "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787",
 | 
			
		||||
                "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f",
 | 
			
		||||
                "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937",
 | 
			
		||||
                "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64",
 | 
			
		||||
                "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487",
 | 
			
		||||
                "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b",
 | 
			
		||||
                "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41",
 | 
			
		||||
                "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a",
 | 
			
		||||
                "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3",
 | 
			
		||||
                "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166",
 | 
			
		||||
                "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==1.4.1"
 | 
			
		||||
            "version": "==1.4.2"
 | 
			
		||||
        },
 | 
			
		||||
        "typing-extensions": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
## Development
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,9 +6,9 @@ As authentik is currently in a pre-stable, only the latest "stable" version is s
 | 
			
		||||
 | 
			
		||||
| Version  | Supported          |
 | 
			
		||||
| -------- | ------------------ |
 | 
			
		||||
| 0.10.x   | :white_check_mark: |
 | 
			
		||||
| 0.11.x   | :white_check_mark: |
 | 
			
		||||
| 0.12.x   | :white_check_mark: |
 | 
			
		||||
| 0.13.x   | :white_check_mark: |
 | 
			
		||||
| 0.14.x   | :white_check_mark: |
 | 
			
		||||
 | 
			
		||||
## Reporting a Vulnerability
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,2 +1,2 @@
 | 
			
		||||
"""authentik"""
 | 
			
		||||
__version__ = "0.13.0-rc2"
 | 
			
		||||
__version__ = "0.14.2-stable"
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,12 @@
 | 
			
		||||
"""authentik administration overview"""
 | 
			
		||||
"""authentik administration metrics"""
 | 
			
		||||
import time
 | 
			
		||||
from collections import Counter
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
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.functions import ExtractHour
 | 
			
		||||
from django.http import response
 | 
			
		||||
from django.utils.timezone import now
 | 
			
		||||
from drf_yasg2.utils import swagger_auto_schema
 | 
			
		||||
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.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]]:
 | 
			
		||||
@ -47,7 +46,7 @@ def get_events_per_1h(**filter_kwargs) -> List[Dict[str, int]]:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AdministrationMetricsSerializer(Serializer):
 | 
			
		||||
    """Overview View"""
 | 
			
		||||
    """Login Metrics per 1h"""
 | 
			
		||||
 | 
			
		||||
    logins_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"""
 | 
			
		||||
        return get_events_per_1h(action=EventAction.LOGIN_FAILED)
 | 
			
		||||
 | 
			
		||||
    def create(self, request: Request) -> response:
 | 
			
		||||
    def create(self, validated_data: dict) -> Model:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def update(self, request: Request) -> Response:
 | 
			
		||||
    def update(self, instance: Model, validated_data: dict) -> Model:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AdministrationMetricsViewSet(ViewSet):
 | 
			
		||||
    """Return single instance of AdministrationMetricsSerializer"""
 | 
			
		||||
    """Login Metrics per 1h"""
 | 
			
		||||
 | 
			
		||||
    permission_classes = [IsAdminUser]
 | 
			
		||||
 | 
			
		||||
    @swagger_auto_schema(responses={200: AdministrationMetricsSerializer(many=True)})
 | 
			
		||||
    def list(self, request: Request) -> Response:
 | 
			
		||||
        """Return single instance of AdministrationMetricsSerializer"""
 | 
			
		||||
        """Login Metrics per 1h"""
 | 
			
		||||
        serializer = AdministrationMetricsSerializer(True)
 | 
			
		||||
        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 django.contrib import messages
 | 
			
		||||
from django.db.models import Model
 | 
			
		||||
from django.http.response import Http404
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from drf_yasg2.utils import swagger_auto_schema
 | 
			
		||||
@ -26,10 +27,10 @@ class TaskSerializer(Serializer):
 | 
			
		||||
    status = IntegerField(source="result.status.value")
 | 
			
		||||
    messages = ListField(source="result.messages")
 | 
			
		||||
 | 
			
		||||
    def create(self, request: Request) -> Response:
 | 
			
		||||
    def create(self, validated_data: dict) -> Model:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def update(self, request: Request) -> Response:
 | 
			
		||||
    def update(self, instance: Model, validated_data: dict) -> Model:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -66,7 +67,7 @@ class TaskViewSet(ViewSet):
 | 
			
		||||
                    "successful": True,
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        except ImportError:
 | 
			
		||||
        except ImportError:  # pragma: no cover
 | 
			
		||||
            # if we get an import error, the module path has probably changed
 | 
			
		||||
            task.delete()
 | 
			
		||||
            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",
 | 
			
		||||
    "authentication_flow",
 | 
			
		||||
    "enrollment_flow",
 | 
			
		||||
    "verbose_name",
 | 
			
		||||
    "verbose_name_plural",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,11 @@
 | 
			
		||||
"""authentik admin tasks"""
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from packaging.version import parse
 | 
			
		||||
from requests import RequestException, get
 | 
			
		||||
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.root.celery import CELERY_APP
 | 
			
		||||
 | 
			
		||||
@ -19,12 +22,24 @@ def update_latest_version(self: MonitoredTask):
 | 
			
		||||
        response.raise_for_status()
 | 
			
		||||
        data = response.json()
 | 
			
		||||
        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(
 | 
			
		||||
            TaskResult(
 | 
			
		||||
                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:
 | 
			
		||||
        cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
 | 
			
		||||
        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 %}
 | 
			
		||||
                <tr role="row">
 | 
			
		||||
                    <th role="columnheader">
 | 
			
		||||
                        <a href="/flows/{{ flow.slug }}/">
 | 
			
		||||
                        <a href="/flows/{{ flow.slug }}">
 | 
			
		||||
                            <div><code>{{ flow.slug }}</code></div>
 | 
			
		||||
                            <small>{{ flow.name }}</small>
 | 
			
		||||
                        </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>
 | 
			
		||||
                        </ak-modal-button>
 | 
			
		||||
                        <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' %}
 | 
			
		||||
                            </ak-spinner-button>
 | 
			
		||||
                            <div slot="modal"></div>
 | 
			
		||||
 | 
			
		||||
@ -41,6 +41,17 @@
 | 
			
		||||
                                </ak-modal-button>
 | 
			
		||||
                            </li>
 | 
			
		||||
                            {% 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>
 | 
			
		||||
                    </ak-dropdown>
 | 
			
		||||
                    <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">
 | 
			
		||||
            <thead>
 | 
			
		||||
                <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 'Link' %}</th>
 | 
			
		||||
                    <th role="cell"></th>
 | 
			
		||||
                </tr>
 | 
			
		||||
            </thead>
 | 
			
		||||
@ -47,12 +48,17 @@
 | 
			
		||||
                <tr role="row">
 | 
			
		||||
                    <td role="cell">
 | 
			
		||||
                        <span>
 | 
			
		||||
                            {{ invitation.expiry }}
 | 
			
		||||
                            {{ invitation.invite_uuid }}
 | 
			
		||||
                        </span>
 | 
			
		||||
                    </td>
 | 
			
		||||
                    <td role="cell">
 | 
			
		||||
                        <span>
 | 
			
		||||
                            {{ invitation.Link }}
 | 
			
		||||
                            {{ invitation.created_by }}
 | 
			
		||||
                        </span>
 | 
			
		||||
                    </td>
 | 
			
		||||
                    <td role="cell">
 | 
			
		||||
                        <span>
 | 
			
		||||
                            {{ invitation.expiry|default:"-" }}
 | 
			
		||||
                        </span>
 | 
			
		||||
                    </td>
 | 
			
		||||
                    <td>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										73
									
								
								authentik/admin/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								authentik/admin/tests/test_api.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,73 @@
 | 
			
		||||
"""test admin api"""
 | 
			
		||||
from json import loads
 | 
			
		||||
 | 
			
		||||
from django.shortcuts import reverse
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
from authentik import __version__
 | 
			
		||||
from authentik.core.models import Group, User
 | 
			
		||||
from authentik.core.tasks import clean_expired_models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestAdminAPI(TestCase):
 | 
			
		||||
    """test admin api"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        super().setUp()
 | 
			
		||||
        self.user = User.objects.create(username="test-user")
 | 
			
		||||
        self.group = Group.objects.create(name="superusers", is_superuser=True)
 | 
			
		||||
        self.group.users.add(self.user)
 | 
			
		||||
        self.group.save()
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
 | 
			
		||||
    def test_tasks(self):
 | 
			
		||||
        """Test Task API"""
 | 
			
		||||
        clean_expired_models.delay()
 | 
			
		||||
        response = self.client.get(reverse("authentik_api:admin_system_tasks-list"))
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        body = loads(response.content)
 | 
			
		||||
        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):
 | 
			
		||||
        """Test metrics API"""
 | 
			
		||||
        response = self.client.get(reverse("authentik_api:admin_metrics-list"))
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
@ -1,9 +1,13 @@
 | 
			
		||||
"""admin tests"""
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.test.client import RequestFactory
 | 
			
		||||
 | 
			
		||||
from authentik.admin.views.policies_bindings import PolicyBindingCreateView
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.policies.forms import PolicyBindingForm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestPolicyBindingView(TestCase):
 | 
			
		||||
@ -18,9 +22,22 @@ class TestPolicyBindingView(TestCase):
 | 
			
		||||
        view = PolicyBindingCreateView(request=request)
 | 
			
		||||
        self.assertEqual(view.get_initial(), {})
 | 
			
		||||
 | 
			
		||||
    def test_with_param(self):
 | 
			
		||||
    def test_with_params_invalid(self):
 | 
			
		||||
        """Test PolicyBindingCreateView with invalid get params"""
 | 
			
		||||
        request = self.factory.get("/", {"target": uuid4()})
 | 
			
		||||
        view = PolicyBindingCreateView(request=request)
 | 
			
		||||
        self.assertEqual(view.get_initial(), {})
 | 
			
		||||
 | 
			
		||||
    def test_with_params(self):
 | 
			
		||||
        """Test PolicyBindingCreateView with get params"""
 | 
			
		||||
        target = Application.objects.create(name="test")
 | 
			
		||||
        request = self.factory.get("/", {"target": target.pk.hex})
 | 
			
		||||
        view = PolicyBindingCreateView(request=request)
 | 
			
		||||
        self.assertEqual(view.get_initial(), {"target": target, "order": 0})
 | 
			
		||||
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            isinstance(
 | 
			
		||||
                PolicyBindingForm(initial={"target": "foo"}).fields["target"].widget,
 | 
			
		||||
                forms.HiddenInput,
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,12 @@
 | 
			
		||||
"""admin tests"""
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.test.client import RequestFactory
 | 
			
		||||
 | 
			
		||||
from authentik.admin.views.stages_bindings import StageBindingCreateView
 | 
			
		||||
from authentik.flows.forms import FlowStageBindingForm
 | 
			
		||||
from authentik.flows.models import Flow
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -18,9 +22,22 @@ class TestStageBindingView(TestCase):
 | 
			
		||||
        view = StageBindingCreateView(request=request)
 | 
			
		||||
        self.assertEqual(view.get_initial(), {})
 | 
			
		||||
 | 
			
		||||
    def test_with_param(self):
 | 
			
		||||
    def test_with_params_invalid(self):
 | 
			
		||||
        """Test StageBindingCreateView with invalid get params"""
 | 
			
		||||
        request = self.factory.get("/", {"target": uuid4()})
 | 
			
		||||
        view = StageBindingCreateView(request=request)
 | 
			
		||||
        self.assertEqual(view.get_initial(), {})
 | 
			
		||||
 | 
			
		||||
    def test_with_params(self):
 | 
			
		||||
        """Test StageBindingCreateView with get params"""
 | 
			
		||||
        target = Flow.objects.create(name="test", slug="test")
 | 
			
		||||
        request = self.factory.get("/", {"target": target.pk.hex})
 | 
			
		||||
        view = StageBindingCreateView(request=request)
 | 
			
		||||
        self.assertEqual(view.get_initial(), {"target": target, "order": 0})
 | 
			
		||||
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            isinstance(
 | 
			
		||||
                FlowStageBindingForm(initial={"target": "foo"}).fields["target"].widget,
 | 
			
		||||
                forms.HiddenInput,
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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,
 | 
			
		||||
    users,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.saml.views import MetadataImportView
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    path(
 | 
			
		||||
@ -34,11 +35,7 @@ urlpatterns = [
 | 
			
		||||
        overview.PolicyCacheClearView.as_view(),
 | 
			
		||||
        name="overview-clear-policy-cache",
 | 
			
		||||
    ),
 | 
			
		||||
    path("overview/", overview.AdministrationOverviewView.as_view(), name="overview"),
 | 
			
		||||
    # Applications
 | 
			
		||||
    path(
 | 
			
		||||
        "applications/", applications.ApplicationListView.as_view(), name="applications"
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "applications/create/",
 | 
			
		||||
        applications.ApplicationCreateView.as_view(),
 | 
			
		||||
@ -120,6 +117,11 @@ urlpatterns = [
 | 
			
		||||
        providers.ProviderCreateView.as_view(),
 | 
			
		||||
        name="provider-create",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "providers/create/saml/from-metadata/",
 | 
			
		||||
        MetadataImportView.as_view(),
 | 
			
		||||
        name="provider-saml-from-metadata",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "providers/<int:pk>/update/",
 | 
			
		||||
        providers.ProviderUpdateView.as_view(),
 | 
			
		||||
 | 
			
		||||
@ -6,44 +6,15 @@ from django.contrib.auth.mixins import (
 | 
			
		||||
from django.contrib.messages.views import SuccessMessageMixin
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.views.generic import ListView, UpdateView
 | 
			
		||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
 | 
			
		||||
from django.views.generic import UpdateView
 | 
			
		||||
from guardian.mixins import PermissionRequiredMixin
 | 
			
		||||
 | 
			
		||||
from authentik.admin.views.utils import (
 | 
			
		||||
    BackSuccessUrlMixin,
 | 
			
		||||
    DeleteMessageView,
 | 
			
		||||
    SearchListMixin,
 | 
			
		||||
    UserPaginateListMixin,
 | 
			
		||||
)
 | 
			
		||||
from authentik.admin.views.utils import BackSuccessUrlMixin, DeleteMessageView
 | 
			
		||||
from authentik.core.forms.applications import ApplicationForm
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
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(
 | 
			
		||||
    SuccessMessageMixin,
 | 
			
		||||
    BackSuccessUrlMixin,
 | 
			
		||||
@ -58,7 +29,7 @@ class ApplicationCreateView(
 | 
			
		||||
    permission_required = "authentik_core.add_application"
 | 
			
		||||
 | 
			
		||||
    template_name = "generic/create.html"
 | 
			
		||||
    success_url = reverse_lazy("authentik_admin:applications")
 | 
			
		||||
    success_url = reverse_lazy("authentik_core:shell")
 | 
			
		||||
    success_message = _("Successfully created Application")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -76,7 +47,7 @@ class ApplicationUpdateView(
 | 
			
		||||
    permission_required = "authentik_core.change_application"
 | 
			
		||||
 | 
			
		||||
    template_name = "generic/update.html"
 | 
			
		||||
    success_url = reverse_lazy("authentik_admin:applications")
 | 
			
		||||
    success_url = reverse_lazy("authentik_core:shell")
 | 
			
		||||
    success_message = _("Successfully updated Application")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -89,5 +60,5 @@ class ApplicationDeleteView(
 | 
			
		||||
    permission_required = "authentik_core.delete_application"
 | 
			
		||||
 | 
			
		||||
    template_name = "generic/delete.html"
 | 
			
		||||
    success_url = reverse_lazy("authentik_admin:applications")
 | 
			
		||||
    success_url = reverse_lazy("authentik_core:shell")
 | 
			
		||||
    success_message = _("Successfully deleted Application")
 | 
			
		||||
 | 
			
		||||
@ -1,65 +1,25 @@
 | 
			
		||||
"""authentik administration overview"""
 | 
			
		||||
from typing import Union
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.messages.views import SuccessMessageMixin
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.http.request import HttpRequest
 | 
			
		||||
from django.http.response import HttpResponse
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.views.generic import FormView, TemplateView
 | 
			
		||||
from packaging.version import LegacyVersion, Version, parse
 | 
			
		||||
from django.views.generic import FormView
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik import __version__
 | 
			
		||||
from authentik.admin.forms.overview import FlowCacheClearForm, PolicyCacheClearForm
 | 
			
		||||
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()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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):
 | 
			
		||||
    """View to clear Policy cache"""
 | 
			
		||||
 | 
			
		||||
    form_class = PolicyCacheClearForm
 | 
			
		||||
 | 
			
		||||
    template_name = "generic/form_non_model.html"
 | 
			
		||||
    success_url = reverse_lazy("authentik_admin:overview")
 | 
			
		||||
    success_url = "/"
 | 
			
		||||
    success_message = _("Successfully cleared Policy cache")
 | 
			
		||||
 | 
			
		||||
    def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
@ -75,7 +35,7 @@ class FlowCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView):
 | 
			
		||||
    form_class = FlowCacheClearForm
 | 
			
		||||
 | 
			
		||||
    template_name = "generic/form_non_model.html"
 | 
			
		||||
    success_url = reverse_lazy("authentik_admin:overview")
 | 
			
		||||
    success_url = "/"
 | 
			
		||||
    success_message = _("Successfully cleared Flow cache")
 | 
			
		||||
 | 
			
		||||
    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.stages.invitation.forms import InvitationForm
 | 
			
		||||
from authentik.stages.invitation.models import Invitation
 | 
			
		||||
from authentik.stages.invitation.signals import invitation_created
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InvitationListView(
 | 
			
		||||
@ -59,7 +58,6 @@ class InvitationCreateView(
 | 
			
		||||
        obj = form.save(commit=False)
 | 
			
		||||
        obj.created_by = self.request.user
 | 
			
		||||
        obj.save()
 | 
			
		||||
        invitation_created.send(sender=self, request=self.request, invitation=obj)
 | 
			
		||||
        return HttpResponseRedirect(self.success_url)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
"""API Authentication"""
 | 
			
		||||
from base64 import b64decode
 | 
			
		||||
from binascii import Error
 | 
			
		||||
from typing import Any, Optional, Tuple, Union
 | 
			
		||||
 | 
			
		||||
from rest_framework.authentication import BaseAuthentication, get_authorization_header
 | 
			
		||||
@ -24,7 +25,7 @@ def token_from_header(raw_header: bytes) -> Optional[Token]:
 | 
			
		||||
            return None
 | 
			
		||||
    try:
 | 
			
		||||
        auth_credentials = b64decode(auth_credentials.encode()).decode()
 | 
			
		||||
    except UnicodeDecodeError:
 | 
			
		||||
    except (UnicodeDecodeError, Error):
 | 
			
		||||
        return None
 | 
			
		||||
    # Accept credentials with username and without
 | 
			
		||||
    if ":" in auth_credentials:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										37
									
								
								authentik/api/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								authentik/api/tests.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
			
		||||
"""Test API Authentication"""
 | 
			
		||||
from base64 import b64encode
 | 
			
		||||
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from guardian.shortcuts import get_anonymous_user
 | 
			
		||||
 | 
			
		||||
from authentik.api.auth import token_from_header
 | 
			
		||||
from authentik.core.models import Token, TokenIntents
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestAPIAuth(TestCase):
 | 
			
		||||
    """Test API Authentication"""
 | 
			
		||||
 | 
			
		||||
    def test_valid(self):
 | 
			
		||||
        """Test valid token"""
 | 
			
		||||
        token = Token.objects.create(
 | 
			
		||||
            intent=TokenIntents.INTENT_API, user=get_anonymous_user()
 | 
			
		||||
        )
 | 
			
		||||
        auth = b64encode(f":{token.key}".encode()).decode()
 | 
			
		||||
        self.assertEqual(token_from_header(f"Basic {auth}".encode()), token)
 | 
			
		||||
 | 
			
		||||
    def test_invalid_type(self):
 | 
			
		||||
        """Test invalid type"""
 | 
			
		||||
        self.assertIsNone(token_from_header("foo bar".encode()))
 | 
			
		||||
 | 
			
		||||
    def test_invalid_decode(self):
 | 
			
		||||
        """Test invalid bas64"""
 | 
			
		||||
        self.assertIsNone(token_from_header("Basic bar".encode()))
 | 
			
		||||
 | 
			
		||||
    def test_invalid_empty_password(self):
 | 
			
		||||
        """Test invalid with empty password"""
 | 
			
		||||
        self.assertIsNone(token_from_header("Basic :".encode()))
 | 
			
		||||
 | 
			
		||||
    def test_invalid_no_token(self):
 | 
			
		||||
        """Test invalid with no token"""
 | 
			
		||||
        auth = b64encode(":abc".encode()).decode()
 | 
			
		||||
        self.assertIsNone(token_from_header(f"Basic :{auth}".encode()))
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
"""core Configs API"""
 | 
			
		||||
from django.db.models import Model
 | 
			
		||||
from drf_yasg2.utils import swagger_auto_schema
 | 
			
		||||
from rest_framework.permissions import AllowAny
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
@ -19,10 +20,10 @@ class ConfigSerializer(Serializer):
 | 
			
		||||
    error_reporting_environment = ReadOnlyField()
 | 
			
		||||
    error_reporting_send_pii = ReadOnlyField()
 | 
			
		||||
 | 
			
		||||
    def create(self, request: Request) -> Response:
 | 
			
		||||
    def create(self, validated_data: dict) -> Model:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def update(self, request: Request) -> Response:
 | 
			
		||||
    def update(self, instance: Model, validated_data: dict) -> Model:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
"""core messages API"""
 | 
			
		||||
from django.contrib.messages import get_messages
 | 
			
		||||
from django.db.models import Model
 | 
			
		||||
from drf_yasg2.utils import swagger_auto_schema
 | 
			
		||||
from rest_framework.permissions import AllowAny
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
@ -17,10 +18,10 @@ class MessageSerializer(Serializer):
 | 
			
		||||
    extra_tags = ReadOnlyField()
 | 
			
		||||
    level_tag = ReadOnlyField()
 | 
			
		||||
 | 
			
		||||
    def create(self, request: Request) -> Response:
 | 
			
		||||
    def create(self, validated_data: dict) -> Model:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def update(self, request: Request) -> Response:
 | 
			
		||||
    def update(self, instance: Model, validated_data: dict) -> Model:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,12 +5,12 @@ from drf_yasg2.views import get_schema_view
 | 
			
		||||
from rest_framework import routers
 | 
			
		||||
from rest_framework.permissions import AllowAny
 | 
			
		||||
 | 
			
		||||
from authentik.admin.api.overview import AdministrationOverviewViewSet
 | 
			
		||||
from authentik.admin.api.overview_metrics import AdministrationMetricsViewSet
 | 
			
		||||
from authentik.admin.api.metrics import AdministrationMetricsViewSet
 | 
			
		||||
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.messages import MessagesViewSet
 | 
			
		||||
from authentik.audit.api import EventViewSet
 | 
			
		||||
from authentik.core.api.applications import ApplicationViewSet
 | 
			
		||||
from authentik.core.api.groups import GroupViewSet
 | 
			
		||||
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.users import UserViewSet
 | 
			
		||||
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 (
 | 
			
		||||
    DockerServiceConnectionViewSet,
 | 
			
		||||
    KubernetesServiceConnectionViewSet,
 | 
			
		||||
    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.expiry.api import PasswordExpiryPolicyViewSet
 | 
			
		||||
from authentik.policies.expression.api import ExpressionPolicyViewSet
 | 
			
		||||
@ -63,9 +73,8 @@ router = routers.DefaultRouter()
 | 
			
		||||
router.register("root/messages", MessagesViewSet, basename="messages")
 | 
			
		||||
router.register("root/config", ConfigsViewSet, basename="configs")
 | 
			
		||||
 | 
			
		||||
router.register(
 | 
			
		||||
    "admin/overview", AdministrationOverviewViewSet, basename="admin_overview"
 | 
			
		||||
)
 | 
			
		||||
router.register("admin/version", VersionViewSet, basename="admin_version")
 | 
			
		||||
router.register("admin/workers", WorkerViewSet, basename="admin_workers")
 | 
			
		||||
router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics")
 | 
			
		||||
router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks")
 | 
			
		||||
 | 
			
		||||
@ -82,11 +91,12 @@ router.register(
 | 
			
		||||
router.register("outposts/proxy", ProxyOutpostConfigViewSet)
 | 
			
		||||
 | 
			
		||||
router.register("flows/instances", FlowViewSet)
 | 
			
		||||
router.register("flows/cached", FlowCacheViewSet, basename="flows_cache")
 | 
			
		||||
router.register("flows/bindings", FlowStageBindingViewSet)
 | 
			
		||||
 | 
			
		||||
router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet)
 | 
			
		||||
 | 
			
		||||
router.register("audit/events", EventViewSet)
 | 
			
		||||
router.register("events/events", EventViewSet)
 | 
			
		||||
 | 
			
		||||
router.register("sources/all", SourceViewSet)
 | 
			
		||||
router.register("sources/ldap", LDAPSourceViewSet)
 | 
			
		||||
@ -94,6 +104,7 @@ router.register("sources/saml", SAMLSourceViewSet)
 | 
			
		||||
router.register("sources/oauth", OAuthSourceViewSet)
 | 
			
		||||
 | 
			
		||||
router.register("policies/all", PolicyViewSet)
 | 
			
		||||
router.register("policies/cached", PolicyCacheViewSet, basename="policies_cache")
 | 
			
		||||
router.register("policies/bindings", PolicyBindingViewSet)
 | 
			
		||||
router.register("policies/expression", ExpressionPolicyViewSet)
 | 
			
		||||
router.register("policies/group_membership", GroupMembershipPolicyViewSet)
 | 
			
		||||
@ -137,7 +148,9 @@ info = openapi.Info(
 | 
			
		||||
    title="authentik API",
 | 
			
		||||
    default_version="v2",
 | 
			
		||||
    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(
 | 
			
		||||
    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_guardian.filters import ObjectPermissionsFilter
 | 
			
		||||
 | 
			
		||||
from authentik.admin.api.overview_metrics import get_events_per_1h
 | 
			
		||||
from authentik.audit.models import EventAction
 | 
			
		||||
from authentik.admin.api.metrics import get_events_per_1h
 | 
			
		||||
from authentik.core.api.providers import ProviderSerializer
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.events.models import EventAction
 | 
			
		||||
from authentik.policies.engine import PolicyEngine
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -21,6 +22,7 @@ class ApplicationSerializer(ModelSerializer):
 | 
			
		||||
    """Application Serializer"""
 | 
			
		||||
 | 
			
		||||
    launch_url = SerializerMethodField()
 | 
			
		||||
    provider = ProviderSerializer(source="get_provider", required=False)
 | 
			
		||||
 | 
			
		||||
    def get_launch_url(self, instance: Application) -> str:
 | 
			
		||||
        """Get generated launch URL"""
 | 
			
		||||
@ -48,7 +50,15 @@ class ApplicationViewSet(ModelViewSet):
 | 
			
		||||
 | 
			
		||||
    queryset = Application.objects.all()
 | 
			
		||||
    serializer_class = ApplicationSerializer
 | 
			
		||||
    search_fields = [
 | 
			
		||||
        "name",
 | 
			
		||||
        "slug",
 | 
			
		||||
        "meta_launch_url",
 | 
			
		||||
        "meta_description",
 | 
			
		||||
        "meta_publisher",
 | 
			
		||||
    ]
 | 
			
		||||
    lookup_field = "slug"
 | 
			
		||||
    ordering = ["name"]
 | 
			
		||||
 | 
			
		||||
    def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
 | 
			
		||||
        """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())
 | 
			
		||||
        self.paginate_queryset(queryset)
 | 
			
		||||
        allowed_applications = []
 | 
			
		||||
        for application in queryset.order_by("name"):
 | 
			
		||||
        for application in queryset:
 | 
			
		||||
            engine = PolicyEngine(application, self.request.user, self.request)
 | 
			
		||||
            engine.build()
 | 
			
		||||
            if engine.passing:
 | 
			
		||||
@ -78,7 +88,7 @@ class ApplicationViewSet(ModelViewSet):
 | 
			
		||||
            get_objects_for_user(request.user, "authentik_core.view_application"),
 | 
			
		||||
            slug=slug,
 | 
			
		||||
        )
 | 
			
		||||
        if not request.user.has_perm("authentik_audit.view_event"):
 | 
			
		||||
        if not request.user.has_perm("authentik_events.view_event"):
 | 
			
		||||
            raise Http404
 | 
			
		||||
        return Response(
 | 
			
		||||
            get_events_per_1h(
 | 
			
		||||
 | 
			
		||||
@ -1,30 +1,49 @@
 | 
			
		||||
"""Provider API Views"""
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProviderSerializer(ModelSerializer):
 | 
			
		||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
 | 
			
		||||
    """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"""
 | 
			
		||||
        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:
 | 
			
		||||
 | 
			
		||||
        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"""
 | 
			
		||||
 | 
			
		||||
    queryset = Provider.objects.all()
 | 
			
		||||
    serializer_class = ProviderSerializer
 | 
			
		||||
    filterset_fields = {
 | 
			
		||||
        "application": ["isnull"],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return Provider.objects.select_subclasses()
 | 
			
		||||
 | 
			
		||||
@ -3,10 +3,11 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
			
		||||
from rest_framework.viewsets import ReadOnlyModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
 | 
			
		||||
from authentik.core.api.utils import MetaNameSerializer
 | 
			
		||||
from authentik.core.models import Source
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SourceSerializer(ModelSerializer):
 | 
			
		||||
class SourceSerializer(ModelSerializer, MetaNameSerializer):
 | 
			
		||||
    """Source Serializer"""
 | 
			
		||||
 | 
			
		||||
    __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.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.audit.models import Event, EventAction
 | 
			
		||||
from authentik.core.models import Token
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TokenSerializer(ModelSerializer):
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
"""User API Views"""
 | 
			
		||||
from drf_yasg2.utils import swagger_auto_schema
 | 
			
		||||
from guardian.utils import get_anonymous_user
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
@ -36,6 +37,9 @@ class UserViewSet(ModelViewSet):
 | 
			
		||||
    queryset = User.objects.all()
 | 
			
		||||
    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)})
 | 
			
		||||
    @action(detail=False)
 | 
			
		||||
    # 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,4 +1,5 @@
 | 
			
		||||
"""Channels base classes"""
 | 
			
		||||
from channels.exceptions import DenyConnection
 | 
			
		||||
from channels.generic.websocket import JsonWebsocketConsumer
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
@ -17,16 +18,13 @@ class AuthJsonConsumer(JsonWebsocketConsumer):
 | 
			
		||||
        headers = dict(self.scope["headers"])
 | 
			
		||||
        if b"authorization" not in headers:
 | 
			
		||||
            LOGGER.warning("WS Request without authorization header")
 | 
			
		||||
            self.close()
 | 
			
		||||
            return False
 | 
			
		||||
            raise DenyConnection()
 | 
			
		||||
 | 
			
		||||
        raw_header = headers[b"authorization"]
 | 
			
		||||
 | 
			
		||||
        token = token_from_header(raw_header)
 | 
			
		||||
        if not token:
 | 
			
		||||
            LOGGER.warning("Failed to authenticate")
 | 
			
		||||
            self.close()
 | 
			
		||||
            return False
 | 
			
		||||
            raise DenyConnection()
 | 
			
		||||
 | 
			
		||||
        self.user = token.user
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,11 @@
 | 
			
		||||
"""Property Mapping Evaluator"""
 | 
			
		||||
from traceback import format_tb
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
from authentik.lib.expression.evaluator import BaseEvaluator
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -19,3 +21,18 @@ class PropertyMappingEvaluator(BaseEvaluator):
 | 
			
		||||
        if request:
 | 
			
		||||
            self._context["request"] = request
 | 
			
		||||
        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 guardian.mixins import GuardianUserMixin
 | 
			
		||||
from model_utils.managers import InheritanceManager
 | 
			
		||||
from rest_framework.serializers import Serializer
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.exceptions import PropertyMappingExpressionException
 | 
			
		||||
@ -127,7 +128,7 @@ class User(GuardianUserMixin, AbstractUser):
 | 
			
		||||
        verbose_name_plural = _("Users")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Provider(models.Model):
 | 
			
		||||
class Provider(SerializerModel):
 | 
			
		||||
    """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
 | 
			
		||||
 | 
			
		||||
    name = models.TextField()
 | 
			
		||||
@ -156,6 +157,11 @@ class Provider(models.Model):
 | 
			
		||||
        """Return Form class used to edit this object"""
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> Type[Serializer]:
 | 
			
		||||
        """Get serializer for this model"""
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,15 @@
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
{% load authentik_utils %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
{{ block.super }}
 | 
			
		||||
<style>
 | 
			
		||||
    .pf-c-empty-state {
 | 
			
		||||
        height: 100vh;
 | 
			
		||||
    }
 | 
			
		||||
</style>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
<section class="pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
 | 
			
		||||
    <div class="pf-c-empty-state">
 | 
			
		||||
 | 
			
		||||
@ -31,7 +31,7 @@
 | 
			
		||||
            <p class="pf-c-form__helper-text">{{ field.help_text }}</p>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </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">
 | 
			
		||||
            <label class="pf-c-form__label" for="{{ field.name }}-{{ forloop.counter0 }}">
 | 
			
		||||
                <span class="pf-c-form__label-text">{{ field.label }}</span>
 | 
			
		||||
@ -46,6 +46,9 @@
 | 
			
		||||
                {% if field.help_text %}
 | 
			
		||||
                <p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
 | 
			
		||||
                {% 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>
 | 
			
		||||
    {% elif field.field.widget|fieldtype == 'CheckboxInput' %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										26
									
								
								authentik/core/templates/user/details.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								authentik/core/templates/user/details.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
<div class="pf-c-card">
 | 
			
		||||
    <div class="pf-c-card__header pf-c-title pf-m-md">
 | 
			
		||||
        {% trans 'Update details' %}
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="pf-c-card__body">
 | 
			
		||||
        <form action="" method="post" class="pf-c-form pf-m-horizontal">
 | 
			
		||||
            {% include 'partials/form_horizontal.html' with form=form %}
 | 
			
		||||
            {% block beneath_form %}
 | 
			
		||||
            {% endblock %}
 | 
			
		||||
            <div class="pf-c-form__group pf-m-action">
 | 
			
		||||
                <div class="pf-c-form__horizontal-group">
 | 
			
		||||
                    <div class="pf-c-form__actions">
 | 
			
		||||
                        <input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
 | 
			
		||||
                        {% if unenrollment_enabled %}
 | 
			
		||||
                        <a class="pf-c-button pf-m-danger"
 | 
			
		||||
                            href="{% url 'authentik_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{%
 | 
			
		||||
                            trans "Delete account" %}</a>
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </form>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
@ -15,29 +15,9 @@
 | 
			
		||||
        <section class="pf-c-page__main-section">
 | 
			
		||||
            <div class="pf-u-display-flex pf-u-justify-content-center">
 | 
			
		||||
                <div class="pf-u-w-75">
 | 
			
		||||
                    <div class="pf-c-card">
 | 
			
		||||
                        <div class="pf-c-card__header pf-c-title pf-m-md">
 | 
			
		||||
                            {% trans 'Update details' %}
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="pf-c-card__body">
 | 
			
		||||
                            <form action="" method="post" class="pf-c-form pf-m-horizontal">
 | 
			
		||||
                                {% include 'partials/form_horizontal.html' with form=form %}
 | 
			
		||||
                                {% block beneath_form %}
 | 
			
		||||
                                {% endblock %}
 | 
			
		||||
                                <div class="pf-c-form__group pf-m-action">
 | 
			
		||||
                                    <div class="pf-c-form__horizontal-group">
 | 
			
		||||
                                        <div class="pf-c-form__actions">
 | 
			
		||||
                                            <input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
 | 
			
		||||
                                            {% if unenrollment_enabled %}
 | 
			
		||||
                                            <a class="pf-c-button pf-m-danger"
 | 
			
		||||
                                                href="{% url 'authentik_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{% trans "Delete account" %}</a>
 | 
			
		||||
                                            {% endif %}
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                            </form>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <ak-site-shell url="{% url 'authentik_core:user-details' %}">
 | 
			
		||||
                        <div slot="body"></div>
 | 
			
		||||
                    </ak-site-shell>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </section>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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")
 | 
			
		||||
@ -34,9 +34,3 @@ class TestOverviewViews(TestCase):
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            self.client.get(reverse("authentik_core:overview")).status_code, 200
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_user_settings(self):
 | 
			
		||||
        """Test user settings"""
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            self.client.get(reverse("authentik_core:user-settings")).status_code, 200
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -28,3 +28,9 @@ class TestUserViews(TestCase):
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            self.client.get(reverse("authentik_core:user-settings")).status_code, 200
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_user_details(self):
 | 
			
		||||
        """Test UserDetailsView"""
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            self.client.get(reverse("authentik_core:user-details")).status_code, 200
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,7 @@ urlpatterns = [
 | 
			
		||||
    path("", shell.ShellView.as_view(), name="shell"),
 | 
			
		||||
    # User views
 | 
			
		||||
    path("-/user/", user.UserSettingsView.as_view(), name="user-settings"),
 | 
			
		||||
    path("-/user/details/", user.UserDetailsView.as_view(), name="user-details"),
 | 
			
		||||
    path("-/user/tokens/", user.TokenListView.as_view(), name="user-tokens"),
 | 
			
		||||
    path(
 | 
			
		||||
        "-/user/tokens/create/",
 | 
			
		||||
@ -24,7 +25,7 @@ urlpatterns = [
 | 
			
		||||
        name="user-tokens-delete",
 | 
			
		||||
    ),
 | 
			
		||||
    # Libray
 | 
			
		||||
    path("library/", library.LibraryView.as_view(), name="overview"),
 | 
			
		||||
    path("library", library.LibraryView.as_view(), name="overview"),
 | 
			
		||||
    # Impersonation
 | 
			
		||||
    path(
 | 
			
		||||
        "-/impersonation/<int:user_id>/",
 | 
			
		||||
 | 
			
		||||
@ -5,12 +5,12 @@ from django.shortcuts import get_object_or_404, redirect
 | 
			
		||||
from django.views import View
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.audit.models import Event, EventAction
 | 
			
		||||
from authentik.core.middleware import (
 | 
			
		||||
    SESSION_IMPERSONATE_ORIGINAL_USER,
 | 
			
		||||
    SESSION_IMPERSONATE_USER,
 | 
			
		||||
)
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ from django.http.response import HttpResponse
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.views.generic import ListView, UpdateView
 | 
			
		||||
from django.views.generic.base import TemplateView
 | 
			
		||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
 | 
			
		||||
from guardian.shortcuts import get_objects_for_user
 | 
			
		||||
 | 
			
		||||
@ -26,14 +27,20 @@ from authentik.flows.models import Flow, FlowDesignation
 | 
			
		||||
from authentik.lib.views import CreateAssignPermView
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
 | 
			
		||||
    """Update User settings"""
 | 
			
		||||
class UserSettingsView(TemplateView):
 | 
			
		||||
    """Multiple SiteShells for user details and all stages"""
 | 
			
		||||
 | 
			
		||||
    template_name = "user/settings.html"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserDetailsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
 | 
			
		||||
    """Update User details"""
 | 
			
		||||
 | 
			
		||||
    template_name = "user/details.html"
 | 
			
		||||
    form_class = UserDetailForm
 | 
			
		||||
 | 
			
		||||
    success_message = _("Successfully updated user.")
 | 
			
		||||
    success_url = reverse_lazy("authentik_core:user-settings")
 | 
			
		||||
    success_url = reverse_lazy("authentik_core:user-details")
 | 
			
		||||
 | 
			
		||||
    def get_object(self):
 | 
			
		||||
        return self.request.user
 | 
			
		||||
@ -87,11 +94,6 @@ class TokenCreateView(
 | 
			
		||||
    success_url = reverse_lazy("authentik_core:user-tokens")
 | 
			
		||||
    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:
 | 
			
		||||
        form.instance.user = self.request.user
 | 
			
		||||
        form.instance.intent = TokenIntents.INTENT_API
 | 
			
		||||
@ -105,21 +107,20 @@ class TokenUpdateView(
 | 
			
		||||
 | 
			
		||||
    model = Token
 | 
			
		||||
    form_class = UserTokenForm
 | 
			
		||||
    permission_required = "authentik_core.update_token"
 | 
			
		||||
    permission_required = "authentik_core.change_token"
 | 
			
		||||
    template_name = "generic/update.html"
 | 
			
		||||
    success_url = reverse_lazy("authentik_core:user-tokens")
 | 
			
		||||
    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:
 | 
			
		||||
        identifier = self.kwargs.get("identifier")
 | 
			
		||||
        return get_objects_for_user(
 | 
			
		||||
            self.request.user, "authentik_core.update_token", self.model
 | 
			
		||||
        ).filter(intent=TokenIntents.INTENT_API, identifier=identifier)
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(
 | 
			
		||||
                self.request.user, self.permission_required, self.model
 | 
			
		||||
            )
 | 
			
		||||
            .filter(intent=TokenIntents.INTENT_API, identifier=identifier)
 | 
			
		||||
            .first()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
 | 
			
		||||
@ -131,7 +132,12 @@ class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessage
 | 
			
		||||
    success_url = reverse_lazy("authentik_core:user-tokens")
 | 
			
		||||
    success_message = _("Successfully deleted 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:
 | 
			
		||||
        identifier = self.kwargs.get("identifier")
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(
 | 
			
		||||
                self.request.user, self.permission_required, self.model
 | 
			
		||||
            )
 | 
			
		||||
            .filter(intent=TokenIntents.INTENT_API, identifier=identifier)
 | 
			
		||||
            .first()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -22,8 +22,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
			
		||||
    def validate_key_data(self, value):
 | 
			
		||||
        """Verify that input is a valid PEM RSA Key"""
 | 
			
		||||
        # Since this field is optional, data can be empty.
 | 
			
		||||
        if value == "":
 | 
			
		||||
            return value
 | 
			
		||||
        if value != "":
 | 
			
		||||
            try:
 | 
			
		||||
                load_pem_private_key(
 | 
			
		||||
                    str.encode("\n".join([x.strip() for x in value.split("\n")])),
 | 
			
		||||
 | 
			
		||||
@ -26,8 +26,7 @@ class CertificateKeyPairForm(forms.ModelForm):
 | 
			
		||||
        """Verify that input is a valid PEM RSA Key"""
 | 
			
		||||
        key_data = self.cleaned_data["key_data"]
 | 
			
		||||
        # Since this field is optional, data can be empty.
 | 
			
		||||
        if key_data == "":
 | 
			
		||||
            return key_data
 | 
			
		||||
        if key_data != "":
 | 
			
		||||
            try:
 | 
			
		||||
                load_pem_private_key(
 | 
			
		||||
                    str.encode("\n".join([x.strip() for x in key_data.split("\n")])),
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
"""Audit API Views"""
 | 
			
		||||
"""Events API Views"""
 | 
			
		||||
from django.db.models.aggregates import Count
 | 
			
		||||
from django.db.models.fields.json import KeyTextTransform
 | 
			
		||||
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.viewsets import ReadOnlyModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.audit.models import Event, EventAction
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EventSerializer(ModelSerializer):
 | 
			
		||||
@ -48,6 +48,15 @@ class EventViewSet(ReadOnlyModelViewSet):
 | 
			
		||||
 | 
			
		||||
    queryset = Event.objects.all()
 | 
			
		||||
    serializer_class = EventSerializer
 | 
			
		||||
    ordering = ["-created"]
 | 
			
		||||
    search_fields = [
 | 
			
		||||
        "user",
 | 
			
		||||
        "action",
 | 
			
		||||
        "app",
 | 
			
		||||
        "context",
 | 
			
		||||
        "client_ip",
 | 
			
		||||
    ]
 | 
			
		||||
    filterset_fields = ["action"]
 | 
			
		||||
 | 
			
		||||
    @swagger_auto_schema(
 | 
			
		||||
        method="GET", responses={200: EventTopPerUserSerialier(many=True)}
 | 
			
		||||
							
								
								
									
										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 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.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.events.models import Event, EventAction
 | 
			
		||||
from authentik.events.signals import EventNewThread
 | 
			
		||||
from authentik.events.utils import model_to_dict
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AuditMiddleware:
 | 
			
		||||
@ -63,8 +63,8 @@ class Migration(migrations.Migration):
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                "verbose_name": "Audit Event",
 | 
			
		||||
                "verbose_name_plural": "Audit Events",
 | 
			
		||||
                "verbose_name": "Event",
 | 
			
		||||
                "verbose_name_plural": "Events",
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -6,7 +6,7 @@ from django.db import migrations, models
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_audit", "0001_initial"),
 | 
			
		||||
        ("authentik_events", "0001_initial"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
@ -3,11 +3,11 @@ from django.apps.registry import Apps
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
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):
 | 
			
		||||
    Event = apps.get_model("authentik_audit", "Event")
 | 
			
		||||
    Event = apps.get_model("authentik_events", "Event")
 | 
			
		||||
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    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
 | 
			
		||||
        event.pk = None
 | 
			
		||||
        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.save()
 | 
			
		||||
@ -24,7 +24,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_audit", "0002_auto_20200918_2116"),
 | 
			
		||||
        ("authentik_events", "0002_auto_20200918_2116"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
@ -6,7 +6,7 @@ from django.db import migrations, models
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_audit", "0003_auto_20200917_1155"),
 | 
			
		||||
        ("authentik_events", "0003_auto_20200917_1155"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
@ -6,7 +6,7 @@ from django.db import migrations, models
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_audit", "0004_auto_20200921_1829"),
 | 
			
		||||
        ("authentik_events", "0004_auto_20200921_1829"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
@ -6,7 +6,7 @@ from django.db import migrations, models
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_audit", "0005_auto_20201005_2139"),
 | 
			
		||||
        ("authentik_events", "0005_auto_20201005_2139"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    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 typing import Any, Dict, Optional, Union
 | 
			
		||||
from uuid import UUID, uuid4
 | 
			
		||||
from typing import Optional, Union
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import AnonymousUser
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.db.models.base import Model
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
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 authentik.core.middleware import (
 | 
			
		||||
@ -19,78 +16,14 @@ from authentik.core.middleware import (
 | 
			
		||||
    SESSION_IMPERSONATE_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
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger("authentik.audit")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
LOGGER = get_logger("authentik.events")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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_FAILED = "login_failed"
 | 
			
		||||
@ -102,7 +35,6 @@ class EventAction(models.TextChoices):
 | 
			
		||||
 | 
			
		||||
    TOKEN_VIEW = "token_view"  # nosec
 | 
			
		||||
 | 
			
		||||
    INVITE_CREATED = "invitation_created"
 | 
			
		||||
    INVITE_USED = "invitation_used"
 | 
			
		||||
 | 
			
		||||
    AUTHORIZE_APPLICATION = "authorize_application"
 | 
			
		||||
@ -111,15 +43,23 @@ class EventAction(models.TextChoices):
 | 
			
		||||
    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_PREFIX = "custom_"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
    user = models.JSONField(default=dict)
 | 
			
		||||
@ -151,6 +91,12 @@ class Event(models.Model):
 | 
			
		||||
        event = Event(action=action, app=app, context=cleaned_kwargs)
 | 
			
		||||
        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(
 | 
			
		||||
        self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None
 | 
			
		||||
    ) -> "Event":
 | 
			
		||||
@ -185,7 +131,7 @@ class Event(models.Model):
 | 
			
		||||
                "you may not edit an existing %s" % self._meta.model_name
 | 
			
		||||
            )
 | 
			
		||||
        LOGGER.debug(
 | 
			
		||||
            "Created Audit event",
 | 
			
		||||
            "Created Event",
 | 
			
		||||
            action=self.action,
 | 
			
		||||
            context=self.context,
 | 
			
		||||
            client_ip=self.client_ip,
 | 
			
		||||
@ -195,5 +141,5 @@ class Event(models.Model):
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        verbose_name = _("Audit Event")
 | 
			
		||||
        verbose_name_plural = _("Audit Events")
 | 
			
		||||
        verbose_name = _("Event")
 | 
			
		||||
        verbose_name_plural = _("Events")
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
"""authentik audit signal listener"""
 | 
			
		||||
"""authentik events signal listener"""
 | 
			
		||||
from threading import Thread
 | 
			
		||||
from typing import Any, Dict, Optional
 | 
			
		||||
 | 
			
		||||
@ -10,11 +10,11 @@ from django.contrib.auth.signals import (
 | 
			
		||||
from django.dispatch import receiver
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
 | 
			
		||||
from authentik.audit.models import Event, EventAction
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
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.signals import invitation_created, invitation_used
 | 
			
		||||
from authentik.stages.invitation.signals import invitation_used
 | 
			
		||||
from authentik.stages.user_write.signals import user_write
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -79,16 +79,6 @@ def on_user_login_failed(
 | 
			
		||||
    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)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
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.test import TestCase
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestAuditEvent(TestCase):
 | 
			
		||||
    """Test Audit Event"""
 | 
			
		||||
class TestEvents(TestCase):
 | 
			
		||||
    """Test Event"""
 | 
			
		||||
 | 
			
		||||
    def test_new_with_model(self):
 | 
			
		||||
        """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"""
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 | 
			
		||||
from django.db.models import Model
 | 
			
		||||
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.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):
 | 
			
		||||
    """Flow Viewset"""
 | 
			
		||||
 | 
			
		||||
    queryset = Flow.objects.all()
 | 
			
		||||
    serializer_class = FlowSerializer
 | 
			
		||||
    lookup_field = "slug"
 | 
			
		||||
    search_fields = ["name", "slug", "designation", "title"]
 | 
			
		||||
    filterset_fields = ["flow_uuid", "name", "slug", "designation"]
 | 
			
		||||
 | 
			
		||||
    @swagger_auto_schema(responses={200: FlowDiagramSerializer()})
 | 
			
		||||
    @action(detail=True, methods=["get"])
 | 
			
		||||
    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):
 | 
			
		||||
@ -98,3 +211,14 @@ class FlowStageBindingViewSet(ModelViewSet):
 | 
			
		||||
    queryset = FlowStageBinding.objects.all()
 | 
			
		||||
    serializer_class = FlowStageBindingSerializer
 | 
			
		||||
    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 structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.audit.models import cleanse_dict
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.events.models import cleanse_dict
 | 
			
		||||
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
 | 
			
		||||
from authentik.flows.markers import ReevaluateMarker, StageMarker
 | 
			
		||||
from authentik.flows.models import Flow, FlowStageBinding, Stage
 | 
			
		||||
@ -19,6 +19,7 @@ LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
PLAN_CONTEXT_PENDING_USER = "pending_user"
 | 
			
		||||
PLAN_CONTEXT_SSO = "is_sso"
 | 
			
		||||
PLAN_CONTEXT_REDIRECT = "redirect"
 | 
			
		||||
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())
 | 
			
		||||
@ -3,14 +3,17 @@ from unittest.mock import MagicMock, PropertyMock, patch
 | 
			
		||||
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.shortcuts import reverse
 | 
			
		||||
from django.test import Client, TestCase
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.test.client import RequestFactory
 | 
			
		||||
from django.utils.encoding import force_str
 | 
			
		||||
 | 
			
		||||
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.flows.exceptions import FlowNonApplicableException
 | 
			
		||||
from authentik.flows.markers import ReevaluateMarker, StageMarker
 | 
			
		||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
 | 
			
		||||
from authentik.flows.planner import FlowPlan
 | 
			
		||||
from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN
 | 
			
		||||
from authentik.flows.planner import FlowPlan, FlowPlanner
 | 
			
		||||
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView
 | 
			
		||||
from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.policies.dummy.models import DummyPolicy
 | 
			
		||||
from authentik.policies.http import AccessDeniedResponse
 | 
			
		||||
@ -35,8 +38,12 @@ class TestFlowExecutor(TestCase):
 | 
			
		||||
    """Test views logic"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.client = Client()
 | 
			
		||||
        self.request_factory = RequestFactory()
 | 
			
		||||
 | 
			
		||||
    @patch(
 | 
			
		||||
        "authentik.flows.views.to_stage_response",
 | 
			
		||||
        TO_STAGE_RESPONSE_MOCK,
 | 
			
		||||
    )
 | 
			
		||||
    def test_existing_plan_diff_flow(self):
 | 
			
		||||
        """Check that a plan for a different flow cancels the current plan"""
 | 
			
		||||
        flow = Flow.objects.create(
 | 
			
		||||
@ -59,7 +66,7 @@ class TestFlowExecutor(TestCase):
 | 
			
		||||
                    "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)
 | 
			
		||||
 | 
			
		||||
    @patch(
 | 
			
		||||
@ -102,10 +109,13 @@ class TestFlowExecutor(TestCase):
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertIsInstance(response, AccessDeniedResponse)
 | 
			
		||||
        self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content)
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.url, reverse("authentik_core:shell"))
 | 
			
		||||
 | 
			
		||||
    @patch(
 | 
			
		||||
        "authentik.flows.views.to_stage_response",
 | 
			
		||||
        TO_STAGE_RESPONSE_MOCK,
 | 
			
		||||
    )
 | 
			
		||||
    def test_invalid_flow_redirect(self):
 | 
			
		||||
        """Tests that an invalid flow still redirects"""
 | 
			
		||||
        flow = Flow.objects.create(
 | 
			
		||||
@ -118,11 +128,8 @@ class TestFlowExecutor(TestCase):
 | 
			
		||||
        dest = "/unique-string"
 | 
			
		||||
        url = reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug})
 | 
			
		||||
        response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertJSONEqual(
 | 
			
		||||
            force_str(response.content),
 | 
			
		||||
            {"type": "redirect", "to": dest},
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.url, reverse("authentik_core:shell"))
 | 
			
		||||
 | 
			
		||||
    def test_multi_stage_flow(self):
 | 
			
		||||
        """Test a full flow with multiple stages"""
 | 
			
		||||
@ -158,6 +165,10 @@ class TestFlowExecutor(TestCase):
 | 
			
		||||
        plan: FlowPlan = session[SESSION_KEY_PLAN]
 | 
			
		||||
        self.assertEqual(len(plan.stages), 1)
 | 
			
		||||
 | 
			
		||||
    @patch(
 | 
			
		||||
        "authentik.flows.views.to_stage_response",
 | 
			
		||||
        TO_STAGE_RESPONSE_MOCK,
 | 
			
		||||
    )
 | 
			
		||||
    def test_reevaluate_remove_last(self):
 | 
			
		||||
        """Test planner with re-evaluate (last stage is removed)"""
 | 
			
		||||
        flow = Flow.objects.create(
 | 
			
		||||
@ -276,6 +287,83 @@ class TestFlowExecutor(TestCase):
 | 
			
		||||
            {"type": "redirect", "to": reverse("authentik_core:shell")},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_reevaluate_keep(self):
 | 
			
		||||
        """Test planner with re-evaluate (everything is kept)"""
 | 
			
		||||
        flow = Flow.objects.create(
 | 
			
		||||
            name="test-default-context",
 | 
			
		||||
            slug="test-default-context",
 | 
			
		||||
            designation=FlowDesignation.AUTHENTICATION,
 | 
			
		||||
        )
 | 
			
		||||
        true_policy = DummyPolicy.objects.create(result=True, wait_min=1, wait_max=2)
 | 
			
		||||
 | 
			
		||||
        binding = 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,
 | 
			
		||||
        )
 | 
			
		||||
        binding3 = FlowStageBinding.objects.create(
 | 
			
		||||
            target=flow, stage=DummyStage.objects.create(name="dummy3"), order=2
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        PolicyBinding.objects.create(policy=true_policy, target=binding2, order=0)
 | 
			
		||||
 | 
			
		||||
        # Here we patch the dummy policy to evaluate to true so the stage is included
 | 
			
		||||
        with patch(
 | 
			
		||||
            "authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE
 | 
			
		||||
        ):
 | 
			
		||||
 | 
			
		||||
            exec_url = reverse(
 | 
			
		||||
                "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
 | 
			
		||||
            )
 | 
			
		||||
            # First request, run the planner
 | 
			
		||||
            response = self.client.get(exec_url)
 | 
			
		||||
 | 
			
		||||
            self.assertEqual(response.status_code, 200)
 | 
			
		||||
            plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
 | 
			
		||||
 | 
			
		||||
            self.assertEqual(plan.stages[0], binding.stage)
 | 
			
		||||
            self.assertEqual(plan.stages[1], binding2.stage)
 | 
			
		||||
            self.assertEqual(plan.stages[2], binding3.stage)
 | 
			
		||||
 | 
			
		||||
            self.assertIsInstance(plan.markers[0], StageMarker)
 | 
			
		||||
            self.assertIsInstance(plan.markers[1], ReevaluateMarker)
 | 
			
		||||
            self.assertIsInstance(plan.markers[2], StageMarker)
 | 
			
		||||
 | 
			
		||||
            # Second request, this passes the first dummy stage
 | 
			
		||||
            response = self.client.post(exec_url)
 | 
			
		||||
            self.assertEqual(response.status_code, 302)
 | 
			
		||||
 | 
			
		||||
            plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
 | 
			
		||||
 | 
			
		||||
            self.assertEqual(plan.stages[0], binding2.stage)
 | 
			
		||||
            self.assertEqual(plan.stages[1], binding3.stage)
 | 
			
		||||
 | 
			
		||||
            self.assertIsInstance(plan.markers[0], StageMarker)
 | 
			
		||||
            self.assertIsInstance(plan.markers[1], StageMarker)
 | 
			
		||||
 | 
			
		||||
            # Third request, this passes the first dummy stage
 | 
			
		||||
            response = self.client.post(exec_url)
 | 
			
		||||
            self.assertEqual(response.status_code, 302)
 | 
			
		||||
 | 
			
		||||
            plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
 | 
			
		||||
 | 
			
		||||
            self.assertEqual(plan.stages[0], binding3.stage)
 | 
			
		||||
 | 
			
		||||
            self.assertIsInstance(plan.markers[0], StageMarker)
 | 
			
		||||
 | 
			
		||||
        # third request, this should trigger the re-evaluate
 | 
			
		||||
        # We do this request without the patch, so the policy results in false
 | 
			
		||||
        response = self.client.post(exec_url)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        self.assertJSONEqual(
 | 
			
		||||
            force_str(response.content),
 | 
			
		||||
            {"type": "redirect", "to": reverse("authentik_core:shell")},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_reevaluate_remove_consecutive(self):
 | 
			
		||||
        """Test planner with re-evaluate (consecutive stages are removed)"""
 | 
			
		||||
        flow = Flow.objects.create(
 | 
			
		||||
@ -351,3 +439,33 @@ class TestFlowExecutor(TestCase):
 | 
			
		||||
            force_str(response.content),
 | 
			
		||||
            {"type": "redirect", "to": reverse("authentik_core:shell")},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_stageview_user_identifier(self):
 | 
			
		||||
        """Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER"""
 | 
			
		||||
        flow = Flow.objects.create(
 | 
			
		||||
            name="test-default-context",
 | 
			
		||||
            slug="test-default-context",
 | 
			
		||||
            designation=FlowDesignation.AUTHENTICATION,
 | 
			
		||||
        )
 | 
			
		||||
        FlowStageBinding.objects.create(
 | 
			
		||||
            target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        ident = "test-identifier"
 | 
			
		||||
 | 
			
		||||
        user = User.objects.create(username="test-user")
 | 
			
		||||
        request = self.request_factory.get(
 | 
			
		||||
            reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
 | 
			
		||||
        )
 | 
			
		||||
        request.user = user
 | 
			
		||||
        planner = FlowPlanner(flow)
 | 
			
		||||
        plan = planner.plan(
 | 
			
		||||
            request, default_context={PLAN_CONTEXT_PENDING_USER_IDENTIFIER: ident}
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        executor = FlowExecutorView()
 | 
			
		||||
        executor.plan = plan
 | 
			
		||||
        executor.flow = flow
 | 
			
		||||
 | 
			
		||||
        stage_view = StageView(executor)
 | 
			
		||||
        self.assertEqual(ident, stage_view.get_context_data()["user"].username)
 | 
			
		||||
 | 
			
		||||
@ -61,7 +61,7 @@ class DataclassEncoder(JSONEncoder):
 | 
			
		||||
            return asdict(o)
 | 
			
		||||
        if isinstance(o, UUID):
 | 
			
		||||
            return str(o)
 | 
			
		||||
        return super().default(o)
 | 
			
		||||
        return super().default(o)  # pragma: no cover
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EntryInvalidError(SentryIgnoredException):
 | 
			
		||||
 | 
			
		||||
@ -11,7 +11,7 @@ from authentik.flows.transfer.common import (
 | 
			
		||||
    FlowBundle,
 | 
			
		||||
    FlowBundleEntry,
 | 
			
		||||
)
 | 
			
		||||
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
 | 
			
		||||
from authentik.policies.models import Policy, PolicyBinding
 | 
			
		||||
from authentik.stages.prompt.models import PromptStage
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -31,11 +31,6 @@ class FlowExporter:
 | 
			
		||||
 | 
			
		||||
    def _prepare_pbm(self):
 | 
			
		||||
        self.pbm_uuids = [self.flow.pbm_uuid]
 | 
			
		||||
        for stage_subclass in Stage.__subclasses__():
 | 
			
		||||
            if issubclass(stage_subclass, PolicyBindingModel):
 | 
			
		||||
                self.pbm_uuids += stage_subclass.objects.filter(
 | 
			
		||||
                    flow=self.flow
 | 
			
		||||
                ).values_list("pbm_uuid", flat=True)
 | 
			
		||||
        self.pbm_uuids += FlowStageBinding.objects.filter(target=self.flow).values_list(
 | 
			
		||||
            "pbm_uuid", flat=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -17,11 +17,16 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
 | 
			
		||||
from django.views.generic import TemplateView, View
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.audit.models import cleanse_dict
 | 
			
		||||
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.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.urls import is_url_absolute, redirect_with_qs
 | 
			
		||||
from authentik.policies.http import AccessDeniedResponse
 | 
			
		||||
@ -83,7 +88,9 @@ class FlowExecutorView(View):
 | 
			
		||||
                return to_stage_response(self.request, self.handle_invalid_flow(exc))
 | 
			
		||||
            except EmptyFlowException as 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
 | 
			
		||||
        # as it hasn't been successfully passed yet
 | 
			
		||||
        next_stage = self.plan.next(self.request)
 | 
			
		||||
@ -143,11 +150,15 @@ class FlowExecutorView(View):
 | 
			
		||||
        """User Successfully passed all stages"""
 | 
			
		||||
        # 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
 | 
			
		||||
        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_ARG_NAME, "authentik_core:shell"
 | 
			
		||||
            )
 | 
			
		||||
        self.cancel()
 | 
			
		||||
        return redirect_with_qs(next_param)
 | 
			
		||||
        return to_stage_response(self.request, redirect_with_qs(next_param))
 | 
			
		||||
 | 
			
		||||
    def stage_ok(self) -> HttpResponse:
 | 
			
		||||
        """Callback called by stages upon successful completion.
 | 
			
		||||
 | 
			
		||||
@ -80,11 +80,15 @@ class BaseEvaluator:
 | 
			
		||||
            span: Span
 | 
			
		||||
            span.set_data("expression", expression_source)
 | 
			
		||||
            param_keys = self._context.keys()
 | 
			
		||||
            try:
 | 
			
		||||
                ast_obj = compile(
 | 
			
		||||
                    self.wrap_expression(expression_source, param_keys),
 | 
			
		||||
                    self._filename,
 | 
			
		||||
                    "exec",
 | 
			
		||||
                )
 | 
			
		||||
            except (SyntaxError, ValueError) as exc:
 | 
			
		||||
                self.handle_error(exc, expression_source)
 | 
			
		||||
                raise exc
 | 
			
		||||
            try:
 | 
			
		||||
                _locals = self._context
 | 
			
		||||
                # 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
 | 
			
		||||
                result = _locals["result"]
 | 
			
		||||
            except Exception as exc:
 | 
			
		||||
                LOGGER.warning("Expression error", exc=exc)
 | 
			
		||||
                raise
 | 
			
		||||
                self.handle_error(exc, expression_source)
 | 
			
		||||
                raise exc
 | 
			
		||||
            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:
 | 
			
		||||
        """Validate expression's syntax, raise ValidationError if Syntax is invalid"""
 | 
			
		||||
        param_keys = self._context.keys()
 | 
			
		||||
 | 
			
		||||
@ -1,55 +0,0 @@
 | 
			
		||||
"""authentik lib navbar Templatetag"""
 | 
			
		||||
from django import template
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
register = template.Library()
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
ACTIVE_STRING = "pf-m-current"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.simple_tag(takes_context=True)
 | 
			
		||||
def is_active(context, *args: str, **_) -> str:
 | 
			
		||||
    """Return whether a navbar link is active or not."""
 | 
			
		||||
    request: HttpRequest = context.get("request")
 | 
			
		||||
    if not request.resolver_match:
 | 
			
		||||
        return ""
 | 
			
		||||
    match = request.resolver_match
 | 
			
		||||
    for url in args:
 | 
			
		||||
        if ":" in url:
 | 
			
		||||
            app_name, url = url.split(":")
 | 
			
		||||
            if match.app_name == app_name and match.url_name == url:
 | 
			
		||||
                return ACTIVE_STRING
 | 
			
		||||
        else:
 | 
			
		||||
            if match.url_name == url:
 | 
			
		||||
                return ACTIVE_STRING
 | 
			
		||||
    return ""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.simple_tag(takes_context=True)
 | 
			
		||||
def is_active_url(context, view: str) -> str:
 | 
			
		||||
    """Return whether a navbar link is active or not."""
 | 
			
		||||
    request: HttpRequest = context.get("request")
 | 
			
		||||
    if not request.resolver_match:
 | 
			
		||||
        return ""
 | 
			
		||||
 | 
			
		||||
    match = request.resolver_match
 | 
			
		||||
    current_full_url = f"{match.app_name}:{match.url_name}"
 | 
			
		||||
 | 
			
		||||
    if current_full_url == view:
 | 
			
		||||
        return ACTIVE_STRING
 | 
			
		||||
    return ""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.simple_tag(takes_context=True)
 | 
			
		||||
def is_active_app(context, *args: str) -> str:
 | 
			
		||||
    """Return True if current link is from app"""
 | 
			
		||||
 | 
			
		||||
    request: HttpRequest = context.get("request")
 | 
			
		||||
    if not request.resolver_match:
 | 
			
		||||
        return ""
 | 
			
		||||
    for app_name in args:
 | 
			
		||||
        if request.resolver_match.app_name == app_name:
 | 
			
		||||
            return ACTIVE_STRING
 | 
			
		||||
    return ""
 | 
			
		||||
							
								
								
									
										0
									
								
								authentik/lib/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/lib/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										18
									
								
								authentik/lib/tests/test_sentry.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								authentik/lib/tests/test_sentry.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
"""test sentry integration"""
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
from authentik.lib.sentry import SentryIgnoredException, before_send
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestSentry(TestCase):
 | 
			
		||||
    """test sentry integration"""
 | 
			
		||||
 | 
			
		||||
    def test_error_not_sent(self):
 | 
			
		||||
        """Test SentryIgnoredError not sent"""
 | 
			
		||||
        self.assertIsNone(
 | 
			
		||||
            before_send(None, {"exc_info": (0, SentryIgnoredException(), 0)})
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_error_sent(self):
 | 
			
		||||
        """Test error sent"""
 | 
			
		||||
        self.assertIsNone(before_send(None, {"exc_info": (0, ValueError(), 0)}))
 | 
			
		||||
@ -20,6 +20,8 @@ class TestTimeUtils(TestCase):
 | 
			
		||||
        """Test invalid expression"""
 | 
			
		||||
        with self.assertRaises(ValueError):
 | 
			
		||||
            timedelta_from_string("foo")
 | 
			
		||||
        with self.assertRaises(ValueError):
 | 
			
		||||
            timedelta_from_string("bar=baz")
 | 
			
		||||
 | 
			
		||||
    def test_validation(self):
 | 
			
		||||
        """Test Django model field validator"""
 | 
			
		||||
@ -35,4 +35,6 @@ def timedelta_from_string(expr: str) -> datetime.timedelta:
 | 
			
		||||
        if key.lower() not in ALLOWED_KEYS:
 | 
			
		||||
            continue
 | 
			
		||||
        kwargs[key.lower()] = float(value)
 | 
			
		||||
    if len(kwargs) < 1:
 | 
			
		||||
        raise ValueError("No valid keys to pass to timedelta")
 | 
			
		||||
    return datetime.timedelta(**kwargs)
 | 
			
		||||
 | 
			
		||||
@ -22,7 +22,6 @@ class AuthentikOutpostConfig(AppConfig):
 | 
			
		||||
 | 
			
		||||
    name = "authentik.outposts"
 | 
			
		||||
    label = "authentik_outposts"
 | 
			
		||||
    mountpoint = "outposts/"
 | 
			
		||||
    verbose_name = "authentik Outpost"
 | 
			
		||||
 | 
			
		||||
    def ready(self):
 | 
			
		||||
 | 
			
		||||
@ -2,8 +2,9 @@
 | 
			
		||||
from dataclasses import asdict, dataclass, field
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from enum import IntEnum
 | 
			
		||||
from typing import Any, Dict
 | 
			
		||||
from typing import Any, Dict, Optional
 | 
			
		||||
 | 
			
		||||
from channels.exceptions import DenyConnection
 | 
			
		||||
from dacite import from_dict
 | 
			
		||||
from dacite.data import Data
 | 
			
		||||
from guardian.shortcuts import get_objects_for_user
 | 
			
		||||
@ -39,18 +40,16 @@ class WebsocketMessage:
 | 
			
		||||
class OutpostConsumer(AuthJsonConsumer):
 | 
			
		||||
    """Handler for Outposts that connect over websockets for health checks and live updates"""
 | 
			
		||||
 | 
			
		||||
    outpost: Outpost
 | 
			
		||||
    outpost: Optional[Outpost] = None
 | 
			
		||||
 | 
			
		||||
    def connect(self):
 | 
			
		||||
        if not super().connect():
 | 
			
		||||
            return
 | 
			
		||||
        super().connect()
 | 
			
		||||
        uuid = self.scope["url_route"]["kwargs"]["pk"]
 | 
			
		||||
        outpost = get_objects_for_user(
 | 
			
		||||
            self.user, "authentik_outposts.view_outpost"
 | 
			
		||||
        ).filter(pk=uuid)
 | 
			
		||||
        if not outpost.exists():
 | 
			
		||||
            self.close()
 | 
			
		||||
            return
 | 
			
		||||
            raise DenyConnection()
 | 
			
		||||
        self.accept()
 | 
			
		||||
        self.outpost = outpost.first()
 | 
			
		||||
        OutpostState(
 | 
			
		||||
@ -60,6 +59,7 @@ class OutpostConsumer(AuthJsonConsumer):
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def disconnect(self, close_code):
 | 
			
		||||
        if self.outpost:
 | 
			
		||||
            OutpostState.for_channel(self.outpost, self.channel_name).delete()
 | 
			
		||||
        LOGGER.debug("removed channel from cache", channel_name=self.channel_name)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
"""Base Controller"""
 | 
			
		||||
from typing import Dict, List
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
from structlog.testing import capture_logs
 | 
			
		||||
@ -7,15 +7,26 @@ from structlog.testing import capture_logs
 | 
			
		||||
from authentik.lib.sentry import SentryIgnoredException
 | 
			
		||||
from authentik.outposts.models import Outpost, OutpostServiceConnection
 | 
			
		||||
 | 
			
		||||
FIELD_MANAGER = "goauthentik.io"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ControllerException(SentryIgnoredException):
 | 
			
		||||
    """Exception raised when anything fails during controller run"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class DeploymentPort:
 | 
			
		||||
    """Info about deployment's single port."""
 | 
			
		||||
 | 
			
		||||
    port: int
 | 
			
		||||
    name: str
 | 
			
		||||
    protocol: str
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseController:
 | 
			
		||||
    """Base Outpost deployment controller"""
 | 
			
		||||
 | 
			
		||||
    deployment_ports: Dict[str, int]
 | 
			
		||||
    deployment_ports: list[DeploymentPort]
 | 
			
		||||
 | 
			
		||||
    outpost: Outpost
 | 
			
		||||
    connection: OutpostServiceConnection
 | 
			
		||||
@ -24,14 +35,14 @@ class BaseController:
 | 
			
		||||
        self.outpost = outpost
 | 
			
		||||
        self.connection = connection
 | 
			
		||||
        self.logger = get_logger()
 | 
			
		||||
        self.deployment_ports = {}
 | 
			
		||||
        self.deployment_ports = []
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    def up(self):
 | 
			
		||||
        """Called by scheduled task to reconcile deployment/service/etc"""
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def up_with_logs(self) -> List[str]:
 | 
			
		||||
    def up_with_logs(self) -> list[str]:
 | 
			
		||||
        """Call .up() but capture all log output and return it."""
 | 
			
		||||
        with capture_logs() as logs:
 | 
			
		||||
            self.up()
 | 
			
		||||
 | 
			
		||||
@ -68,7 +68,10 @@ class DockerController(BaseController):
 | 
			
		||||
                "image": image_name,
 | 
			
		||||
                "name": f"authentik-proxy-{self.outpost.uuid.hex}",
 | 
			
		||||
                "detach": True,
 | 
			
		||||
                "ports": {x: x for _, x in self.deployment_ports.items()},
 | 
			
		||||
                "ports": {
 | 
			
		||||
                    f"{port.port}/{port.protocol.lower()}": port.port
 | 
			
		||||
                    for port in self.deployment_ports
 | 
			
		||||
                },
 | 
			
		||||
                "environment": self._get_env(),
 | 
			
		||||
                "labels": self._get_labels(),
 | 
			
		||||
            }
 | 
			
		||||
@ -139,7 +142,10 @@ class DockerController(BaseController):
 | 
			
		||||
 | 
			
		||||
    def get_static_deployment(self) -> str:
 | 
			
		||||
        """Generate docker-compose yaml for proxy, version 3.5"""
 | 
			
		||||
        ports = [f"{x}:{x}" for _, x in self.deployment_ports.items()]
 | 
			
		||||
        ports = [
 | 
			
		||||
            f"{port.port}:{port.port}/{port.protocol.lower()}"
 | 
			
		||||
            for port in self.deployment_ports
 | 
			
		||||
        ]
 | 
			
		||||
        image_prefix = CONFIG.y("outposts.docker_image_base")
 | 
			
		||||
        compose = {
 | 
			
		||||
            "version": "3.5",
 | 
			
		||||
@ -154,6 +160,7 @@ class DockerController(BaseController):
 | 
			
		||||
                        ),
 | 
			
		||||
                        "AUTHENTIK_TOKEN": self.outpost.token.key,
 | 
			
		||||
                    },
 | 
			
		||||
                    "labels": self._get_labels(),
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -93,7 +93,8 @@ class KubernetesObjectReconciler(Generic[T]):
 | 
			
		||||
    def reconcile(self, current: T, reference: T):
 | 
			
		||||
        """Check what operations should be done, should be raised as
 | 
			
		||||
        ReconcileTrigger"""
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
        if current.metadata.annotations != reference.metadata.annotations:
 | 
			
		||||
            raise NeedsUpdate()
 | 
			
		||||
 | 
			
		||||
    def create(self, reference: T):
 | 
			
		||||
        """API Wrapper to create object"""
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ from kubernetes.client import (
 | 
			
		||||
 | 
			
		||||
from authentik import __version__
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.outposts.controllers.base import FIELD_MANAGER
 | 
			
		||||
from authentik.outposts.controllers.k8s.base import (
 | 
			
		||||
    KubernetesObjectReconciler,
 | 
			
		||||
    NeedsUpdate,
 | 
			
		||||
@ -43,6 +44,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
 | 
			
		||||
        return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
 | 
			
		||||
 | 
			
		||||
    def reconcile(self, current: V1Deployment, reference: V1Deployment):
 | 
			
		||||
        super().reconcile(current, reference)
 | 
			
		||||
        if current.spec.replicas != reference.spec.replicas:
 | 
			
		||||
            raise NeedsUpdate()
 | 
			
		||||
        if (
 | 
			
		||||
@ -63,8 +65,14 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
 | 
			
		||||
        """Get deployment object for outpost"""
 | 
			
		||||
        # Generate V1ContainerPort objects
 | 
			
		||||
        container_ports = []
 | 
			
		||||
        for port_name, port in self.controller.deployment_ports.items():
 | 
			
		||||
            container_ports.append(V1ContainerPort(container_port=port, name=port_name))
 | 
			
		||||
        for port in self.controller.deployment_ports:
 | 
			
		||||
            container_ports.append(
 | 
			
		||||
                V1ContainerPort(
 | 
			
		||||
                    container_port=port.port,
 | 
			
		||||
                    name=port.name,
 | 
			
		||||
                    protocol=port.protocol.upper(),
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        meta = self.get_object_meta(name=self.name)
 | 
			
		||||
        secret_name = f"authentik-outpost-{self.controller.outpost.uuid.hex}-api"
 | 
			
		||||
        image_prefix = CONFIG.y("outposts.docker_image_base")
 | 
			
		||||
@ -118,7 +126,9 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def create(self, reference: V1Deployment):
 | 
			
		||||
        return self.api.create_namespaced_deployment(self.namespace, reference)
 | 
			
		||||
        return self.api.create_namespaced_deployment(
 | 
			
		||||
            self.namespace, reference, field_manager=FIELD_MANAGER
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def delete(self, reference: V1Deployment):
 | 
			
		||||
        return self.api.delete_namespaced_deployment(
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user