Compare commits
360 Commits
version/0.
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
677a181b9c | |||
4b551add1a | |||
217cca822d | |||
e6f897c7e6 | |||
65c9d4bf4c | |||
6e88e52d78 | |||
6e69edf1af | |||
08e7ef3c1e | |||
d728163eea | |||
cf76652a4c | |||
c525ecc334 | |||
49d40d4337 | |||
94182f88a4 | |||
1c25f4f09b | |||
6495d6c50a | |||
b81f3e4a38 | |||
aad3b43ac3 | |||
60f52f102a | |||
f3ccb5341d | |||
cb73210447 | |||
81efc9a673 | |||
72c6c0da9b | |||
8fef839965 | |||
87b830ff9a | |||
8acb9dde5f | |||
36e8b1004c | |||
f959212692 | |||
2d2a404028 | |||
394ad6ade5 | |||
4baf9e4a22 | |||
d020599e09 | |||
4f28a89e63 | |||
f8b4b92e8d | |||
33f208657c | |||
c1fbfc63ab | |||
192dbe05c4 | |||
0b41cb84f0 | |||
d637bd0bf9 | |||
a2bddc6d91 | |||
2e42da11ea | |||
f297d1256d | |||
5e1e5afb24 | |||
da59e7c4a7 | |||
8684d106d5 | |||
2579e168c3 | |||
7f5caf901d | |||
1c686e19b5 | |||
3cc92f6c97 | |||
8f5b33a3a2 | |||
4447345345 | |||
42c6401ba7 | |||
eef111bcfd | |||
6192b2787f | |||
c7d28f8ca9 | |||
1342266368 | |||
7ff679b1a3 | |||
8beddcddb0 | |||
9fe8554f28 | |||
812fe72e60 | |||
d0e4533cdd | |||
b1b5d94ddc | |||
59722e0bbe | |||
9c5bb3998c | |||
c180c4b1a2 | |||
308896719d | |||
95c1473dd2 | |||
b14c5039ed | |||
b6948334f2 | |||
29e08e7477 | |||
36bc1dc020 | |||
61d1407804 | |||
47ddf0d7f2 | |||
cb36a3c8c7 | |||
cac94792fa | |||
6f56c37d2f | |||
8369fa16ae | |||
f30bdbecd6 | |||
c727c845df | |||
b2b737e59e | |||
e2b930afe3 | |||
36c0b924bc | |||
1ccf6dcf6f | |||
f8a426f0e8 | |||
f8756d0fc9 | |||
fd6d99f4f9 | |||
04379f2c90 | |||
ba1195cf70 | |||
b0bd9212c7 | |||
209179e012 | |||
df16f635fa | |||
14ccf47a2b | |||
2aac024477 | |||
4743e72e18 | |||
cab2942c4e | |||
9fb5ce2a1a | |||
0eab4489c5 | |||
3aae030b23 | |||
e7060cb90a | |||
6c0b9e3525 | |||
82bb179bc2 | |||
774eb0388b | |||
6ed78830a0 | |||
6fe323f1a7 | |||
85c2db018e | |||
bc9e7e8b93 | |||
08c58ce3fb | |||
c3bc986473 | |||
2e69efe699 | |||
4daa373dcf | |||
a85b8a65c0 | |||
d8dc1f8bb5 | |||
0f4d5bc3b0 | |||
6eed549577 | |||
be54ba4fe2 | |||
68b9c34f78 | |||
3584bdf530 | |||
e712719333 | |||
9a21c2f6bd | |||
0632d8ff37 | |||
6bfaf71c12 | |||
b6c8c319e5 | |||
4fde1b7365 | |||
412f5b9210 | |||
a9e53cd52a | |||
d0ee7908ab | |||
e69834dec4 | |||
1b9d22615c | |||
e995536a15 | |||
e6818faab1 | |||
010e834149 | |||
16d5e1d9ff | |||
765ae80698 | |||
bbd0ff24d8 | |||
7a403613b2 | |||
4ad184a3fb | |||
48d5f28e7a | |||
0cb48121b2 | |||
4194ffe2d4 | |||
4636fe7e64 | |||
182d714b16 | |||
540c22ce15 | |||
8c3008abce | |||
8a22c86aaa | |||
22ce142cb8 | |||
1a292feebb | |||
09f4d812b3 | |||
2bab4ebfe8 | |||
a8647caca9 | |||
590597caf6 | |||
7b43777b22 | |||
77861b52e3 | |||
5f9c1e229c | |||
119adb3e7b | |||
5db38bd0b7 | |||
0e1587bc1a | |||
dc16a8a4c9 | |||
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 | |||
59c93defcf | |||
a2a1a27502 | |||
e3227e7d54 | |||
1f4a8fffdb | |||
86b1183883 | |||
f781f4848c | |||
19824d693c | |||
0694b911a4 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.13.0-rc1
|
||||
current_version = 2021.1.1-stable
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||
@ -23,12 +23,14 @@ values =
|
||||
|
||||
[bumpversion:file:helm/values.yaml]
|
||||
|
||||
[bumpversion:file:helm/README.md]
|
||||
|
||||
[bumpversion:file:helm/Chart.yaml]
|
||||
|
||||
[bumpversion:file:.github/workflows/release.yml]
|
||||
|
||||
[bumpversion:file:authentik/__init__.py]
|
||||
|
||||
[bumpversion:file:proxy/pkg/version.go]
|
||||
[bumpversion:file:outpost/pkg/version.go]
|
||||
|
||||
[bumpversion:file:web/src/constants.ts]
|
||||
|
21
.github/workflows/release.yml
vendored
21
.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-rc1
|
||||
-t beryju/authentik:2021.1.1-stable
|
||||
-t beryju/authentik:latest
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/authentik:0.13.0-rc1
|
||||
run: docker push beryju/authentik:2021.1.1-stable
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/authentik:latest
|
||||
build-proxy:
|
||||
@ -34,7 +34,7 @@ jobs:
|
||||
go-version: "^1.15"
|
||||
- name: prepare go api client
|
||||
run: |
|
||||
cd proxy
|
||||
cd outpost
|
||||
go get -u github.com/go-swagger/go-swagger/cmd/swagger
|
||||
swagger generate client -f ../swagger.yaml -A authentik -t pkg/
|
||||
go build -v .
|
||||
@ -45,14 +45,14 @@ jobs:
|
||||
run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
|
||||
- name: Building Docker Image
|
||||
run: |
|
||||
cd proxy/
|
||||
cd outpost/
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/authentik-proxy:0.13.0-rc1 \
|
||||
-t beryju/authentik-proxy:2021.1.1-stable \
|
||||
-t beryju/authentik-proxy:latest \
|
||||
-f Dockerfile .
|
||||
-f proxy.Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/authentik-proxy:0.13.0-rc1
|
||||
run: docker push beryju/authentik-proxy:2021.1.1-stable
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/authentik-proxy:latest
|
||||
build-static:
|
||||
@ -69,17 +69,18 @@ jobs:
|
||||
cd web/
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/authentik-static:0.13.0-rc1 \
|
||||
-t beryju/authentik-static:2021.1.1-stable \
|
||||
-t beryju/authentik-static:latest \
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/authentik-static:0.13.0-rc1
|
||||
run: docker push beryju/authentik-static:2021.1.1-stable
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/authentik-static:latest
|
||||
test-release:
|
||||
needs:
|
||||
- build-server
|
||||
- build-static
|
||||
- build-proxy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
@ -106,5 +107,5 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
tagName: 0.13.0-rc1
|
||||
tagName: 2021.1.1-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
|
||||
|
374
Pipfile.lock
generated
374
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:b5052144034e490358c659d0e480c17a4e604fd3aee9a97ddfe6e361a245a4a5",
|
||||
"sha256:efd6c96c98900e9fbf217f13cb58f59b793e51f69a1ce61817eefd31f17c6ef5"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.16.34"
|
||||
"version": "==1.16.55"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:49f5e56a7382a65ee0873371edcd91bdba8fc3f70abe102ebc1a0da2e6fbed06",
|
||||
"sha256:4d81d92127ef646ae0f0ee84c9c220c92fa82312e765c29f8cb3b000fdbdd038"
|
||||
"sha256:760d0c16c1474c2a46e3fa45e33ae7457b5cab7410737ab1692340ade764cc73",
|
||||
"sha256:b34327d84b3bb5620fb54603677a9a973b167290c2c1e7ab69c4a46b201c6d46"
|
||||
],
|
||||
"version": "==1.19.34"
|
||||
"version": "==1.19.55"
|
||||
},
|
||||
"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": [
|
||||
@ -265,11 +265,11 @@
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2",
|
||||
"sha256:edb10b5c45e7e9c0fb1dc00b76ec7449aca258a39ffd613dbd078c51d19c9f03"
|
||||
"sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7",
|
||||
"sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.1.4"
|
||||
"version": "==3.1.5"
|
||||
},
|
||||
"django-cors-middleware": {
|
||||
"hashes": [
|
||||
@ -343,15 +343,16 @@
|
||||
},
|
||||
"django-storages": {
|
||||
"hashes": [
|
||||
"sha256:12de8fb2605b9b57bfaf54b075280d7cbb3b3ee1ca4bc9b9add147af87fe3a2c",
|
||||
"sha256:652275ab7844538c462b62810276c0244866f345878256a9e0e86f5b1283ae18"
|
||||
"sha256:c823dbf56c9e35b0999a13d7e05062b837bae36c518a40255d522fbe3750fbb4",
|
||||
"sha256:f28765826d507a0309cfaa849bd084894bc71d81bf0d09479168d44785396f80"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.10.1"
|
||||
"version": "==1.11.1"
|
||||
},
|
||||
"djangorestframework": {
|
||||
"hashes": [
|
||||
"sha256:0209bafcb7b5010fdfec784034f059d512256424de2a0f084cb82b096d6dd6a7"
|
||||
"sha256:0209bafcb7b5010fdfec784034f059d512256424de2a0f084cb82b096d6dd6a7",
|
||||
"sha256:0898182b4737a7b584a2c73735d89816343369f259fea932d90dc78e35d8ac33"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.12.2"
|
||||
@ -366,11 +367,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 +397,10 @@
|
||||
},
|
||||
"google-auth": {
|
||||
"hashes": [
|
||||
"sha256:5176db85f1e7e837a646cd9cede72c3c404ccf2e3373d9ee14b2db88febad440",
|
||||
"sha256:b728625ff5dfce8f9e56a499c8a4eb51443a67f20f6d28b67d5774c310ec4b6b"
|
||||
"sha256:0b0e026b412a0ad096e753907559e4bdb180d9ba9f68dd9036164db4fdc4ad2e",
|
||||
"sha256:ce752cc51c31f479dbf9928435ef4b07514b20261b021c7383bee4bda646acb8"
|
||||
],
|
||||
"version": "==1.23.0"
|
||||
"version": "==1.24.0"
|
||||
},
|
||||
"gunicorn": {
|
||||
"hashes": [
|
||||
@ -411,10 +412,10 @@
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab",
|
||||
"sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87"
|
||||
"sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6",
|
||||
"sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"
|
||||
],
|
||||
"version": "==0.11.0"
|
||||
"version": "==0.12.0"
|
||||
},
|
||||
"hiredis": {
|
||||
"hashes": [
|
||||
@ -486,10 +487,10 @@
|
||||
},
|
||||
"hyperlink": {
|
||||
"hashes": [
|
||||
"sha256:47fcc7cd339c6cb2444463ec3277bdcfe142c8b1daf2160bdd52248deec815af",
|
||||
"sha256:c528d405766f15a2c536230de7e160b65a08e20264d8891b3eb03307b0df3c63"
|
||||
"sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b",
|
||||
"sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4"
|
||||
],
|
||||
"version": "==20.0.1"
|
||||
"version": "==21.0.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
@ -646,26 +647,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 +687,11 @@
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236",
|
||||
"sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"
|
||||
"sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
|
||||
"sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.7"
|
||||
"version": "==20.8"
|
||||
},
|
||||
"prometheus-client": {
|
||||
"hashes": [
|
||||
@ -691,10 +702,10 @@
|
||||
},
|
||||
"prompt-toolkit": {
|
||||
"hashes": [
|
||||
"sha256:25c95d2ac813909f813c93fde734b6e44406d1477a9faef7c915ff37d39c0a8c",
|
||||
"sha256:7debb9a521e0b1ee7d2fe96ee4bd60ef03c6492784de0547337ca4433e46aa63"
|
||||
"sha256:ac329c69bd8564cb491940511957312c7b8959bb5b3cf3582b406068a51d5bb7",
|
||||
"sha256:b8b3d0bde65da350290c46a8f54f336b3cbf5464a4ac11239668d986852e79d5"
|
||||
],
|
||||
"version": "==3.0.8"
|
||||
"version": "==3.0.10"
|
||||
},
|
||||
"psycopg2-binary": {
|
||||
"hashes": [
|
||||
@ -855,10 +866,10 @@
|
||||
},
|
||||
"pyopenssl": {
|
||||
"hashes": [
|
||||
"sha256:898aefbde331ba718570244c3b01dcddb1b31a3b336613436a45e52e27d9a82d",
|
||||
"sha256:92f08eccbd73701cf744e8ffd6989aa7842d48cbe3fea8a7c031c5647f590ac5"
|
||||
"sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51",
|
||||
"sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b"
|
||||
],
|
||||
"version": "==20.0.0"
|
||||
"version": "==20.0.1"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
@ -889,10 +900,10 @@
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
|
||||
"sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
|
||||
"sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
|
||||
"sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
|
||||
],
|
||||
"version": "==2020.4"
|
||||
"version": "==2020.5"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
@ -930,10 +941,10 @@
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8",
|
||||
"sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"
|
||||
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
|
||||
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
|
||||
],
|
||||
"version": "==2.25.0"
|
||||
"version": "==2.25.1"
|
||||
},
|
||||
"requests-oauthlib": {
|
||||
"hashes": [
|
||||
@ -945,11 +956,11 @@
|
||||
},
|
||||
"rsa": {
|
||||
"hashes": [
|
||||
"sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa",
|
||||
"sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233"
|
||||
"sha256:69805d6b69f56eb05b62daea3a7dbd7aa44324ad1306445e05da8060232d00f4",
|
||||
"sha256:a8774e55b59fd9fc893b0d05e9bfc6f47081f46ff5b46f39ccf24631b7be356b"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==4.6"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==4.7"
|
||||
},
|
||||
"ruamel.yaml": {
|
||||
"hashes": [
|
||||
@ -960,10 +971,10 @@
|
||||
},
|
||||
"s3transfer": {
|
||||
"hashes": [
|
||||
"sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13",
|
||||
"sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db"
|
||||
"sha256:1e28620e5b444652ed752cf87c7e0cb15b0e578972568c6609f0f18212f259ed",
|
||||
"sha256:7fdddb4f22275cf1d32129e21f056337fd2a80b6ccef1664528145b72c49e6d2"
|
||||
],
|
||||
"version": "==0.3.3"
|
||||
"version": "==0.3.4"
|
||||
},
|
||||
"sentry-sdk": {
|
||||
"hashes": [
|
||||
@ -997,11 +1008,11 @@
|
||||
},
|
||||
"structlog": {
|
||||
"hashes": [
|
||||
"sha256:7a48375db6274ed1d0ae6123c486472aa1d0890b08d314d2b016f3aa7f35990b",
|
||||
"sha256:8a672be150547a93d90a7d74229a29e765be05bd156a35cdcc527ebf68e9af92"
|
||||
"sha256:33dd6bd5f49355e52c1c61bb6a4f20d0b48ce0328cc4a45fe872d38b97a05ccd",
|
||||
"sha256:af79dfa547d104af8d60f86eac12fb54825f54a46bc998e4504ef66177103174"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.1.0"
|
||||
"version": "==20.2.0"
|
||||
},
|
||||
"swagger-spec-validator": {
|
||||
"hashes": [
|
||||
@ -1044,10 +1055,10 @@
|
||||
},
|
||||
"txaio": {
|
||||
"hashes": [
|
||||
"sha256:17938f2bca4a9cabce61346758e482ca4e600160cbc28e861493eac74a19539d",
|
||||
"sha256:38a469daf93c37e5527cb062653d6393ae11663147c42fab7ddc3f6d00d434ae"
|
||||
"sha256:1488d31d564a116538cc1265ac3f7979fb6223bb5a9e9f1479436ee2c17d8549",
|
||||
"sha256:a8676d6c68aea1f0e2548c4afdb8e6253873af3bc2659bb5bcd9f39dff7ff90f"
|
||||
],
|
||||
"version": "==20.4.1"
|
||||
"version": "==20.12.1"
|
||||
},
|
||||
"uritemplate": {
|
||||
"hashes": [
|
||||
@ -1073,11 +1084,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 +1274,11 @@
|
||||
},
|
||||
"bandit": {
|
||||
"hashes": [
|
||||
"sha256:2ff3fe35fe3212c0be5fc9c4899bd0108e2b5239c5ff62fb174639e4660fe958",
|
||||
"sha256:d02dfe250f4aa2d166c127ad81d192579e2bfcdb8501717c0e2005e35a6bcf60"
|
||||
"sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07",
|
||||
"sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.6.3"
|
||||
"version": "==1.7.0"
|
||||
},
|
||||
"black": {
|
||||
"hashes": [
|
||||
@ -1308,51 +1319,66 @@
|
||||
},
|
||||
"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": [
|
||||
"sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2",
|
||||
"sha256:edb10b5c45e7e9c0fb1dc00b76ec7449aca258a39ffd613dbd078c51d19c9f03"
|
||||
"sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7",
|
||||
"sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.1.4"
|
||||
"version": "==3.1.5"
|
||||
},
|
||||
"django-debug-toolbar": {
|
||||
"hashes": [
|
||||
@ -1392,10 +1418,10 @@
|
||||
},
|
||||
"gitpython": {
|
||||
"hashes": [
|
||||
"sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b",
|
||||
"sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8"
|
||||
"sha256:42dbefd8d9e2576c496ed0059f3103dcef7125b9ce16f9d5f9c834aed44a1dac",
|
||||
"sha256:867ec3dfb126aac0f8296b19fb63b8c4a399f32b4b6fafe84c4b10af5fa9f7b5"
|
||||
],
|
||||
"version": "==3.1.11"
|
||||
"version": "==3.1.12"
|
||||
},
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
@ -1453,11 +1479,11 @@
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236",
|
||||
"sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"
|
||||
"sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
|
||||
"sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.7"
|
||||
"version": "==20.8"
|
||||
},
|
||||
"pathspec": {
|
||||
"hashes": [
|
||||
@ -1496,10 +1522,10 @@
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
"sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
|
||||
"sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
|
||||
"sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3",
|
||||
"sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"
|
||||
],
|
||||
"version": "==1.9.0"
|
||||
"version": "==1.10.0"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
@ -1566,11 +1592,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 +1608,10 @@
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
|
||||
"sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
|
||||
"sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
|
||||
"sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
|
||||
],
|
||||
"version": "==2020.4"
|
||||
"version": "==2020.5"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
@ -1716,38 +1742,38 @@
|
||||
},
|
||||
"typed-ast": {
|
||||
"hashes": [
|
||||
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
|
||||
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
|
||||
"sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d",
|
||||
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
|
||||
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
|
||||
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
|
||||
"sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c",
|
||||
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
|
||||
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
|
||||
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
|
||||
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
|
||||
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
|
||||
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
|
||||
"sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d",
|
||||
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
|
||||
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
|
||||
"sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c",
|
||||
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
|
||||
"sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395",
|
||||
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
|
||||
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
|
||||
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
|
||||
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
|
||||
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
|
||||
"sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072",
|
||||
"sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298",
|
||||
"sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91",
|
||||
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
|
||||
"sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f",
|
||||
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
|
||||
"sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1",
|
||||
"sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d",
|
||||
"sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6",
|
||||
"sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd",
|
||||
"sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37",
|
||||
"sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151",
|
||||
"sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07",
|
||||
"sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440",
|
||||
"sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70",
|
||||
"sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496",
|
||||
"sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea",
|
||||
"sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400",
|
||||
"sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc",
|
||||
"sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606",
|
||||
"sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc",
|
||||
"sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581",
|
||||
"sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412",
|
||||
"sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a",
|
||||
"sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2",
|
||||
"sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787",
|
||||
"sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f",
|
||||
"sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937",
|
||||
"sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64",
|
||||
"sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487",
|
||||
"sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b",
|
||||
"sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41",
|
||||
"sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a",
|
||||
"sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3",
|
||||
"sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166",
|
||||
"sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"
|
||||
],
|
||||
"version": "==1.4.1"
|
||||
"version": "==1.4.2"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
|
@ -1,4 +1,4 @@
|
||||
<img src="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
|
||||
|
||||
|
12
SECURITY.md
12
SECURITY.md
@ -2,13 +2,11 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
As authentik is currently in a pre-stable, only the latest "stable" version is supported. After authentik 1.0, this will change.
|
||||
|
||||
| Version | Supported |
|
||||
| -------- | ------------------ |
|
||||
| 0.10.x | :white_check_mark: |
|
||||
| 0.11.x | :white_check_mark: |
|
||||
| 0.12.x | :white_check_mark: |
|
||||
| Version | Supported |
|
||||
| ---------- | ------------------ |
|
||||
| 0.13.x | :white_check_mark: |
|
||||
| 0.14.x | :white_check_mark: |
|
||||
| 2021.1.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
@ -1,2 +1,2 @@
|
||||
"""authentik"""
|
||||
__version__ = "0.13.0-rc1"
|
||||
__version__ = "2021.1.1-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 structlog.stdlib 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>
|
||||
|
@ -38,7 +38,7 @@
|
||||
{% for task in object_list %}
|
||||
<tr role="row">
|
||||
<th role="columnheader">
|
||||
<pre>{{ task.task_name }}</pre>
|
||||
<span>{{ task.html_name|join:"_­" }}</span>
|
||||
</th>
|
||||
<td role="cell">
|
||||
<span>
|
||||
|
@ -2,7 +2,7 @@
|
||||
from django import template
|
||||
from django.db.models import Model
|
||||
from django.utils.html import mark_safe
|
||||
from structlog import get_logger
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
register = template.Library()
|
||||
LOGGER = get_logger()
|
||||
|
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,
|
||||
)
|
||||
)
|
||||
|
78
authentik/admin/tests/test_tasks.py
Normal file
78
authentik/admin/tests/test_tasks.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""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/99999999.9999999"
|
||||
}""",
|
||||
)
|
||||
)
|
||||
|
||||
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), "99999999.9999999")
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.UPDATE_AVAILABLE,
|
||||
context__new_version="99999999.9999999",
|
||||
).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="99999999.9999999",
|
||||
)
|
||||
),
|
||||
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()
|
||||
)
|
@ -4,6 +4,8 @@ from django.urls import path
|
||||
from authentik.admin.views import (
|
||||
applications,
|
||||
certificate_key_pair,
|
||||
events_notifications_rules,
|
||||
events_notifications_transports,
|
||||
flows,
|
||||
groups,
|
||||
outposts,
|
||||
@ -22,6 +24,7 @@ from authentik.admin.views import (
|
||||
tokens,
|
||||
users,
|
||||
)
|
||||
from authentik.providers.saml.views import MetadataImportView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
@ -34,11 +37,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 +119,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(),
|
||||
@ -350,4 +354,36 @@ urlpatterns = [
|
||||
tasks.TaskListView.as_view(),
|
||||
name="tasks",
|
||||
),
|
||||
# Event Notification Transpots
|
||||
path(
|
||||
"events/transports/create/",
|
||||
events_notifications_transports.NotificationTransportCreateView.as_view(),
|
||||
name="notification-transport-create",
|
||||
),
|
||||
path(
|
||||
"events/transports/<uuid:pk>/update/",
|
||||
events_notifications_transports.NotificationTransportUpdateView.as_view(),
|
||||
name="notification-transport-update",
|
||||
),
|
||||
path(
|
||||
"events/transports/<uuid:pk>/delete/",
|
||||
events_notifications_transports.NotificationTransportDeleteView.as_view(),
|
||||
name="notification-transport-delete",
|
||||
),
|
||||
# Event Notification Rules
|
||||
path(
|
||||
"events/rules/create/",
|
||||
events_notifications_rules.NotificationRuleCreateView.as_view(),
|
||||
name="notification-rule-create",
|
||||
),
|
||||
path(
|
||||
"events/rules/<uuid:pk>/update/",
|
||||
events_notifications_rules.NotificationRuleUpdateView.as_view(),
|
||||
name="notification-rule-update",
|
||||
),
|
||||
path(
|
||||
"events/rules/<uuid:pk>/delete/",
|
||||
events_notifications_rules.NotificationRuleDeleteView.as_view(),
|
||||
name="notification-rule-delete",
|
||||
),
|
||||
]
|
||||
|
@ -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")
|
||||
|
64
authentik/admin/views/events_notifications_rules.py
Normal file
64
authentik/admin/views/events_notifications_rules.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""authentik NotificationRule administration"""
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.mixins import (
|
||||
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import UpdateView
|
||||
from guardian.mixins import PermissionRequiredMixin
|
||||
|
||||
from authentik.admin.views.utils import BackSuccessUrlMixin, DeleteMessageView
|
||||
from authentik.events.forms import NotificationRuleForm
|
||||
from authentik.events.models import NotificationRule
|
||||
from authentik.lib.views import CreateAssignPermView
|
||||
|
||||
|
||||
class NotificationRuleCreateView(
|
||||
SuccessMessageMixin,
|
||||
BackSuccessUrlMixin,
|
||||
LoginRequiredMixin,
|
||||
DjangoPermissionRequiredMixin,
|
||||
CreateAssignPermView,
|
||||
):
|
||||
"""Create new NotificationRule"""
|
||||
|
||||
model = NotificationRule
|
||||
form_class = NotificationRuleForm
|
||||
permission_required = "authentik_events.add_NotificationRule"
|
||||
|
||||
template_name = "generic/create.html"
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully created Notification Rule")
|
||||
|
||||
|
||||
class NotificationRuleUpdateView(
|
||||
SuccessMessageMixin,
|
||||
BackSuccessUrlMixin,
|
||||
LoginRequiredMixin,
|
||||
PermissionRequiredMixin,
|
||||
UpdateView,
|
||||
):
|
||||
"""Update application"""
|
||||
|
||||
model = NotificationRule
|
||||
form_class = NotificationRuleForm
|
||||
permission_required = "authentik_events.change_NotificationRule"
|
||||
|
||||
template_name = "generic/update.html"
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully updated Notification Rule")
|
||||
|
||||
|
||||
class NotificationRuleDeleteView(
|
||||
LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView
|
||||
):
|
||||
"""Delete application"""
|
||||
|
||||
model = NotificationRule
|
||||
permission_required = "authentik_events.delete_NotificationRule"
|
||||
|
||||
template_name = "generic/delete.html"
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully deleted Notification Rule")
|
64
authentik/admin/views/events_notifications_transports.py
Normal file
64
authentik/admin/views/events_notifications_transports.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""authentik NotificationTransport administration"""
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.mixins import (
|
||||
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import UpdateView
|
||||
from guardian.mixins import PermissionRequiredMixin
|
||||
|
||||
from authentik.admin.views.utils import BackSuccessUrlMixin, DeleteMessageView
|
||||
from authentik.events.forms import NotificationTransportForm
|
||||
from authentik.events.models import NotificationTransport
|
||||
from authentik.lib.views import CreateAssignPermView
|
||||
|
||||
|
||||
class NotificationTransportCreateView(
|
||||
SuccessMessageMixin,
|
||||
BackSuccessUrlMixin,
|
||||
LoginRequiredMixin,
|
||||
DjangoPermissionRequiredMixin,
|
||||
CreateAssignPermView,
|
||||
):
|
||||
"""Create new NotificationTransport"""
|
||||
|
||||
model = NotificationTransport
|
||||
form_class = NotificationTransportForm
|
||||
permission_required = "authentik_events.add_notificationtransport"
|
||||
|
||||
template_name = "generic/create.html"
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully created Notification Transport")
|
||||
|
||||
|
||||
class NotificationTransportUpdateView(
|
||||
SuccessMessageMixin,
|
||||
BackSuccessUrlMixin,
|
||||
LoginRequiredMixin,
|
||||
PermissionRequiredMixin,
|
||||
UpdateView,
|
||||
):
|
||||
"""Update application"""
|
||||
|
||||
model = NotificationTransport
|
||||
form_class = NotificationTransportForm
|
||||
permission_required = "authentik_events.change_notificationtransport"
|
||||
|
||||
template_name = "generic/update.html"
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully updated Notification Transport")
|
||||
|
||||
|
||||
class NotificationTransportDeleteView(
|
||||
LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView
|
||||
):
|
||||
"""Delete application"""
|
||||
|
||||
model = NotificationTransport
|
||||
permission_required = "authentik_events.delete_notificationtransport"
|
||||
|
||||
template_name = "generic/delete.html"
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully deleted Notification Transport")
|
@ -1,71 +1,35 @@
|
||||
"""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 structlog import get_logger
|
||||
from django.views.generic import FormView
|
||||
from structlog.stdlib 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
|
||||
from authentik.core.api.applications import user_app_cache_key
|
||||
|
||||
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:
|
||||
keys = cache.keys("policy_*")
|
||||
cache.delete_many(keys)
|
||||
LOGGER.debug("Cleared Policy cache", keys=len(keys))
|
||||
# Also delete user application cache
|
||||
keys = user_app_cache_key("*")
|
||||
cache.delete_many(keys)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
@ -75,7 +39,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,10 +1,11 @@
|
||||
"""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
|
||||
from rest_framework.request import Request
|
||||
from structlog import get_logger
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
|
||||
@ -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,14 +19,28 @@ 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.event import EventViewSet
|
||||
from authentik.events.api.notification import NotificationViewSet
|
||||
from authentik.events.api.notification_rule import NotificationRuleViewSet
|
||||
from authentik.events.api.notification_transport import NotificationTransportViewSet
|
||||
from authentik.flows.api import (
|
||||
FlowCacheViewSet,
|
||||
FlowStageBindingViewSet,
|
||||
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.event_matcher.api import EventMatcherPolicyViewSet
|
||||
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
|
||||
from authentik.policies.expression.api import ExpressionPolicyViewSet
|
||||
from authentik.policies.group_membership.api import GroupMembershipPolicyViewSet
|
||||
@ -63,9 +77,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 +95,15 @@ 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("events/notifications", NotificationViewSet)
|
||||
router.register("events/transports", NotificationTransportViewSet)
|
||||
router.register("events/rules", NotificationRuleViewSet)
|
||||
|
||||
router.register("sources/all", SourceViewSet)
|
||||
router.register("sources/ldap", LDAPSourceViewSet)
|
||||
@ -94,8 +111,10 @@ 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/event_matcher", EventMatcherPolicyViewSet)
|
||||
router.register("policies/group_membership", GroupMembershipPolicyViewSet)
|
||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
|
||||
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
|
||||
@ -137,7 +156,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,199 +0,0 @@
|
||||
"""authentik audit models"""
|
||||
from inspect import getmodule, stack
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from uuid import UUID, 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 (
|
||||
SESSION_IMPERSONATE_ORIGINAL_USER,
|
||||
SESSION_IMPERSONATE_USER,
|
||||
)
|
||||
from authentik.core.models import User
|
||||
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
|
||||
|
||||
|
||||
class EventAction(models.TextChoices):
|
||||
"""All possible actions to save into the audit log"""
|
||||
|
||||
LOGIN = "login"
|
||||
LOGIN_FAILED = "login_failed"
|
||||
LOGOUT = "logout"
|
||||
|
||||
USER_WRITE = "user_write"
|
||||
SUSPICIOUS_REQUEST = "suspicious_request"
|
||||
PASSWORD_SET = "password_set" # noqa # nosec
|
||||
|
||||
TOKEN_VIEW = "token_view" # nosec
|
||||
|
||||
INVITE_CREATED = "invitation_created"
|
||||
INVITE_USED = "invitation_used"
|
||||
|
||||
AUTHORIZE_APPLICATION = "authorize_application"
|
||||
SOURCE_LINKED = "source_linked"
|
||||
|
||||
IMPERSONATION_STARTED = "impersonation_started"
|
||||
IMPERSONATION_ENDED = "impersonation_ended"
|
||||
|
||||
MODEL_CREATED = "model_created"
|
||||
MODEL_UPDATED = "model_updated"
|
||||
MODEL_DELETED = "model_deleted"
|
||||
|
||||
CUSTOM_PREFIX = "custom_"
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
"""An individual audit log event"""
|
||||
|
||||
event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
user = models.JSONField(default=dict)
|
||||
action = models.TextField(choices=EventAction.choices)
|
||||
app = models.TextField()
|
||||
context = models.JSONField(default=dict, blank=True)
|
||||
client_ip = models.GenericIPAddressField(null=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@staticmethod
|
||||
def _get_app_from_request(request: HttpRequest) -> str:
|
||||
if not isinstance(request, HttpRequest):
|
||||
return ""
|
||||
return request.resolver_match.app_name
|
||||
|
||||
@staticmethod
|
||||
def new(
|
||||
action: Union[str, EventAction],
|
||||
app: Optional[str] = None,
|
||||
_inspect_offset: int = 1,
|
||||
**kwargs,
|
||||
) -> "Event":
|
||||
"""Create new Event instance from arguments. Instance is NOT saved."""
|
||||
if not isinstance(action, EventAction):
|
||||
action = EventAction.CUSTOM_PREFIX + action
|
||||
if not app:
|
||||
app = getmodule(stack()[_inspect_offset][0]).__name__
|
||||
cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
|
||||
event = Event(action=action, app=app, context=cleaned_kwargs)
|
||||
return event
|
||||
|
||||
def from_http(
|
||||
self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None
|
||||
) -> "Event":
|
||||
"""Add data from a Django-HttpRequest, allowing the creation of
|
||||
Events independently from requests.
|
||||
`user` arguments optionally overrides user from requests."""
|
||||
if hasattr(request, "user"):
|
||||
self.user = get_user(
|
||||
request.user,
|
||||
request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None),
|
||||
)
|
||||
if user:
|
||||
self.user = get_user(user)
|
||||
# Check if we're currently impersonating, and add that user
|
||||
if hasattr(request, "session"):
|
||||
if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
|
||||
self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER])
|
||||
self.user["on_behalf_of"] = get_user(
|
||||
request.session[SESSION_IMPERSONATE_USER]
|
||||
)
|
||||
# User 255.255.255.255 as fallback if IP cannot be determined
|
||||
self.client_ip = get_client_ip(request) or "255.255.255.255"
|
||||
# If there's no app set, we get it from the requests too
|
||||
if not self.app:
|
||||
self.app = Event._get_app_from_request(request)
|
||||
self.save()
|
||||
return self
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self._state.adding:
|
||||
raise ValidationError(
|
||||
"you may not edit an existing %s" % self._meta.model_name
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Created Audit event",
|
||||
action=self.action,
|
||||
context=self.context,
|
||||
client_ip=self.client_ip,
|
||||
user=self.user,
|
||||
)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Audit Event")
|
||||
verbose_name_plural = _("Audit Events")
|
@ -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",
|
||||
]
|
@ -4,7 +4,7 @@ from django.apps import AppConfig, apps
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.sites import AlreadyRegistered
|
||||
from guardian.admin import GuardedModelAdmin
|
||||
from structlog import get_logger
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
@ -1,23 +1,34 @@
|
||||
"""Application API Views"""
|
||||
from django.core.cache import cache
|
||||
from django.db.models import QuerySet
|
||||
from django.http.response import Http404
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from 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
|
||||
|
||||
|
||||
def user_app_cache_key(user_pk: str) -> str:
|
||||
"""Cache key where application list for user is saved"""
|
||||
return f"user_app_cache_{user_pk}"
|
||||
|
||||
|
||||
class ApplicationSerializer(ModelSerializer):
|
||||
"""Application Serializer"""
|
||||
|
||||
launch_url = SerializerMethodField()
|
||||
provider = ProviderSerializer(source="get_provider", required=False)
|
||||
|
||||
def get_launch_url(self, instance: Application) -> str:
|
||||
"""Get generated launch URL"""
|
||||
@ -45,7 +56,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"""
|
||||
@ -59,20 +78,31 @@ class ApplicationViewSet(ModelViewSet):
|
||||
"""Custom list method that checks Policy based access instead of guardian"""
|
||||
queryset = self._filter_queryset_for_list(self.get_queryset())
|
||||
self.paginate_queryset(queryset)
|
||||
allowed_applications = []
|
||||
for application in queryset.order_by("name"):
|
||||
engine = PolicyEngine(application, self.request.user, self.request)
|
||||
engine.build()
|
||||
if engine.passing:
|
||||
allowed_applications.append(application)
|
||||
allowed_applications = cache.get(user_app_cache_key(self.request.user.pk))
|
||||
if not allowed_applications:
|
||||
allowed_applications = []
|
||||
for application in queryset:
|
||||
engine = PolicyEngine(application, self.request.user, self.request)
|
||||
engine.build()
|
||||
if engine.passing:
|
||||
allowed_applications.append(application)
|
||||
cache.set(
|
||||
user_app_cache_key(self.request.user.pk),
|
||||
allowed_applications,
|
||||
timeout=86400,
|
||||
)
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@action(detail=True)
|
||||
def metrics(self, request: Request, slug: str):
|
||||
"""Metrics for application logins"""
|
||||
# TODO: Check app read and audit read perms
|
||||
app = Application.objects.get(slug=slug)
|
||||
app = get_object_or_404(
|
||||
get_objects_for_user(request.user, "authentik_core.view_application"),
|
||||
slug=slug,
|
||||
)
|
||||
if not request.user.has_perm("authentik_events.view_event"):
|
||||
raise Http404
|
||||
return Response(
|
||||
get_events_per_1h(
|
||||
action=EventAction.AUTHORIZE_APPLICATION,
|
||||
|
@ -23,7 +23,7 @@ class PropertyMappingSerializer(ModelSerializer):
|
||||
class PropertyMappingViewSet(ReadOnlyModelViewSet):
|
||||
"""PropertyMapping Viewset"""
|
||||
|
||||
queryset = PropertyMapping.objects.all()
|
||||
queryset = PropertyMapping.objects.none()
|
||||
serializer_class = PropertyMappingSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
@ -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()
|
||||
queryset = Provider.objects.none()
|
||||
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")
|
||||
@ -30,7 +31,7 @@ class SourceSerializer(ModelSerializer):
|
||||
class SourceViewSet(ReadOnlyModelViewSet):
|
||||
"""Source Viewset"""
|
||||
|
||||
queryset = Source.objects.all()
|
||||
queryset = Source.objects.none()
|
||||
serializer_class = SourceSerializer
|
||||
lookup_field = "slug"
|
||||
|
||||
|
@ -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
|
||||
@ -33,9 +34,12 @@ class UserSerializer(ModelSerializer):
|
||||
class UserViewSet(ModelViewSet):
|
||||
"""User Viewset"""
|
||||
|
||||
queryset = User.objects.all()
|
||||
queryset = User.objects.none()
|
||||
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,6 +1,7 @@
|
||||
"""Channels base classes"""
|
||||
from channels.exceptions import DenyConnection
|
||||
from channels.generic.websocket import JsonWebsocketConsumer
|
||||
from structlog import get_logger
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.auth import token_from_header
|
||||
from authentik.core.models import User
|
||||
@ -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,
|
||||
message=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,7 +14,8 @@ 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 structlog import get_logger
|
||||
from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.signals import password_changed
|
||||
@ -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
|
||||
|
||||
|
@ -8,7 +8,7 @@ from dbbackup.db.exceptions import CommandConnectorError
|
||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||
from django.core import management
|
||||
from django.utils.timezone import now
|
||||
from structlog import get_logger
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import ExpiringModel
|
||||
from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
|
@ -6,19 +6,17 @@
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preload" href="{% static 'dist/assets/fonts/DINEngschriftStd.woff2' %}" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{% static 'dist/assets/fonts/DINEngschriftStd.woff' %}" as="font" type="font/woff" crossorigin>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>{% block title %}{% trans title|default:config.authentik.branding.title %}{% endblock %}</title>
|
||||
<link rel="icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}">
|
||||
<link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-addons.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/fontawesome.min.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||
<script src="{% url 'javascript-catalog' %}"></script>
|
||||
<script src="{% static 'dist/main.js' %}" type="module"></script>
|
||||
<link rel="icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}">
|
||||
<link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.css' %}?v={{ ak_version }}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-addons.css' %}?v={{ ak_version }}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/fontawesome.min.css' %}?v={{ ak_version }}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}">
|
||||
<script src="{% url 'javascript-catalog' %}?v={{ ak_version }}"></script>
|
||||
<script src="{% static 'dist/main.js' %}?v={{ ak_version }}" type="module"></script>
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
@ -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>
|
@ -1,5 +1,6 @@
|
||||
{% load i18n %}
|
||||
{% load authentik_user_settings %}
|
||||
{% load authentik_utils %}
|
||||
|
||||
<div class="pf-c-page">
|
||||
<main role="main" class="pf-c-page__main" tabindex="-1">
|
||||
@ -12,67 +13,45 @@
|
||||
<p>{% trans "Configure settings relevant to your user profile." %}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pf-c-page__main-section">
|
||||
<div class="pf-u-display-flex pf-u-justify-content-center">
|
||||
<div class="pf-u-w-75">
|
||||
<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>
|
||||
<ak-tabs>
|
||||
<section slot="page-1" data-tab-title="{% trans 'User details' %}" class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
<div class="pf-u-display-flex pf-u-justify-content-center">
|
||||
<div class="pf-u-w-75">
|
||||
<ak-site-shell url="{% url 'authentik_core:user-details' %}">
|
||||
<div slot="body"></div>
|
||||
</ak-site-shell>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pf-c-page__main-section">
|
||||
<div class="pf-u-display-flex pf-u-justify-content-center">
|
||||
<div class="pf-u-w-75">
|
||||
<ak-site-shell url="{% url 'authentik_core:user-tokens' %}">
|
||||
<div slot="body"></div>
|
||||
</ak-site-shell>
|
||||
</section>
|
||||
<section slot="page-2" data-tab-title="{% trans 'Tokens' %}" class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
<ak-site-shell url="{% url 'authentik_core:user-tokens' %}">
|
||||
<div slot="body"></div>
|
||||
</ak-site-shell>
|
||||
</section>
|
||||
{% user_stages as user_stages_loc %}
|
||||
{% for stage, stage_link in user_stages_loc.items %}
|
||||
<section slot="page-{{ stage.pk }}" data-tab-title="{{ stage|verbose_name }}" class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
<div class="pf-u-display-flex pf-u-justify-content-center">
|
||||
<div class="pf-u-w-75">
|
||||
<ak-site-shell url="{{ stage_link }}">
|
||||
<div slot="body"></div>
|
||||
</ak-site-shell>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% user_stages as user_stages_loc %}
|
||||
{% for stage in user_stages_loc %}
|
||||
<section class="pf-c-page__main-section">
|
||||
<div class="pf-u-display-flex pf-u-justify-content-center">
|
||||
<div class="pf-u-w-75">
|
||||
<ak-site-shell url="{{ stage }}">
|
||||
<div slot="body"></div>
|
||||
</ak-site-shell>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% user_sources as user_sources_loc %}
|
||||
{% for source, source_link in user_sources_loc.item %}
|
||||
<section slot="page-{{ source.pk }}" data-tab-title="{{ source|verbose_name }}" class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
<div class="pf-u-display-flex pf-u-justify-content-center">
|
||||
<div class="pf-u-w-75">
|
||||
<ak-site-shell url="{{ source_link }}">
|
||||
<div slot="body"></div>
|
||||
</ak-site-shell>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% user_sources as user_sources_loc %}
|
||||
{% for source in user_sources_loc %}
|
||||
<section class="pf-c-page__main-section">
|
||||
<div class="pf-u-display-flex pf-u-justify-content-center">
|
||||
<div class="pf-u-w-75">
|
||||
<ak-site-shell url="{{ source }}">
|
||||
<div slot="body"></div>
|
||||
</ak-site-shell>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
</ak-tabs>
|
||||
</main>
|
||||
</div>
|
||||
|
@ -13,26 +13,26 @@ register = template.Library()
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
# pylint: disable=unused-argument
|
||||
def user_stages(context: RequestContext) -> list[str]:
|
||||
def user_stages(context: RequestContext) -> dict[Stage, str]:
|
||||
"""Return list of all stages which apply to user"""
|
||||
_all_stages: Iterable[Stage] = Stage.objects.all().select_subclasses()
|
||||
matching_stages: list[str] = []
|
||||
matching_stages: dict[Stage, str] = {}
|
||||
for stage in _all_stages:
|
||||
user_settings = stage.ui_user_settings
|
||||
if not user_settings:
|
||||
continue
|
||||
matching_stages.append(user_settings)
|
||||
matching_stages[stage] = user_settings
|
||||
return matching_stages
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def user_sources(context: RequestContext) -> list[str]:
|
||||
def user_sources(context: RequestContext) -> dict[Source, str]:
|
||||
"""Return a list of all sources which are enabled for the user"""
|
||||
user = context.get("request").user
|
||||
_all_sources: Iterable[Source] = Source.objects.filter(
|
||||
enabled=True
|
||||
).select_subclasses()
|
||||
matching_sources: list[str] = []
|
||||
matching_sources: dict[Source, str] = {}
|
||||
for source in _all_sources:
|
||||
user_settings = source.ui_user_settings
|
||||
if not user_settings:
|
||||
@ -40,5 +40,5 @@ def user_sources(context: RequestContext) -> list[str]:
|
||||
policy_engine = PolicyEngine(source, user, context.get("request"))
|
||||
policy_engine.build()
|
||||
if policy_engine.passing:
|
||||
matching_sources.append(user_settings)
|
||||
matching_sources[source] = user_settings
|
||||
return matching_sources
|
||||
|
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>/",
|
||||
|
@ -3,14 +3,14 @@
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.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,16 +22,15 @@ 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
|
||||
try:
|
||||
load_pem_private_key(
|
||||
str.encode("\n".join([x.strip() for x in value.split("\n")])),
|
||||
password=None,
|
||||
backend=default_backend(),
|
||||
)
|
||||
except ValueError:
|
||||
raise ValidationError("Unable to load private key.")
|
||||
if value != "":
|
||||
try:
|
||||
load_pem_private_key(
|
||||
str.encode("\n".join([x.strip() for x in value.split("\n")])),
|
||||
password=None,
|
||||
backend=default_backend(),
|
||||
)
|
||||
except ValueError:
|
||||
raise ValidationError("Unable to load private key.")
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
|
@ -26,16 +26,15 @@ 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
|
||||
try:
|
||||
load_pem_private_key(
|
||||
str.encode("\n".join([x.strip() for x in key_data.split("\n")])),
|
||||
password=None,
|
||||
backend=default_backend(),
|
||||
)
|
||||
except ValueError:
|
||||
raise forms.ValidationError("Unable to load private key.")
|
||||
if key_data != "":
|
||||
try:
|
||||
load_pem_private_key(
|
||||
str.encode("\n".join([x.strip() for x in key_data.split("\n")])),
|
||||
password=None,
|
||||
backend=default_backend(),
|
||||
)
|
||||
except ValueError:
|
||||
raise forms.ValidationError("Unable to load private key.")
|
||||
return key_data
|
||||
|
||||
class Meta:
|
||||
|
@ -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)}
|
53
authentik/events/api/notification.py
Normal file
53
authentik/events/api/notification.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Notification API Views"""
|
||||
from rest_framework import mixins
|
||||
from rest_framework.fields import ReadOnlyField
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.events.api.event import EventSerializer
|
||||
from authentik.events.models import Notification
|
||||
|
||||
|
||||
class NotificationSerializer(ModelSerializer):
|
||||
"""Notification Serializer"""
|
||||
|
||||
body = ReadOnlyField()
|
||||
severity = ReadOnlyField()
|
||||
event = EventSerializer()
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Notification
|
||||
fields = [
|
||||
"pk",
|
||||
"severity",
|
||||
"body",
|
||||
"created",
|
||||
"event",
|
||||
"seen",
|
||||
]
|
||||
|
||||
|
||||
class NotificationViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
GenericViewSet,
|
||||
):
|
||||
"""Notification Viewset"""
|
||||
|
||||
queryset = Notification.objects.all()
|
||||
serializer_class = NotificationSerializer
|
||||
filterset_fields = [
|
||||
"severity",
|
||||
"body",
|
||||
"created",
|
||||
"event",
|
||||
"seen",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
if not self.request:
|
||||
return super().get_queryset()
|
||||
return Notification.objects.filter(user=self.request.user)
|
28
authentik/events/api/notification_rule.py
Normal file
28
authentik/events/api/notification_rule.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""NotificationRule API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.events.models import NotificationRule
|
||||
|
||||
|
||||
class NotificationRuleSerializer(ModelSerializer):
|
||||
"""NotificationRule Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = NotificationRule
|
||||
depth = 2
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"transports",
|
||||
"severity",
|
||||
"group",
|
||||
]
|
||||
|
||||
|
||||
class NotificationRuleViewSet(ModelViewSet):
|
||||
"""NotificationRule Viewset"""
|
||||
|
||||
queryset = NotificationRule.objects.all()
|
||||
serializer_class = NotificationRuleSerializer
|
66
authentik/events/api/notification_transport.py
Normal file
66
authentik/events/api/notification_transport.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""NotificationTransport API Views"""
|
||||
from django.http.response import Http404
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.events.models import (
|
||||
Notification,
|
||||
NotificationSeverity,
|
||||
NotificationTransport,
|
||||
NotificationTransportError,
|
||||
TransportMode,
|
||||
)
|
||||
|
||||
|
||||
class NotificationTransportSerializer(ModelSerializer):
|
||||
"""NotificationTransport Serializer"""
|
||||
|
||||
mode_verbose = SerializerMethodField()
|
||||
|
||||
def get_mode_verbose(self, instance: NotificationTransport):
|
||||
"""Return selected mode with a UI Label"""
|
||||
return TransportMode(instance.mode).label
|
||||
|
||||
class Meta:
|
||||
|
||||
model = NotificationTransport
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"mode",
|
||||
"mode_verbose",
|
||||
"webhook_url",
|
||||
]
|
||||
|
||||
|
||||
class NotificationTransportViewSet(ModelViewSet):
|
||||
"""NotificationTransport Viewset"""
|
||||
|
||||
queryset = NotificationTransport.objects.all()
|
||||
serializer_class = NotificationTransportSerializer
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
# pylint: disable=invalid-name
|
||||
def test(self, request: Request, pk=None) -> Response:
|
||||
"""Send example notification using selected transport. Requires
|
||||
Modify permissions."""
|
||||
transports = get_objects_for_user(
|
||||
request.user, "authentik_events.change_notificationtransport"
|
||||
).filter(pk=pk)
|
||||
if not transports.exists():
|
||||
raise Http404
|
||||
transport: NotificationTransport = transports.first()
|
||||
notification = Notification(
|
||||
severity=NotificationSeverity.NOTICE,
|
||||
body=f"Test Notification from transport {transport.name}",
|
||||
user=request.user,
|
||||
)
|
||||
try:
|
||||
return Response(transport.send(notification))
|
||||
except NotificationTransportError as exc:
|
||||
return Response(str(exc.__cause__ or None), status=503)
|
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")
|
47
authentik/events/forms.py
Normal file
47
authentik/events/forms.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""authentik events NotificationTransport forms"""
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.events.models import NotificationRule, NotificationTransport
|
||||
|
||||
|
||||
class NotificationTransportForm(forms.ModelForm):
|
||||
"""NotificationTransport Form"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = NotificationTransport
|
||||
fields = [
|
||||
"name",
|
||||
"mode",
|
||||
"webhook_url",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"webhook_url": forms.TextInput(),
|
||||
}
|
||||
labels = {
|
||||
"webhook_url": _("Webhook URL"),
|
||||
}
|
||||
help_texts = {
|
||||
"webhook_url": _(
|
||||
("Only required when the Generic or Slack Webhook is used.")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class NotificationRuleForm(forms.ModelForm):
|
||||
"""NotificationRule Form"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = NotificationRule
|
||||
fields = [
|
||||
"name",
|
||||
"group",
|
||||
"transports",
|
||||
"severity",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
"""Audit middleware"""
|
||||
"""Events middleware"""
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
|
||||
@ -6,10 +6,12 @@ from django.contrib.auth.models import User
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from guardian.models import UserObjectPermission
|
||||
|
||||
from authentik.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, Notification
|
||||
from authentik.events.signals import EventNewThread
|
||||
from authentik.events.utils import model_to_dict
|
||||
|
||||
|
||||
class AuditMiddleware:
|
||||
@ -62,7 +64,7 @@ class AuditMiddleware:
|
||||
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
||||
):
|
||||
"""Signal handler for all object's post_save"""
|
||||
if isinstance(instance, Event):
|
||||
if isinstance(instance, (Event, Notification, UserObjectPermission)):
|
||||
return
|
||||
|
||||
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
|
||||
@ -74,7 +76,7 @@ class AuditMiddleware:
|
||||
user: User, request: HttpRequest, sender, instance: Model, **_
|
||||
):
|
||||
"""Signal handler for all object's pre_delete"""
|
||||
if isinstance(instance, Event):
|
||||
if isinstance(instance, (Event, Notification, UserObjectPermission)):
|
||||
return
|
||||
|
||||
EventNewThread(
|
@ -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"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,148 @@
|
||||
# Generated by Django 3.1.4 on 2021-01-11 16:36
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("authentik_policies", "0004_policy_execution_logging"),
|
||||
("authentik_core", "0016_auto_20201202_2234"),
|
||||
("authentik_events", "0009_auto_20201227_1210"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="NotificationTransport",
|
||||
fields=[
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("name", models.TextField(unique=True)),
|
||||
(
|
||||
"mode",
|
||||
models.TextField(
|
||||
choices=[
|
||||
("webhook", "Generic Webhook"),
|
||||
("webhook_slack", "Slack Webhook (Slack/Discord)"),
|
||||
("email", "Email"),
|
||||
]
|
||||
),
|
||||
),
|
||||
("webhook_url", models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Notification Transport",
|
||||
"verbose_name_plural": "Notification Transports",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NotificationRule",
|
||||
fields=[
|
||||
(
|
||||
"policybindingmodel_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_policies.policybindingmodel",
|
||||
),
|
||||
),
|
||||
("name", models.TextField(unique=True)),
|
||||
(
|
||||
"severity",
|
||||
models.TextField(
|
||||
choices=[
|
||||
("notice", "Notice"),
|
||||
("warning", "Warning"),
|
||||
("alert", "Alert"),
|
||||
],
|
||||
default="notice",
|
||||
help_text="Controls which severity level the created notifications will have.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
(
|
||||
"transports",
|
||||
models.ManyToManyField(
|
||||
help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.",
|
||||
to="authentik_events.NotificationTransport",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Notification Rule",
|
||||
"verbose_name_plural": "Notification Rules",
|
||||
},
|
||||
bases=("authentik_policies.policybindingmodel",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Notification",
|
||||
fields=[
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"severity",
|
||||
models.TextField(
|
||||
choices=[
|
||||
("notice", "Notice"),
|
||||
("warning", "Warning"),
|
||||
("alert", "Alert"),
|
||||
]
|
||||
),
|
||||
),
|
||||
("body", models.TextField()),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("seen", models.BooleanField(default=False)),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="authentik_events.event",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Notification",
|
||||
"verbose_name_plural": "Notifications",
|
||||
},
|
||||
),
|
||||
]
|
@ -0,0 +1,165 @@
|
||||
# Generated by Django 3.1.4 on 2021-01-10 18:57
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from authentik.events.models import EventAction, NotificationSeverity, TransportMode
|
||||
|
||||
|
||||
def notify_configuration_error(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
Group = apps.get_model("authentik_core", "Group")
|
||||
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
|
||||
EventMatcherPolicy = apps.get_model(
|
||||
"authentik_policies_event_matcher", "EventMatcherPolicy"
|
||||
)
|
||||
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
|
||||
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
|
||||
|
||||
admin_group = (
|
||||
Group.objects.using(db_alias)
|
||||
.filter(name="authentik Admins", is_superuser=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
|
||||
name="default-match-configuration-error",
|
||||
defaults={"action": EventAction.CONFIGURATION_ERROR},
|
||||
)
|
||||
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
|
||||
name="default-notify-configuration-error",
|
||||
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
|
||||
)
|
||||
trigger.transports.set(
|
||||
NotificationTransport.objects.using(db_alias).filter(
|
||||
name="default-email-transport"
|
||||
)
|
||||
)
|
||||
trigger.save()
|
||||
PolicyBinding.objects.using(db_alias).update_or_create(
|
||||
target=trigger,
|
||||
policy=policy,
|
||||
defaults={
|
||||
"order": 0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_update(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
Group = apps.get_model("authentik_core", "Group")
|
||||
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
|
||||
EventMatcherPolicy = apps.get_model(
|
||||
"authentik_policies_event_matcher", "EventMatcherPolicy"
|
||||
)
|
||||
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
|
||||
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
|
||||
|
||||
admin_group = (
|
||||
Group.objects.using(db_alias)
|
||||
.filter(name="authentik Admins", is_superuser=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
|
||||
name="default-match-update",
|
||||
defaults={"action": EventAction.UPDATE_AVAILABLE},
|
||||
)
|
||||
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
|
||||
name="default-notify-update",
|
||||
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
|
||||
)
|
||||
trigger.transports.set(
|
||||
NotificationTransport.objects.using(db_alias).filter(
|
||||
name="default-email-transport"
|
||||
)
|
||||
)
|
||||
trigger.save()
|
||||
PolicyBinding.objects.using(db_alias).update_or_create(
|
||||
target=trigger,
|
||||
policy=policy,
|
||||
defaults={
|
||||
"order": 0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_exception(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
Group = apps.get_model("authentik_core", "Group")
|
||||
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
|
||||
EventMatcherPolicy = apps.get_model(
|
||||
"authentik_policies_event_matcher", "EventMatcherPolicy"
|
||||
)
|
||||
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
|
||||
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
|
||||
|
||||
admin_group = (
|
||||
Group.objects.using(db_alias)
|
||||
.filter(name="authentik Admins", is_superuser=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
policy_policy_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
|
||||
name="default-match-policy-exception",
|
||||
defaults={"action": EventAction.POLICY_EXCEPTION},
|
||||
)
|
||||
policy_pm_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
|
||||
name="default-match-property-mapping-exception",
|
||||
defaults={"action": EventAction.PROPERTY_MAPPING_EXCEPTION},
|
||||
)
|
||||
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
|
||||
name="default-notify-exception",
|
||||
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
|
||||
)
|
||||
trigger.transports.set(
|
||||
NotificationTransport.objects.using(db_alias).filter(
|
||||
name="default-email-transport"
|
||||
)
|
||||
)
|
||||
trigger.save()
|
||||
PolicyBinding.objects.using(db_alias).update_or_create(
|
||||
target=trigger,
|
||||
policy=policy_policy_exc,
|
||||
defaults={
|
||||
"order": 0,
|
||||
},
|
||||
)
|
||||
PolicyBinding.objects.using(db_alias).update_or_create(
|
||||
target=trigger,
|
||||
policy=policy_pm_exc,
|
||||
defaults={
|
||||
"order": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def transport_email_global(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
|
||||
|
||||
NotificationTransport.objects.using(db_alias).update_or_create(
|
||||
name="default-email-transport",
|
||||
defaults={"mode": TransportMode.EMAIL},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_events",
|
||||
"0010_notification_notificationtransport_notificationrule",
|
||||
),
|
||||
("authentik_core", "0016_auto_20201202_2234"),
|
||||
("authentik_policies_event_matcher", "0003_auto_20210110_1907"),
|
||||
("authentik_policies", "0004_policy_execution_logging"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(transport_email_global),
|
||||
migrations.RunPython(notify_configuration_error),
|
||||
migrations.RunPython(notify_update),
|
||||
migrations.RunPython(notify_exception),
|
||||
]
|
365
authentik/events/models.py
Normal file
365
authentik/events/models.py
Normal file
@ -0,0 +1,365 @@
|
||||
"""authentik events models"""
|
||||
from inspect import getmodule, stack
|
||||
from smtplib import SMTPException
|
||||
from typing import Optional, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from requests import RequestException, post
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.core.middleware import (
|
||||
SESSION_IMPERSONATE_ORIGINAL_USER,
|
||||
SESSION_IMPERSONATE_USER,
|
||||
)
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.events.utils import cleanse_dict, get_user, sanitize_dict
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.stages.email.tasks import send_mail
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
LOGGER = get_logger("authentik.events")
|
||||
|
||||
|
||||
class NotificationTransportError(SentryIgnoredException):
|
||||
"""Error raised when a notification fails to be delivered"""
|
||||
|
||||
|
||||
class EventAction(models.TextChoices):
|
||||
"""All possible actions to save into the events log"""
|
||||
|
||||
LOGIN = "login"
|
||||
LOGIN_FAILED = "login_failed"
|
||||
LOGOUT = "logout"
|
||||
|
||||
USER_WRITE = "user_write"
|
||||
SUSPICIOUS_REQUEST = "suspicious_request"
|
||||
PASSWORD_SET = "password_set" # noqa # nosec
|
||||
|
||||
TOKEN_VIEW = "token_view" # nosec
|
||||
|
||||
INVITE_USED = "invitation_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_PREFIX = "custom_"
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
"""An individual Audit/Metrics/Notification/Error Event"""
|
||||
|
||||
event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
user = models.JSONField(default=dict)
|
||||
action = models.TextField(choices=EventAction.choices)
|
||||
app = models.TextField()
|
||||
context = models.JSONField(default=dict, blank=True)
|
||||
client_ip = models.GenericIPAddressField(null=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@staticmethod
|
||||
def _get_app_from_request(request: HttpRequest) -> str:
|
||||
if not isinstance(request, HttpRequest):
|
||||
return ""
|
||||
return request.resolver_match.app_name
|
||||
|
||||
@staticmethod
|
||||
def new(
|
||||
action: Union[str, EventAction],
|
||||
app: Optional[str] = None,
|
||||
_inspect_offset: int = 1,
|
||||
**kwargs,
|
||||
) -> "Event":
|
||||
"""Create new Event instance from arguments. Instance is NOT saved."""
|
||||
if not isinstance(action, EventAction):
|
||||
action = EventAction.CUSTOM_PREFIX + action
|
||||
if not app:
|
||||
app = getmodule(stack()[_inspect_offset][0]).__name__
|
||||
cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
|
||||
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":
|
||||
"""Add data from a Django-HttpRequest, allowing the creation of
|
||||
Events independently from requests.
|
||||
`user` arguments optionally overrides user from requests."""
|
||||
if hasattr(request, "user"):
|
||||
original_user = None
|
||||
if hasattr(request, "session"):
|
||||
original_user = request.session.get(
|
||||
SESSION_IMPERSONATE_ORIGINAL_USER, None
|
||||
)
|
||||
self.user = get_user(request.user, original_user)
|
||||
if user:
|
||||
self.user = get_user(user)
|
||||
# Check if we're currently impersonating, and add that user
|
||||
if hasattr(request, "session"):
|
||||
if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
|
||||
self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER])
|
||||
self.user["on_behalf_of"] = get_user(
|
||||
request.session[SESSION_IMPERSONATE_USER]
|
||||
)
|
||||
# User 255.255.255.255 as fallback if IP cannot be determined
|
||||
self.client_ip = get_client_ip(request) or "255.255.255.255"
|
||||
# If there's no app set, we get it from the requests too
|
||||
if not self.app:
|
||||
self.app = Event._get_app_from_request(request)
|
||||
self.save()
|
||||
return self
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self._state.adding:
|
||||
raise ValidationError("you may not edit an existing Event")
|
||||
LOGGER.debug(
|
||||
"Created Event",
|
||||
action=self.action,
|
||||
context=self.context,
|
||||
client_ip=self.client_ip,
|
||||
user=self.user,
|
||||
)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
"""Return a summary of this event."""
|
||||
if "message" in self.context:
|
||||
return self.context["message"]
|
||||
return f"{self.action}: {self.context}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"<Event action={self.action} user={self.user} context={self.context}>"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Event")
|
||||
verbose_name_plural = _("Events")
|
||||
|
||||
|
||||
class TransportMode(models.TextChoices):
|
||||
"""Modes that a notification transport can send a notification"""
|
||||
|
||||
WEBHOOK = "webhook", _("Generic Webhook")
|
||||
WEBHOOK_SLACK = "webhook_slack", _("Slack Webhook (Slack/Discord)")
|
||||
EMAIL = "email", _("Email")
|
||||
|
||||
|
||||
class NotificationTransport(models.Model):
|
||||
"""Action which is executed when a Rule matches"""
|
||||
|
||||
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
mode = models.TextField(choices=TransportMode.choices)
|
||||
|
||||
webhook_url = models.TextField(blank=True)
|
||||
|
||||
def send(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to user, called from async task"""
|
||||
if self.mode == TransportMode.WEBHOOK:
|
||||
return self.send_webhook(notification)
|
||||
if self.mode == TransportMode.WEBHOOK_SLACK:
|
||||
return self.send_webhook_slack(notification)
|
||||
if self.mode == TransportMode.EMAIL:
|
||||
return self.send_email(notification)
|
||||
raise ValueError(f"Invalid mode {self.mode} set")
|
||||
|
||||
def send_webhook(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to generic webhook"""
|
||||
try:
|
||||
response = post(
|
||||
self.webhook_url,
|
||||
json={
|
||||
"body": notification.body,
|
||||
"severity": notification.severity,
|
||||
"user_email": notification.user.email,
|
||||
"user_username": notification.user.username,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
raise NotificationTransportError(exc.response.text) from exc
|
||||
return [
|
||||
response.status_code,
|
||||
response.text,
|
||||
]
|
||||
|
||||
def send_webhook_slack(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to slack or slack-compatible endpoints"""
|
||||
fields = [
|
||||
{
|
||||
"title": _("Severity"),
|
||||
"value": notification.severity,
|
||||
"short": True,
|
||||
},
|
||||
{
|
||||
"title": _("Dispatched for user"),
|
||||
"value": str(notification.user),
|
||||
"short": True,
|
||||
},
|
||||
]
|
||||
if notification.event:
|
||||
for key, value in notification.event.context.items():
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
# https://birdie0.github.io/discord-webhooks-guide/other/field_limits.html
|
||||
if len(fields) >= 25:
|
||||
continue
|
||||
fields.append({"title": key[:256], "value": value[:1024]})
|
||||
body = {
|
||||
"username": "authentik",
|
||||
"icon_url": "https://goauthentik.io/img/icon.png",
|
||||
"attachments": [
|
||||
{
|
||||
"author_name": "authentik",
|
||||
"author_link": "https://goauthentik.io",
|
||||
"author_icon": "https://goauthentik.io/img/icon.png",
|
||||
"title": notification.body,
|
||||
"color": "#fd4b2d",
|
||||
"fields": fields,
|
||||
"footer": f"authentik v{__version__}",
|
||||
}
|
||||
],
|
||||
}
|
||||
if notification.event:
|
||||
body["attachments"][0]["title"] = notification.event.action
|
||||
body["attachments"][0]["text"] = notification.event.action
|
||||
try:
|
||||
response = post(self.webhook_url, json=body)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
raise NotificationTransportError(exc.response.text) from exc
|
||||
return [
|
||||
response.status_code,
|
||||
response.text,
|
||||
]
|
||||
|
||||
def send_email(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification via global email configuration"""
|
||||
body_trunc = (
|
||||
(notification.body[:75] + "..")
|
||||
if len(notification.body) > 75
|
||||
else notification.body
|
||||
)
|
||||
mail = TemplateEmailMessage(
|
||||
subject=f"authentik Notification: {body_trunc}",
|
||||
template_name="email/setup.html",
|
||||
to=[notification.user.email],
|
||||
template_context={
|
||||
"body": notification.body,
|
||||
},
|
||||
)
|
||||
# Email is sent directly here, as the call to send() should have been from a task.
|
||||
try:
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
return send_mail(mail.__dict__) # pylint: disable=no-value-for-parameter
|
||||
except (SMTPException, ConnectionError) as exc:
|
||||
raise NotificationTransportError from exc
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Notification Transport {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Notification Transport")
|
||||
verbose_name_plural = _("Notification Transports")
|
||||
|
||||
|
||||
class NotificationSeverity(models.TextChoices):
|
||||
"""Severity images that a notification can have"""
|
||||
|
||||
NOTICE = "notice", _("Notice")
|
||||
WARNING = "warning", _("Warning")
|
||||
ALERT = "alert", _("Alert")
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
"""Event Notification"""
|
||||
|
||||
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
severity = models.TextField(choices=NotificationSeverity.choices)
|
||||
body = models.TextField()
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
event = models.ForeignKey(Event, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
seen = models.BooleanField(default=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self) -> str:
|
||||
body_trunc = (self.body[:75] + "..") if len(self.body) > 75 else self.body
|
||||
return f"Notification for user {self.user}: {body_trunc}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Notification")
|
||||
verbose_name_plural = _("Notifications")
|
||||
|
||||
|
||||
class NotificationRule(PolicyBindingModel):
|
||||
"""Decide when to create a Notification based on policies attached to this object."""
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
transports = models.ManyToManyField(
|
||||
NotificationTransport,
|
||||
help_text=_(
|
||||
(
|
||||
"Select which transports should be used to notify the user. If none are "
|
||||
"selected, the notification will only be shown in the authentik UI."
|
||||
)
|
||||
),
|
||||
)
|
||||
severity = models.TextField(
|
||||
choices=NotificationSeverity.choices,
|
||||
default=NotificationSeverity.NOTICE,
|
||||
help_text=_(
|
||||
"Controls which severity level the created notifications will have."
|
||||
),
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
Group,
|
||||
help_text=_(
|
||||
(
|
||||
"Define which group of users this notification should be sent and shown to. "
|
||||
"If left empty, Notification won't ben sent."
|
||||
)
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Notification Rule {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Notification Rule")
|
||||
verbose_name_plural = _("Notification Rules")
|
@ -1,4 +1,4 @@
|
||||
"""authentik audit signal listener"""
|
||||
"""authentik events signal listener"""
|
||||
from threading import Thread
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@ -7,14 +7,18 @@ from django.contrib.auth.signals import (
|
||||
user_logged_out,
|
||||
user_login_failed,
|
||||
)
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.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.events.tasks import event_notification_handler
|
||||
from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan
|
||||
from authentik.flows.views import SESSION_KEY_PLAN
|
||||
from authentik.stages.invitation.models import Invitation
|
||||
from authentik.stages.invitation.signals import invitation_created, invitation_used
|
||||
from authentik.stages.invitation.signals import invitation_used
|
||||
from authentik.stages.user_write.signals import user_write
|
||||
|
||||
|
||||
@ -44,6 +48,11 @@ class EventNewThread(Thread):
|
||||
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
||||
"""Log successful login"""
|
||||
thread = EventNewThread(EventAction.LOGIN, request)
|
||||
if SESSION_KEY_PLAN in request.session:
|
||||
flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
if PLAN_CONTEXT_SOURCE in flow_plan.context:
|
||||
# Login request came from an external source, save it in the context
|
||||
thread.kwargs["using_source"] = flow_plan.context[PLAN_CONTEXT_SOURCE]
|
||||
thread.user = user
|
||||
thread.run()
|
||||
|
||||
@ -79,16 +88,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, **_):
|
||||
@ -105,3 +104,10 @@ def on_password_changed(sender, user: User, password: str, **_):
|
||||
"""Log password change"""
|
||||
thread = EventNewThread(EventAction.PASSWORD_SET, None, user=user)
|
||||
thread.run()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Event)
|
||||
# pylint: disable=unused-argument
|
||||
def event_post_save_notification(sender, instance: Event, **_):
|
||||
"""Start task to check if any policies trigger an notification on this event"""
|
||||
event_notification_handler.delay(instance.event_uuid.hex)
|
99
authentik/events/tasks.py
Normal file
99
authentik/events/tasks.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""Event notification tasks"""
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
Notification,
|
||||
NotificationRule,
|
||||
NotificationTransport,
|
||||
NotificationTransportError,
|
||||
)
|
||||
from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.policies.engine import PolicyEngine, PolicyEngineMode
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def event_notification_handler(event_uuid: str):
|
||||
"""Start task for each trigger definition"""
|
||||
for trigger in NotificationRule.objects.all():
|
||||
event_trigger_handler.apply_async(
|
||||
args=[event_uuid, trigger.name], queue="authentik_events"
|
||||
)
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def event_trigger_handler(event_uuid: str, trigger_name: str):
|
||||
"""Check if policies attached to NotificationRule match event"""
|
||||
event: Event = Event.objects.get(event_uuid=event_uuid)
|
||||
trigger: NotificationRule = NotificationRule.objects.get(name=trigger_name)
|
||||
|
||||
if "policy_uuid" in event.context:
|
||||
policy_uuid = event.context["policy_uuid"]
|
||||
if PolicyBinding.objects.filter(
|
||||
target__in=NotificationRule.objects.all().values_list(
|
||||
"pbm_uuid", flat=True
|
||||
),
|
||||
policy=policy_uuid,
|
||||
).exists():
|
||||
# If policy that caused this event to be created is attached
|
||||
# to *any* NotificationRule, we return early.
|
||||
# This is the most effective way to prevent infinite loops.
|
||||
LOGGER.debug(
|
||||
"e(trigger): attempting to prevent infinite loop", trigger=trigger
|
||||
)
|
||||
return
|
||||
|
||||
if not trigger.group:
|
||||
LOGGER.debug("e(trigger): trigger has no group", trigger=trigger)
|
||||
return
|
||||
|
||||
LOGGER.debug("e(trigger): checking if trigger applies", trigger=trigger)
|
||||
policy_engine = PolicyEngine(trigger, get_anonymous_user())
|
||||
policy_engine.mode = PolicyEngineMode.MODE_OR
|
||||
policy_engine.empty_result = False
|
||||
policy_engine.use_cache = False
|
||||
policy_engine.request.context["event"] = event
|
||||
policy_engine.build()
|
||||
result = policy_engine.result
|
||||
if not result.passing:
|
||||
return
|
||||
|
||||
LOGGER.debug("e(trigger): event trigger matched", trigger=trigger)
|
||||
# Create the notification objects
|
||||
for user in trigger.group.users.all():
|
||||
notification = Notification.objects.create(
|
||||
severity=trigger.severity, body=event.summary, event=event, user=user
|
||||
)
|
||||
|
||||
for transport in trigger.transports.all():
|
||||
notification_transport.apply_async(
|
||||
args=[notification.pk, transport.pk], queue="authentik_events"
|
||||
)
|
||||
|
||||
|
||||
@CELERY_APP.task(
|
||||
bind=True,
|
||||
autoretry_for=(NotificationTransportError,),
|
||||
retry_backoff=True,
|
||||
base=MonitoredTask,
|
||||
)
|
||||
def notification_transport(
|
||||
self: MonitoredTask, notification_pk: int, transport_pk: int
|
||||
):
|
||||
"""Send notification over specified transport"""
|
||||
self.save_on_success = False
|
||||
try:
|
||||
notification: Notification = Notification.objects.get(pk=notification_pk)
|
||||
transport: NotificationTransport = NotificationTransport.objects.get(
|
||||
pk=transport_pk
|
||||
)
|
||||
transport.send(notification)
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
|
||||
except NotificationTransportError as exc:
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||
raise exc
|
0
authentik/events/tests/__init__.py
Normal file
0
authentik/events/tests/__init__.py
Normal file
24
authentik/events/tests/test_api.py
Normal file
24
authentik/events/tests/test_api.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Event API tests"""
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
class TestEventsAPI(APITestCase):
|
||||
"""Test Event API"""
|
||||
|
||||
def test_top_n(self):
|
||||
"""Test top_per_user"""
|
||||
user = User.objects.get(username="akadmin")
|
||||
self.client.force_login(user)
|
||||
|
||||
event = Event.new(EventAction.AUTHORIZE_APPLICATION)
|
||||
event.save() # We save to ensure nothing is un-saveable
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:event-top-per-user"),
|
||||
data={"filter_action": EventAction.AUTHORIZE_APPLICATION},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
@ -1,26 +1,37 @@
|
||||
"""audit event tests"""
|
||||
"""event tests"""
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.audit.models import Event
|
||||
from authentik.core.models import Group
|
||||
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"""
|
||||
event = Event.new("unittest", test={"model": get_anonymous_user()})
|
||||
test_model = Group.objects.create(name="test")
|
||||
event = Event.new("unittest", test={"model": test_model})
|
||||
event.save() # We save to ensure nothing is un-saveable
|
||||
model_content_type = ContentType.objects.get_for_model(get_anonymous_user())
|
||||
model_content_type = ContentType.objects.get_for_model(test_model)
|
||||
self.assertEqual(
|
||||
event.context.get("test").get("model").get("app"),
|
||||
model_content_type.app_label,
|
||||
)
|
||||
|
||||
def test_new_with_user(self):
|
||||
"""Create a new Event passing a user as kwarg"""
|
||||
event = Event.new("unittest", test={"model": get_anonymous_user()})
|
||||
event.save() # We save to ensure nothing is un-saveable
|
||||
self.assertEqual(
|
||||
event.context.get("test").get("model").get("username"),
|
||||
get_anonymous_user().username,
|
||||
)
|
||||
|
||||
def test_new_with_uuid_model(self):
|
||||
"""Create a new Event passing a model (with UUID PK) as kwarg"""
|
||||
temp_model = DummyPolicy.objects.create(name="test", result=True)
|
48
authentik/events/tests/test_middleware.py
Normal file
48
authentik/events/tests/test_middleware.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Event Middleware tests"""
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
class TestEventsMiddleware(APITestCase):
|
||||
"""Test Event Middleware"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.get(username="akadmin")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_create(self):
|
||||
"""Test model creation event"""
|
||||
self.client.post(
|
||||
reverse("authentik_api:application-list"),
|
||||
data={"name": "test-create", "slug": "test-create"},
|
||||
)
|
||||
self.assertTrue(Application.objects.filter(name="test-create").exists())
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED,
|
||||
context__model__model_name="application",
|
||||
context__model__app="authentik_core",
|
||||
context__model__name="test-create",
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_delete(self):
|
||||
"""Test model creation event"""
|
||||
Application.objects.create(name="test-delete", slug="test-delete")
|
||||
self.client.delete(
|
||||
reverse("authentik_api:application-detail", kwargs={"slug": "test-delete"})
|
||||
)
|
||||
self.assertFalse(Application.objects.filter(name="test").exists())
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.MODEL_DELETED,
|
||||
context__model__model_name="application",
|
||||
context__model__app="authentik_core",
|
||||
context__model__name="test-delete",
|
||||
).exists()
|
||||
)
|
90
authentik/events/tests/test_notifications.py
Normal file
90
authentik/events/tests/test_notifications.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Notification tests"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
EventAction,
|
||||
NotificationRule,
|
||||
NotificationTransport,
|
||||
)
|
||||
from authentik.policies.event_matcher.models import EventMatcherPolicy
|
||||
from authentik.policies.exceptions import PolicyException
|
||||
from authentik.policies.models import PolicyBinding
|
||||
|
||||
|
||||
class TestEventsNotifications(TestCase):
|
||||
"""Test Event Notifications"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.group = Group.objects.create(name="test-group")
|
||||
self.user = User.objects.create(name="test-user")
|
||||
self.group.users.add(self.user)
|
||||
self.group.save()
|
||||
|
||||
def test_trigger_empty(self):
|
||||
"""Test trigger without any policies attached"""
|
||||
transport = NotificationTransport.objects.create(name="transport")
|
||||
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
|
||||
trigger.transports.add(transport)
|
||||
trigger.save()
|
||||
|
||||
execute_mock = MagicMock()
|
||||
with patch("authentik.events.models.NotificationTransport.send", execute_mock):
|
||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||
self.assertEqual(execute_mock.call_count, 0)
|
||||
|
||||
def test_trigger_single(self):
|
||||
"""Test simple transport triggering"""
|
||||
transport = NotificationTransport.objects.create(name="transport")
|
||||
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
|
||||
trigger.transports.add(transport)
|
||||
trigger.save()
|
||||
matcher = EventMatcherPolicy.objects.create(
|
||||
name="matcher", action=EventAction.CUSTOM_PREFIX
|
||||
)
|
||||
PolicyBinding.objects.create(target=trigger, policy=matcher, order=0)
|
||||
|
||||
execute_mock = MagicMock()
|
||||
with patch("authentik.events.models.NotificationTransport.send", execute_mock):
|
||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||
self.assertEqual(execute_mock.call_count, 1)
|
||||
|
||||
def test_trigger_no_group(self):
|
||||
"""Test trigger without group"""
|
||||
trigger = NotificationRule.objects.create(name="trigger")
|
||||
matcher = EventMatcherPolicy.objects.create(
|
||||
name="matcher", action=EventAction.CUSTOM_PREFIX
|
||||
)
|
||||
PolicyBinding.objects.create(target=trigger, policy=matcher, order=0)
|
||||
|
||||
execute_mock = MagicMock()
|
||||
with patch("authentik.events.models.NotificationTransport.send", execute_mock):
|
||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||
self.assertEqual(execute_mock.call_count, 0)
|
||||
|
||||
def test_policy_error_recursive(self):
|
||||
"""Test Policy error which would cause recursion"""
|
||||
transport = NotificationTransport.objects.create(name="transport")
|
||||
NotificationRule.objects.filter(name__startswith="default").delete()
|
||||
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
|
||||
trigger.transports.add(transport)
|
||||
trigger.save()
|
||||
matcher = EventMatcherPolicy.objects.create(
|
||||
name="matcher", action=EventAction.CUSTOM_PREFIX
|
||||
)
|
||||
PolicyBinding.objects.create(target=trigger, policy=matcher, order=0)
|
||||
|
||||
execute_mock = MagicMock()
|
||||
passes = MagicMock(side_effect=PolicyException)
|
||||
with patch(
|
||||
"authentik.policies.event_matcher.models.EventMatcherPolicy.passes", passes
|
||||
):
|
||||
with patch(
|
||||
"authentik.events.models.NotificationTransport.send", execute_mock
|
||||
):
|
||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||
self.assertEqual(passes.call_count, 0)
|
98
authentik/events/utils.py
Normal file
98
authentik/events/utils.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""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.core.handlers.wsgi import WSGIRequest
|
||||
from django.db import models
|
||||
from django.db.models.base import Model
|
||||
from django.http.request import HttpRequest
|
||||
from django.views.debug import SafeExceptionReporterFilter
|
||||
from guardian.utils import get_anonymous_user
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.policies.types import PolicyRequest
|
||||
|
||||
# Special keys which are *not* cleaned, even when the default filter
|
||||
# is matched
|
||||
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, User):
|
||||
final_dict[key] = sanitize_dict(get_user(value))
|
||||
elif isinstance(value, models.Model):
|
||||
final_dict[key] = sanitize_dict(model_to_dict(value))
|
||||
elif isinstance(value, UUID):
|
||||
final_dict[key] = value.hex
|
||||
elif isinstance(value, (HttpRequest, WSGIRequest)):
|
||||
continue
|
||||
else:
|
||||
final_dict[key] = value
|
||||
return final_dict
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user