Compare commits
613 Commits
Author | SHA1 | Date | |
---|---|---|---|
c0c6ff51e2 | |||
4384cbb953 | |||
3397fb6560 | |||
b231521ff6 | |||
3efc709e03 | |||
dda7f54a16 | |||
2d33d3e2b8 | |||
d50c9c7748 | |||
665a0bcffe | |||
a141b8c5ea | |||
9d710702a4 | |||
e835fa6073 | |||
c0d037b9e9 | |||
b2bc431823 | |||
9c5140006b | |||
4e72cb96c9 | |||
f88e81ffef | |||
17861289c8 | |||
b99aad743b | |||
b1ce4d630d | |||
135f2b710c | |||
e8c18bd9b6 | |||
76621e497f | |||
065e0edc5f | |||
d72792ff37 | |||
f9239b008e | |||
b31c7357ed | |||
7c6d6f5297 | |||
e225139011 | |||
37edf80321 | |||
038f5dc554 | |||
5e964fad39 | |||
3800d67d71 | |||
7f5d1a0b6b | |||
92860cffca | |||
2aced5c010 | |||
4e1879715d | |||
403f70d6db | |||
b33256c809 | |||
e39fac32ec | |||
80e3331596 | |||
2a3af5214e | |||
4911d713a5 | |||
5e24f685c1 | |||
97d3621705 | |||
544a02ca3f | |||
ae26e44cc2 | |||
b0f9d33b32 | |||
c5e7e5ab68 | |||
5e3add0b81 | |||
9ccd43c29b | |||
9fc6c9aaf7 | |||
2d61200a05 | |||
268d826158 | |||
a47b6c330d | |||
de52fa7f48 | |||
680f4966a1 | |||
a9b9b27a0b | |||
52e7ff9919 | |||
f4a010e505 | |||
0bd14488bb | |||
6500559f8e | |||
642dbd4098 | |||
a8187d15c6 | |||
df5168765b | |||
c26ae16060 | |||
9ccb8fb838 | |||
e68b3d2cbd | |||
1be3f8368f | |||
3e64dd4653 | |||
48dde287d8 | |||
4da2f33892 | |||
74ca13861c | |||
532872b3c6 | |||
d37f730ee8 | |||
5e744c4c52 | |||
858c4eb808 | |||
3ffeb8ab00 | |||
0579b2935c | |||
c5cb01bd33 | |||
efd5836e43 | |||
f0f2092fb4 | |||
e09b7eb978 | |||
3e5db9eedb | |||
91500d1022 | |||
51d2990eb9 | |||
38b8df4a07 | |||
211027919f | |||
38c33395c8 | |||
7704f51441 | |||
ae63808678 | |||
81e349e07d | |||
49cdf1c7cf | |||
7e36b0e8fd | |||
552c07e932 | |||
44e3b33aaa | |||
a8ce219016 | |||
b9e93065f7 | |||
78f9ceb995 | |||
1904a187e0 | |||
0320a9aece | |||
4e94e51218 | |||
4392657a71 | |||
fbce1ef7c7 | |||
309d10c4e2 | |||
f43100a247 | |||
4d05a09a20 | |||
3538f1a629 | |||
993958c356 | |||
2d6d2357b5 | |||
a66d85b63d | |||
b0bd0435c4 | |||
b2e6938815 | |||
d66dd543bf | |||
de7a5a30d1 | |||
40c35dc77b | |||
5dd03098e5 | |||
672a28bb28 | |||
8ea2f5253a | |||
1e0146a453 | |||
c03133622f | |||
8303cfbd2b | |||
3ef550f738 | |||
c8767e23bf | |||
f302408712 | |||
c88c0b0127 | |||
acb1eab24e | |||
6cd2205f1f | |||
f6fd262618 | |||
5125990c4c | |||
52cb145333 | |||
c6bd93fe85 | |||
6a762d463f | |||
5beb319b27 | |||
12622d5847 | |||
a9baaf4da4 | |||
f61098b874 | |||
8ca4f730e8 | |||
0b5f85469c | |||
8e2b2123f1 | |||
b4b9a913b3 | |||
2dc6478c34 | |||
28614b5793 | |||
4a0103a88d | |||
fb494bc32a | |||
de9c00b293 | |||
3e5cbb40ce | |||
47793635b2 | |||
259800ce35 | |||
a38f286fb9 | |||
b6ffbfa40e | |||
b814a4f009 | |||
4ed6b7727a | |||
c3a2781507 | |||
ffba1d2b85 | |||
248409e43f | |||
a316cbba73 | |||
12135c445b | |||
844202f36b | |||
9b4a124c08 | |||
ab1b31604c | |||
a8b18480aa | |||
b5e4df5c16 | |||
2dbcc7a297 | |||
1730b3bacc | |||
d730ffbc72 | |||
d36fececd6 | |||
0caafea777 | |||
c847339b0e | |||
58bb08b604 | |||
98d303c6c0 | |||
9514edafba | |||
adb9149413 | |||
c51fed5307 | |||
db746f1296 | |||
62046aed59 | |||
5e0e8804c0 | |||
416791d4c5 | |||
5ee11ed4e0 | |||
3b2ef95798 | |||
827e4c65a7 | |||
fef89feb62 | |||
42f92306a5 | |||
44b8fd6ef5 | |||
5a86ebe318 | |||
1e3df62993 | |||
662eaf4933 | |||
3fd82e51bd | |||
154e38b42e | |||
915cdeb426 | |||
e15836e9ca | |||
8e1eae9a45 | |||
d67542d7f5 | |||
a202d082e8 | |||
4087f1c03b | |||
bbacb7e210 | |||
19cf8f6bdd | |||
f05d1750ee | |||
fa696b56c2 | |||
3f52cd9c2b | |||
d44a1934fe | |||
08f66df860 | |||
48d9a3ec8a | |||
de0b4ddc99 | |||
6e1bb0c49c | |||
d4597b6bb6 | |||
52f5930744 | |||
9504ad3b80 | |||
d2c7f8a963 | |||
ff05deaa1f | |||
b233f567ce | |||
b30d2c9536 | |||
5dd37ea696 | |||
49393070e0 | |||
fdb6dd4077 | |||
74a516cde0 | |||
58da68d72f | |||
918250ce78 | |||
c7ca3949f6 | |||
bbf5e95186 | |||
462e757f92 | |||
58798f1513 | |||
087490e26a | |||
c08d3dd82f | |||
430cb5ea1b | |||
9b1c279fd5 | |||
17be8b626d | |||
412757b178 | |||
18c64fafe4 | |||
77a1600c13 | |||
59ce586ea4 | |||
5fe28f6503 | |||
1f641c0ba6 | |||
cca3797669 | |||
c9cb5800ec | |||
a28fdac242 | |||
0724fcffeb | |||
7032abf2e7 | |||
9e8fa5827d | |||
5d18838868 | |||
2578970f7d | |||
f44fe81573 | |||
aa5d97f49b | |||
f262c93912 | |||
763c5e8356 | |||
050295ea20 | |||
77044f56fc | |||
eea413a90f | |||
8cad2f9f56 | |||
64ac32f683 | |||
1287c3dc4a | |||
9d7fc9db8c | |||
99b10c436a | |||
bb54085c20 | |||
9a0ada75fa | |||
848ce8c978 | |||
7b8df16c9e | |||
7a84f38db9 | |||
ba4de07ad8 | |||
b2d87940d6 | |||
6edc5180c7 | |||
f0c895a008 | |||
6d6716b8a2 | |||
d64a010c39 | |||
e1f241bd55 | |||
ad88637f22 | |||
a756a74b49 | |||
c311c0a221 | |||
ecc0934657 | |||
c402f1ff87 | |||
eb810f1bf0 | |||
c067573193 | |||
553c119356 | |||
e62cb1b6b8 | |||
4da243a59e | |||
622192e75e | |||
81a6ec644a | |||
58100cda8b | |||
734ab5f3cd | |||
d855f752c8 | |||
5ac3ecb85e | |||
dfb9e3a0c8 | |||
c2b2ce1f11 | |||
cecfe47540 | |||
4b544ae207 | |||
e30e17038b | |||
7e2c16ee38 | |||
041f3a22fa | |||
f990ef27cf | |||
bef762e0d6 | |||
0d001b358e | |||
c1cd5c71e0 | |||
d4209510c2 | |||
620e279453 | |||
bbf73c48a3 | |||
9319dda0ec | |||
14f5340802 | |||
0152985e64 | |||
e43268f585 | |||
7ef788752e | |||
b66d7ce1fd | |||
dc34652efd | |||
e0d2fb0de1 | |||
e0d9443141 | |||
a6305a5cae | |||
9e2578be1e | |||
09b8f532a7 | |||
e0939a2856 | |||
90f4b458e3 | |||
f5213deb67 | |||
bb08b1e637 | |||
ea6f5c920b | |||
54ff4cddbf | |||
645641f4bd | |||
97d83890e0 | |||
ec5dec4a16 | |||
4cfb621423 | |||
c381331c10 | |||
a7923f2a06 | |||
e5f7172c97 | |||
43fff0450b | |||
107fd3fce1 | |||
1a9b6dec26 | |||
444be5bb7f | |||
5ebfa018ee | |||
a6dab5e1ee | |||
f766871824 | |||
ba29bbe3be | |||
d711031ce9 | |||
af5c19cc52 | |||
359fbd2d73 | |||
e8b9853367 | |||
376b2b8051 | |||
e8d0af87e4 | |||
a4267320b0 | |||
52dd42701a | |||
fc9b1e5b12 | |||
2ecfaa41cf | |||
7106c4fdcf | |||
9420ca9949 | |||
956a1851a2 | |||
dafed86179 | |||
e72efce071 | |||
77b9658dba | |||
090c984ca3 | |||
2ff25b656f | |||
ff4d1edd63 | |||
79d12578c7 | |||
c0784b40e0 | |||
ff87c487c8 | |||
82b43b5a9d | |||
4b4e159a8e | |||
bb1c339655 | |||
aca6d6346f | |||
e7efaf4365 | |||
c6d76f580e | |||
941df0366d | |||
7762d6ed52 | |||
466df367e6 | |||
b0c8787cfa | |||
cf805f530f | |||
b40c6a1c67 | |||
3a62010445 | |||
3b4e7d9169 | |||
4245ba0d15 | |||
95e4c23db1 | |||
f5e120ad2e | |||
fab146b328 | |||
5aeadf8f98 | |||
5f9c655594 | |||
dd18cac702 | |||
b76ab902e5 | |||
f5082e2d3a | |||
61c493fc91 | |||
6779e19ac9 | |||
443eb43d1f | |||
560bd5a872 | |||
8f35a64faf | |||
7507f6be50 | |||
ac3b441456 | |||
53113e5eeb | |||
9d5db3ec12 | |||
169dcb86e2 | |||
e4f5224f42 | |||
98907e66e9 | |||
c05343d58e | |||
541fbc9a6d | |||
ef08e02333 | |||
35cc7ef8d7 | |||
e77382864b | |||
b5fd802005 | |||
98897f3c98 | |||
d49bb8a6ca | |||
05f2d3b2d9 | |||
d4d6d832b1 | |||
9c92138f2d | |||
5a4806bc43 | |||
54105e221e | |||
adfc76aa79 | |||
3e3f7af796 | |||
07969f7e10 | |||
249ab23df4 | |||
3141b47fba | |||
31f4cf0253 | |||
21d48b32c9 | |||
11bd42af82 | |||
feac9cb3a3 | |||
f6b5012f56 | |||
f9b388c658 | |||
4093f4669a | |||
9594f2cd8d | |||
380203eb53 | |||
307a73c752 | |||
7ad471a810 | |||
1184f9d070 | |||
3050aca3e6 | |||
8c41c6785d | |||
092ce0f9d8 | |||
97dceb5623 | |||
23b6df536f | |||
95b4206986 | |||
914de78576 | |||
ecf00fe9d6 | |||
7257e791ff | |||
c71b3a319d | |||
767147aef1 | |||
ce5a45037a | |||
9c9ca37586 | |||
381cd2e1ff | |||
2a2d5a5583 | |||
5c41dafc97 | |||
6367a98134 | |||
0bbe2b0331 | |||
6a77d511e8 | |||
989e3733a2 | |||
fbc24ea400 | |||
2b8c2f612e | |||
4905020e77 | |||
75787d20bc | |||
ca9f120988 | |||
5fb6753445 | |||
e86954e8ea | |||
604cd60dbe | |||
05f4ae8e58 | |||
88ac783fd2 | |||
bc66ede9aa | |||
1c295896e6 | |||
f90076abe9 | |||
01aa372e59 | |||
479ac81aa9 | |||
9c69c6d129 | |||
ea1e9cb4c6 | |||
dac7a77afb | |||
9b21197fec | |||
e4255649c0 | |||
81aff42e03 | |||
221851abc1 | |||
7f019583f2 | |||
e18a188723 | |||
f91ae5b319 | |||
dd39b9ebe8 | |||
15896a3b11 | |||
e092606181 | |||
a4707c5fc9 | |||
f0dde845db | |||
b0ea027769 | |||
d9f2faa462 | |||
7b4d31d4f6 | |||
522e182694 | |||
6c8a6620d2 | |||
d68b2b22e0 | |||
a4068001a3 | |||
574fed2618 | |||
8762e1c5ae | |||
d94e3113ff | |||
3c5b2618c0 | |||
602c5580d3 | |||
038beafb5e | |||
14923f8c07 | |||
b715687617 | |||
c46fa5d69c | |||
310e1d4501 | |||
fc957b63ff | |||
d53f64890c | |||
5f5583e2cb | |||
4c11ac9a42 | |||
cf6ad94509 | |||
08bb9c73a0 | |||
8e49194764 | |||
8afcb50a39 | |||
0326e1031f | |||
117009c0a2 | |||
b7833d8e09 | |||
3fd39fb823 | |||
317b7cabb3 | |||
a59bc1f436 | |||
c24810b876 | |||
bc94353850 | |||
f13a3505f3 | |||
4af871f408 | |||
162d5ccb62 | |||
b1723b4985 | |||
6bf7d56d51 | |||
9751cbbf83 | |||
8fa5ffa007 | |||
f353956353 | |||
02cfb2d877 | |||
1b6f88f6fd | |||
9f6ad08c50 | |||
25340fd744 | |||
7f2b44db04 | |||
d67b6c6120 | |||
4cfb5752b2 | |||
0d7b2d9f44 | |||
08ebc4cd59 | |||
85ae9712e3 | |||
83128f3019 | |||
7aa5ba9c6b | |||
e5dee2d7e6 | |||
b0232b804e | |||
de7cec35c6 | |||
700c57b807 | |||
ce75bba2c3 | |||
46f8ebd136 | |||
f8279d6972 | |||
072ca4da4f | |||
8c5c30dfd4 | |||
edc0116a3a | |||
c1b2c3689c | |||
6746cc33a0 | |||
74723d1a1f | |||
fccb8148d5 | |||
3a4ebbf92c | |||
48735e685c | |||
cdcae4efb0 | |||
f7c795c7f6 | |||
beba2ba092 | |||
9ac10a97ce | |||
2f5f82d797 | |||
c7fdb2acd7 | |||
51c7216b70 | |||
0f3ffaade0 | |||
156b98f7f0 | |||
a09faac9a7 | |||
d20c552248 | |||
f7fdf7902d | |||
b327963925 | |||
1eb3d563c6 | |||
02991c70a9 | |||
71ddbb409c | |||
fbcedc2fa0 | |||
3dad818af2 | |||
5dc0fa91e8 | |||
565c9ae98d | |||
2d6aa620b4 | |||
03d5a6cfe1 | |||
a5c47e4fdc | |||
9581278481 | |||
1c3ac21291 | |||
25faf05807 | |||
968dd52f6f | |||
a4b32b0d31 | |||
be1415fbd4 | |||
b5901a1570 | |||
bdc6dc8683 | |||
5087fa67dc | |||
fc205713c8 | |||
9adc5ad59e | |||
f63ccd033d | |||
d7c0e2ec35 | |||
00da52f32e | |||
287c684866 | |||
e94cf6ddc9 | |||
81272a2f7a | |||
e622a49b72 | |||
9030aed8a4 | |||
eee534a161 | |||
344abbda66 | |||
834814f867 | |||
7f823a04cd | |||
0f5e925a1a | |||
e0c79389ca | |||
a40bc65fd4 | |||
81bf98c746 | |||
41b59c5445 | |||
e1bbf9d80c | |||
bd2abdf45f | |||
abb91fbb65 | |||
f9b16a2110 | |||
588ac1d6a6 | |||
058d2938fb | |||
3db3214cbe | |||
bfc80f982c | |||
727bc87ede | |||
e2143d3ee8 | |||
b46ff4158a | |||
734233257c | |||
250558baf3 | |||
8e5323e2d7 | |||
06a920502c | |||
d5d036b412 | |||
9d03e75d9b | |||
0158807847 | |||
06a3f3ea0d | |||
12ae0a587d | |||
b3aa057d58 | |||
dd6d332166 | |||
6eca2eb147 | |||
744e204817 | |||
d45e7d6b85 | |||
6fd47edbe3 | |||
a616310eb7 | |||
2130029f90 |
@ -1,2 +1,5 @@
|
||||
[report]
|
||||
include = lemur/*.py
|
||||
omit = lemur/migrations/*
|
||||
lemur/tests/*
|
||||
|
||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -13,6 +13,7 @@
|
||||
MANIFEST
|
||||
test.conf
|
||||
pip-log.txt
|
||||
package-lock.json
|
||||
/htmlcov
|
||||
/cover
|
||||
/build
|
||||
@ -27,5 +28,7 @@ pip-log.txt
|
||||
docs/_build
|
||||
.editorconfig
|
||||
.idea
|
||||
test.conf
|
||||
lemur/tests/tmp
|
||||
lemur/tests/tmp
|
||||
|
||||
/lemur/plugins/lemur_email/tests/expiration-rendered.html
|
||||
/lemur/plugins/lemur_email/tests/rotation-rendered.html
|
||||
|
@ -1,5 +1,10 @@
|
||||
- repo: git://github.com/pre-commit/pre-commit-hooks
|
||||
sha: 18d7035de5388cc7775be57f529c154bf541aab9
|
||||
sha: v0.9.1
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: flake8
|
||||
- id: check-merge-conflict
|
||||
- repo: git://github.com/pre-commit/mirrors-jshint
|
||||
sha: v2.9.5
|
||||
hooks:
|
||||
- id: jshint
|
||||
|
@ -3,15 +3,13 @@ sudo: required
|
||||
dist: trusty
|
||||
|
||||
node_js:
|
||||
- "4.2"
|
||||
- "6.2.0"
|
||||
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- python: "2.7"
|
||||
env: TOXENV=py27
|
||||
- python: "3.5"
|
||||
env: TOXENV=py35
|
||||
|
||||
@ -36,9 +34,11 @@ before_script:
|
||||
|
||||
install:
|
||||
- pip install coveralls
|
||||
- pip install bandit
|
||||
|
||||
script:
|
||||
- make test
|
||||
- bandit -r . -ll -ii -x lemur/tests/,docs
|
||||
|
||||
after_success:
|
||||
- coveralls
|
||||
|
127
CHANGELOG.rst
127
CHANGELOG.rst
@ -1,14 +1,117 @@
|
||||
Changelog
|
||||
=========
|
||||
|
||||
0.5 - `master`
|
||||
|
||||
0.7 - `2018-05-07`
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
.. note:: This version is not yet released and is under active development
|
||||
This release adds LetsEncrypt support with DNS providers Dyn, Route53, and Cloudflare, and expands on the pending certificate functionality.
|
||||
The linux_dst plugin will also be deprecated and removed.
|
||||
|
||||
The pending_dns_authorizations and dns_providers tables were created. New columns
|
||||
were added to the certificates and pending_certificates tables, (For the DNS provider ID), and authorities (For options).
|
||||
Please run a database migration when upgrading.
|
||||
|
||||
The Let's Encrypt flow will run asynchronously. When a certificate is requested through the acme-issuer, a pending certificate
|
||||
will be created. A cron needs to be defined to run `lemur pending_certs fetch_all_acme`. This command will iterate through all of the pending
|
||||
certificates, request a DNS challenge token from Let's Encrypt, and set the appropriate _acme-challenge TXT entry. It will
|
||||
then iterate through and resolve the challenges before requesting a certificate for each pending certificate. If a certificate
|
||||
is successfully obtained, the pending_certificate will be moved to the certificates table with the appropriate properties.
|
||||
|
||||
Special thanks to all who helped with this release, notably:
|
||||
|
||||
- The folks at Cloudflare
|
||||
- dmitryzykov
|
||||
- jchuong
|
||||
- seils
|
||||
- titouanc
|
||||
|
||||
|
||||
0.4 - ``
|
||||
~~~~~~~~
|
||||
Upgrading
|
||||
---------
|
||||
|
||||
.. note:: This release will need a migration change. Please follow the `documentation <https://lemur.readthedocs.io/en/latest/administration.html#upgrading-lemur>`_ to upgrade Lemur.
|
||||
|
||||
0.6 - `2018-01-02`
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Happy Holidays! This is a big release with lots of bug fixes and features. Below are the highlights and are not exhaustive.
|
||||
|
||||
|
||||
Features:
|
||||
|
||||
* Per-certificate rotation policies, requires a database migration. The default rotation policy for all certificates.
|
||||
is 30 days. Every certificate will gain a policy regardless of if auto-rotation is used.
|
||||
* Adds per-user API Keys, allows users to issue multiple long-lived API tokens with the same permission as the user creating them.
|
||||
* Adds the ability to revoke certificates from the Lemur UI/API, this is currently only supported for the digicert CIS and cfssl plugins.
|
||||
* Allow destinations to support an export function. Useful for file system destinations e.g. S3 to specify the export plugin you wish to run before being sent to the destination.
|
||||
* Adds support for uploading certificates to Cloudfront.
|
||||
* Re-worked certificate metadata pane for improved readability.
|
||||
* Adds support for LDAP user authentication
|
||||
|
||||
Bugs:
|
||||
|
||||
* Closed `#767 <https://github.com/Netflix/lemur/issues/767>`_ - Fixed issue with login redirect loop.
|
||||
* Closed `#792 <https://github.com/Netflix/lemur/issues/792>`_ - Fixed an issue with a unique constraint was violated when replacing certificates.
|
||||
* Closed `#752 <https://github.com/Netflix/lemur/issues/752>`_ - Fixed an internal server error when validating notification units.
|
||||
* Closed `#684 <https://github.com/Netflix/lemur/issues/684>`_ - Fixed migration failure when null values encountered.
|
||||
* Closes `#661 <https://github.com/Netflix/lemur/issues/661>`_ - Fixed an issue where default values were missing during clone operations.
|
||||
|
||||
|
||||
Special thanks to all who helped with this release, notably:
|
||||
|
||||
- intgr
|
||||
- SecurityInsanity
|
||||
- johanneslange
|
||||
- RickB17
|
||||
- pr8kerl
|
||||
- bunjiboys
|
||||
|
||||
See the full list of issues closed in `0.6 <https://github.com/Netflix/lemur/milestone/5>`_.
|
||||
|
||||
Upgrading
|
||||
---------
|
||||
|
||||
.. note:: This release will need a migration change. Please follow the `documentation <https://lemur.readthedocs.io/en/latest/administration.html#upgrading-lemur>`_ to upgrade Lemur.
|
||||
|
||||
|
||||
|
||||
0.5 - `2016-04-08`
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This release is most notable for dropping support for python2.7. All Lemur versions >0.4 will now support python3.5 only.
|
||||
|
||||
Big thanks to neilschelly for quite a lot of improvements to the `lemur-cryptography` plugin.
|
||||
|
||||
Other Highlights:
|
||||
|
||||
* Closed `#501 <https://github.com/Netflix/lemur/issues/501>`_ - Endpoint resource as now kept in sync via an
|
||||
expiration mechanism. Such that non-existant endpoints gracefully fall out of Lemur. Certificates are never
|
||||
removed from Lemur.
|
||||
* Closed `#551 <https://github.com/Netflix/lemur/pull/551>`_ - Added the ability to create a 4096 bit key during certificate
|
||||
creation. Closed `#528 <https://github.com/Netflix/lemur/pull/528>`_ to ensure that issuer plugins supported the new 4096 bit keys.
|
||||
* Closed `#566 <https://github.com/Netflix/lemur/issues/566>`_ - Fixed an issue changing the notification status for certificates
|
||||
without private keys.
|
||||
* Closed `#594 <https://github.com/Netflix/lemur/issues/594>`_ - Added `replaced` field indicating if a certificate has been superseded.
|
||||
* Closed `#602 <https://github.com/Netflix/lemur/issues/602>`_ - AWS plugin added support for ALBs for endpoint tracking.
|
||||
|
||||
|
||||
Special thanks to all who helped with this release, notably:
|
||||
|
||||
- RcRonco
|
||||
- harmw
|
||||
- jeremyguarini
|
||||
|
||||
See the full list of issues closed in `0.5 <https://github.com/Netflix/lemur/milestone/4>`_.
|
||||
|
||||
Upgrading
|
||||
---------
|
||||
|
||||
.. note:: This release will need a slight migration change. Please follow the `documentation <https://lemur.readthedocs.io/en/latest/administration.html#upgrading-lemur>`_ to upgrade Lemur.
|
||||
|
||||
|
||||
0.4 - `2016-11-17`
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
There have been quite a few issues closed in this release. Some notables:
|
||||
|
||||
@ -24,11 +127,11 @@ Several fixes/tweaks to Lemurs python3 support (thanks chadhendrie!)
|
||||
This will most likely be the last release to support python2.7 moving Lemur to target python3 exclusively. Please comment
|
||||
on issue #340 if this negatively affects your usage of Lemur.
|
||||
|
||||
See the full list of issues closed in `0.4 <https://github.com/Netflix/lemur/milestone/3>`_.
|
||||
|
||||
Upgrading
|
||||
---------
|
||||
|
||||
See the full list of issues closed in `0.4 <https://github.com/Netflix/lemur/milestone/3>`_.
|
||||
|
||||
.. note:: This release will need a slight migration change. Please follow the `documentation <https://lemur.readthedocs.io/en/latest/administration.html#upgrading-lemur>`_ to upgrade Lemur.
|
||||
|
||||
|
||||
@ -55,7 +158,7 @@ Issuer Plugin Owners
|
||||
--------------------
|
||||
|
||||
This release may break your plugins, the keys in `issuer_options` have been changed from `camelCase` to `under_score`.
|
||||
This change was made to break a undue reliance on downstream options maintains a more pythonic naming convention. Renaming
|
||||
This change was made to break an undue reliance on downstream options maintains a more pythonic naming convention. Renaming
|
||||
these keys should be fairly trivial, additionally pull requests have been submitted to affected plugins to help ease the transition.
|
||||
|
||||
.. note:: This change only affects issuer plugins and does not affect any other types of plugins.
|
||||
@ -65,10 +168,10 @@ these keys should be fairly trivial, additionally pull requests have been submit
|
||||
stricter input validation and better error messages when validation fails.
|
||||
* Closed `#146 <https://github.com/Netflix/lemur/issues/146>`_ - Moved authority type to first pane of authority creation wizard.
|
||||
* Closed `#147 <https://github.com/Netflix/lemur/issues/147>`_ - Added and refactored the relationship between authorities and their
|
||||
root certificates. Displays the certificates (and chains) next the the authority in question.
|
||||
root certificates. Displays the certificates (and chains) next to the authority in question.
|
||||
* Closed `#199 <https://github.com/Netflix/lemur/issues/199>`_ - Ensures that the dates submitted to Lemur during authority and
|
||||
certificate creation are actually dates.
|
||||
* Closed `#230 <https://github.com/Netflix/lemur/issues/230>`_ - Migrated authority dropdown to a ui-select based dropdown, this
|
||||
* Closed `#230 <https://github.com/Netflix/lemur/issues/230>`_ - Migrated authority dropdown to an ui-select based dropdown, this
|
||||
should be easier to determine what authorities are available and when an authority has actually been selected.
|
||||
* Closed `#254 <https://github.com/Netflix/lemur/issues/254>`_ - Forces certificate names to be generally unique. If a certificate name
|
||||
(generated or otherwise) is found to be a duplicate we increment by appending a counter.
|
||||
@ -80,7 +183,7 @@ these keys should be fairly trivial, additionally pull requests have been submit
|
||||
via the UI.
|
||||
* Closed `#289 <https://github.com/Netflix/lemur/issues/289>`_ - Fixed and issue where intermediates were not being properly exported.
|
||||
* Closed `#315 <https://github.com/Netflix/lemur/issues/315>`_ - Made how roles are associated with certificates and authorities much more
|
||||
explict, including adding the ability to add roles directly to certificates and authorities on creation.
|
||||
explicit, including adding the ability to add roles directly to certificates and authorities on creation.
|
||||
|
||||
|
||||
|
||||
@ -130,6 +233,6 @@ these keys should be fairly trivial, additionally pull requests have been submit
|
||||
0.1.5 - 2015-10-26
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
* **SECURITY ISSUE**: Switched from use a AES static key to Fernet encryption.
|
||||
* **SECURITY ISSUE**: Switched from use an AES static key to Fernet encryption.
|
||||
Affects all versions prior to 0.1.5. If upgrading this will require a data migration.
|
||||
see: `Upgrading Lemur <https://lemur.readthedocs.com/adminstration#UpgradingLemur>`_
|
||||
see: `Upgrading Lemur <https://lemur.readthedocs.io/administration#UpgradingLemur>`_
|
||||
|
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@ -0,0 +1,13 @@
|
||||
FROM python:3.5
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y make python-software-properties curl
|
||||
RUN curl -sL https://deb.nodesource.com/setup_7.x | bash -
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y nodejs libldap2-dev libsasl2-dev libldap2-dev libssl-dev
|
||||
RUN pip install -U setuptools
|
||||
RUN pip install coveralls bandit
|
||||
WORKDIR /app
|
||||
COPY . /app/
|
||||
RUN pip install -e .
|
||||
RUN pip install "file://`pwd`#egg=lemur[dev]"
|
||||
RUN pip install "file://`pwd`#egg=lemur[tests]"
|
2
LICENSE
2
LICENSE
@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2014 Netflix, Inc.
|
||||
Copyright 2018 Netflix, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
41
Makefile
41
Makefile
@ -1,6 +1,6 @@
|
||||
NPM_ROOT = ./node_modules
|
||||
STATIC_DIR = src/lemur/static/app
|
||||
|
||||
SHELL=/bin/bash
|
||||
USER := $(shell whoami)
|
||||
|
||||
develop: update-submodules setup-git
|
||||
@ -17,7 +17,22 @@ endif
|
||||
pip install "file://`pwd`#egg=lemur[dev]"
|
||||
pip install "file://`pwd`#egg=lemur[tests]"
|
||||
node_modules/.bin/gulp build
|
||||
node_modules/.bin/gulp package
|
||||
node_modules/.bin/gulp package --urlContextPath=$(urlContextPath)
|
||||
@echo ""
|
||||
|
||||
release:
|
||||
@echo "--> Installing dependencies"
|
||||
ifeq ($(USER), root)
|
||||
@echo "WARNING: It looks like you are installing Lemur as root. This is not generally advised."
|
||||
npm install --unsafe-perm
|
||||
else
|
||||
npm install
|
||||
endif
|
||||
pip install "setuptools>=0.9.8"
|
||||
# order matters here, base package must install first
|
||||
pip install -e .
|
||||
node_modules/.bin/gulp build
|
||||
node_modules/.bin/gulp package --urlContextPath=$(urlContextPath)
|
||||
@echo ""
|
||||
|
||||
dev-docs:
|
||||
@ -89,4 +104,24 @@ coverage: develop
|
||||
publish:
|
||||
python setup.py sdist bdist_wheel upload
|
||||
|
||||
.PHONY: develop dev-postgres dev-docs setup-git build clean update-submodules test testloop test-cli test-js test-python lint lint-python lint-js coverage publish
|
||||
up-reqs:
|
||||
ifndef VIRTUAL_ENV
|
||||
$(error Please activate virtualenv first)
|
||||
endif
|
||||
@echo "--> Updating Python requirements"
|
||||
pip install --upgrade pip
|
||||
pip install --upgrade pip-tools
|
||||
pip-compile --output-file requirements-docs.txt requirements-docs.in -U --no-index
|
||||
pip-compile --output-file requirements-dev.txt requirements-dev.in -U --no-index
|
||||
pip-compile --output-file requirements-tests.txt requirements-tests.in -U --no-index
|
||||
pip-compile --output-file requirements.txt requirements.in -U --no-index
|
||||
@echo "--> Done updating Python requirements"
|
||||
@echo "--> Removing python-ldap from requirements-docs.txt"
|
||||
grep -v "python-ldap" requirements-docs.txt > tempreqs && mv tempreqs requirements-docs.txt
|
||||
@echo "--> Installing new dependencies"
|
||||
pip install -e .
|
||||
@echo "--> Done installing new dependencies"
|
||||
@echo ""
|
||||
|
||||
|
||||
.PHONY: develop dev-postgres dev-docs setup-git build clean update-submodules test testloop test-cli test-js test-python lint lint-python lint-js coverage publish release
|
||||
|
10
README.rst
10
README.rst
@ -6,7 +6,7 @@ Lemur
|
||||
:target: https://gitter.im/Netflix/lemur?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge
|
||||
|
||||
.. image:: https://readthedocs.org/projects/lemur/badge/?version=latest
|
||||
:target: https://lemur.readthedocs.org
|
||||
:target: https://lemur.readthedocs.io
|
||||
:alt: Latest Docs
|
||||
|
||||
.. image:: https://img.shields.io/badge/NetflixOSS-active-brightgreen.svg
|
||||
@ -14,18 +14,22 @@ Lemur
|
||||
.. image:: https://travis-ci.org/Netflix/lemur.svg
|
||||
:target: https://travis-ci.org/Netflix/lemur
|
||||
|
||||
.. image:: https://coveralls.io/repos/github/Netflix/lemur/badge.svg?branch=master
|
||||
:target: https://coveralls.io/github/Netflix/lemur?branch=master
|
||||
|
||||
|
||||
|
||||
Lemur manages TLS certificate creation. While not able to issue certificates itself, Lemur acts as a broker between CAs
|
||||
and environments providing a central portal for developers to issue TLS certificates with 'sane' defaults.
|
||||
|
||||
It works on CPython 2.7, 3.3, 3.4. We deploy on Ubuntu and develop on OS X.
|
||||
It works on CPython 3.5. We deploy on Ubuntu and develop on OS X.
|
||||
|
||||
|
||||
Project resources
|
||||
=================
|
||||
|
||||
- `Lemur Blog Post <http://techblog.netflix.com/2015/09/introducing-lemur.html>`_
|
||||
- `Documentation <http://lemur.readthedocs.org/>`_
|
||||
- `Documentation <http://lemur.readthedocs.io/>`_
|
||||
- `Source code <https://github.com/netflix/lemur>`_
|
||||
- `Issue tracker <https://github.com/netflix/lemur/issues>`_
|
||||
- `Docker <https://github.com/Netflix/lemur-docker>`_
|
||||
|
@ -20,7 +20,6 @@
|
||||
"angular-loading-bar": "~0.8.0",
|
||||
"angular-moment": "~0.10.3",
|
||||
"moment-range": "~2.1.0",
|
||||
"angular-spinkit": "~0.3.3",
|
||||
"angular-clipboard": "~1.3.0",
|
||||
"angularjs-toaster": "~1.0.0",
|
||||
"angular-chart.js": "~0.8.8",
|
||||
@ -32,12 +31,12 @@
|
||||
"font-awesome": "~4.5.0",
|
||||
"lodash": "~4.0.1",
|
||||
"underscore": "~1.8.3",
|
||||
"angular-smart-table": "~2.1.6",
|
||||
"angular-smart-table": "2.1.8",
|
||||
"angular-strap": ">= 2.2.2",
|
||||
"angular-underscore": "^0.5.0",
|
||||
"angular-translate": "^2.9.0",
|
||||
"angular-ui-switch": "~0.1.0",
|
||||
"angular-sanitize": "^1.5.0",
|
||||
"angular-sanitize": "~1.5.0",
|
||||
"angular-file-saver": "~1.0.1",
|
||||
"angular-ui-select": "~0.17.1",
|
||||
"d3": "^3.5.17"
|
||||
|
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
version: '2.0'
|
||||
services:
|
||||
test:
|
||||
build: .
|
||||
volumes:
|
||||
- ".:/app"
|
||||
links:
|
||||
- postgres
|
||||
command: make test
|
||||
environment:
|
||||
SQLALCHEMY_DATABASE_URI: postgresql://lemur:lemur@postgres:5432/lemur
|
||||
VIRTUAL_ENV: 'true'
|
||||
|
||||
postgres:
|
||||
image: postgres:9.4
|
||||
environment:
|
||||
POSTGRES_USER: lemur
|
||||
POSTGRES_PASSWORD: lemur
|
@ -7,6 +7,10 @@ Configuration
|
||||
that allow you to encrypt files at rest and decrypt them when it's time for deployment. See :ref:`Credential Management <CredentialManagement>`
|
||||
for more information.
|
||||
|
||||
.. note::
|
||||
All configuration values are python strings unless otherwise noted.
|
||||
|
||||
|
||||
Basic Configuration
|
||||
-------------------
|
||||
|
||||
@ -24,14 +28,14 @@ Basic Configuration
|
||||
|
||||
LOG_FILE = "/logs/lemur/lemur-test.log"
|
||||
|
||||
.. data:: debug
|
||||
.. data:: DEBUG
|
||||
:noindex:
|
||||
|
||||
Sets the flask debug flag to true (if supported by the webserver)
|
||||
|
||||
::
|
||||
|
||||
debug = False
|
||||
DEBUG = False
|
||||
|
||||
.. warning::
|
||||
This should never be used in a production environment as it exposes Lemur to
|
||||
@ -61,16 +65,59 @@ Basic Configuration
|
||||
SQLALCHEMY_DATABASE_URI = 'postgresql://<user>:<password>@<hostname>:5432/lemur'
|
||||
|
||||
|
||||
.. data:: SQLALCHEMY_POOL_SIZE
|
||||
:noindex:
|
||||
|
||||
The default connection pool size is 5 for sqlalchemy managed connections. Depending on the number of Lemur instances,
|
||||
please specify per instance connection pool size. Below is an example to set connection pool size to 10.
|
||||
|
||||
::
|
||||
|
||||
SQLALCHEMY_POOL_SIZE = 10
|
||||
|
||||
|
||||
.. warning::
|
||||
This is an optional setting but important to review and set for optimal database connection usage and for overall database performance.
|
||||
|
||||
.. data:: SQLALCHEMY_MAX_OVERFLOW
|
||||
:noindex:
|
||||
|
||||
This setting allows to create connections in addition to specified number of connections in pool size. By default, sqlalchemy
|
||||
allows 10 connections to create in addition to the pool size. This is also an optional setting. If `SQLALCHEMY_POOL_SIZE` and
|
||||
`SQLALCHEMY_MAX_OVERFLOW` are not speficied then each Lemur instance may create maximum of 15 connections.
|
||||
|
||||
::
|
||||
|
||||
SQLALCHECK_MAX_OVERFLOW = 0
|
||||
|
||||
|
||||
.. note::
|
||||
Specifying the `SQLALCHEMY_MAX_OVERFLOW` to 0 will enforce limit to not create connections above specified pool size.
|
||||
|
||||
|
||||
.. data:: LEMUR_ALLOW_WEEKEND_EXPIRATION
|
||||
:noindex:
|
||||
|
||||
Specifies whether to allow certificates created by Lemur to expire on weekends. Default is True.
|
||||
|
||||
.. data:: LEMUR_RESTRICTED_DOMAINS
|
||||
.. data:: LEMUR_WHITELISTED_DOMAINS
|
||||
:noindex:
|
||||
|
||||
This allows the administrator to mark a subset of domains or domains matching a particular regex as
|
||||
*restricted*. This means that only an administrator is allows to issue the domains in question.
|
||||
List of regular expressions for domain restrictions; if the list is not empty, normal users can only issue
|
||||
certificates for domain names matching at least one pattern on this list. Administrators are exempt from this
|
||||
restriction.
|
||||
|
||||
Cerificate common name is matched against these rules *if* it does not contain a space. SubjectAltName DNS names
|
||||
are always matched against these rules.
|
||||
|
||||
Take care to write patterns in such way to not allow the `*` wildcard character inadvertently. To match a `.`
|
||||
character, it must be escaped (as `\.`).
|
||||
|
||||
.. data:: LEMUR_OWNER_EMAIL_IN_SUBJECT
|
||||
:noindex:
|
||||
|
||||
By default, Lemur will add the certificate owner's email address to certificate subject (for CAs that allow it).
|
||||
Set this to `False` to disable this.
|
||||
|
||||
.. data:: LEMUR_TOKEN_SECRET
|
||||
:noindex:
|
||||
@ -109,6 +156,12 @@ Basic Configuration
|
||||
LEMUR_ENCRYPTION_KEYS = ['1YeftooSbxCiX2zo8m1lXtpvQjy27smZcUUaGmffhMY=', 'LAfQt6yrkLqOK5lwpvQcT4jf2zdeTQJV1uYeh9coT5s=']
|
||||
|
||||
|
||||
.. data:: DEBUG_DUMP
|
||||
:noindex:
|
||||
|
||||
Dump all imported or generated CSR and certificate details to stdout using OpenSSL. (default: `False`)
|
||||
|
||||
|
||||
Certificate Default Options
|
||||
---------------------------
|
||||
|
||||
@ -164,6 +217,14 @@ and are used when Lemur creates the CSR for your certificates.
|
||||
LEMUR_DEFAULT_ISSUER_PLUGIN = "verisign-issuer"
|
||||
|
||||
|
||||
.. data:: LEMUR_DEFAULT_AUTHORITY
|
||||
:noindex:
|
||||
|
||||
::
|
||||
|
||||
LEMUR_DEFAULT_AUTHORITY = "verisign"
|
||||
|
||||
|
||||
Notification Options
|
||||
--------------------
|
||||
|
||||
@ -194,14 +255,14 @@ Lemur supports sending certification expiration notifications through SES and SM
|
||||
you can send any mail. See: `Verifying Email Address in Amazon SES <http://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses.html>`_
|
||||
|
||||
|
||||
.. data:: LEMUR_MAIL
|
||||
.. data:: LEMUR_EMAIL
|
||||
:noindex:
|
||||
|
||||
Lemur sender's email
|
||||
|
||||
::
|
||||
|
||||
LEMUR_MAIL = 'lemur.example.com'
|
||||
LEMUR_EMAIL = 'lemur.example.com'
|
||||
|
||||
|
||||
.. data:: LEMUR_SECURITY_TEAM_EMAIL
|
||||
@ -226,7 +287,120 @@ Lemur supports sending certification expiration notifications through SES and SM
|
||||
|
||||
Authentication Options
|
||||
----------------------
|
||||
Lemur currently supports Basic Authentication, Ping OAuth2, and Google out of the box. Additional flows can be added relatively easily.
|
||||
Lemur currently supports Basic Authentication, LDAP Authentication, Ping OAuth2, and Google out of the box. Additional flows can be added relatively easily.
|
||||
|
||||
LDAP Options
|
||||
~~~~~~~~~~~~
|
||||
|
||||
Lemur supports the use of an LDAP server in conjunction with Basic Authentication. Lemur local users can still be defined and take precedence over LDAP users. If a local user does not exist, LDAP will be queried for authentication. Only simple ldap binding with or without TLS is supported.
|
||||
|
||||
LDAP support requires the pyldap python library, which also depends on the following openldap packages.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo apt-get update
|
||||
$ sudo apt-get install libldap2-dev libsasl2-dev libldap2-dev libssl-dev
|
||||
|
||||
|
||||
To configure the use of an LDAP server, a number of settings need to be configured in `lemur.conf.py`.
|
||||
|
||||
Here is an example LDAP configuration stanza you can add to your config. Adjust to suit your environment of course.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
LDAP_AUTH = True
|
||||
LDAP_BIND_URI='ldaps://secure.evilcorp.net'
|
||||
LDAP_BASE_DN='DC=users,DC=evilcorp,DC=net'
|
||||
LDAP_EMAIL_DOMAIN='evilcorp.net'
|
||||
LDAP_USE_TLS = True
|
||||
LDAP_CACERT_FILE = '/opt/lemur/trusted.pem'
|
||||
LDAP_REQUIRED_GROUP = 'certificate-management-access'
|
||||
LDAP_GROUPS_TO_ROLES = {'certificate-management-admin': 'admin', 'certificate-management-read-only': 'read-only'}
|
||||
|
||||
|
||||
The lemur ldap module uses the `user principal name` (upn) of the authenticating user to bind. This is done once for each user at login time. The UPN is effectively the email address in AD/LDAP of the user. If the user doesn't provide the email address, it constructs one based on the username supplied (which should normally match the samAccountName) and the value provided by the config LDAP_EMAIL_DOMAIN.
|
||||
The config LDAP_BASE_DN tells lemur where to search within the AD/LDAP tree for the given UPN (user). If the bind with those credentials is successful - there is a valid user in AD with correct password.
|
||||
|
||||
Each of the LDAP options are described below.
|
||||
|
||||
.. data:: LDAP_AUTH
|
||||
:noindex:
|
||||
|
||||
This enables the use of LDAP
|
||||
|
||||
::
|
||||
|
||||
LDAP_AUTH = True
|
||||
|
||||
.. data:: LDAP_BIND_URI
|
||||
:noindex:
|
||||
|
||||
Specifies the LDAP server connection string
|
||||
|
||||
::
|
||||
|
||||
LDAP_BIND_URI = 'ldaps://hostname'
|
||||
|
||||
.. data:: LDAP_BASE_DN
|
||||
:noindex:
|
||||
|
||||
Specifies the LDAP distinguished name location to search for users
|
||||
|
||||
::
|
||||
|
||||
LDAP_BASE_DN = 'DC=Users,DC=Evilcorp,DC=com'
|
||||
|
||||
.. data:: LDAP_EMAIL_DOMAIN
|
||||
:noindex:
|
||||
|
||||
The email domain used by users in your directory. This is used to build the userPrincipalName to search with.
|
||||
|
||||
::
|
||||
|
||||
LDAP_EMAIL_DOMAIN = 'evilcorp.com'
|
||||
|
||||
The following LDAP options are not required, however TLS is always recommended.
|
||||
|
||||
.. data:: LDAP_USE_TLS
|
||||
:noindex:
|
||||
|
||||
Enables the use of TLS when connecting to the LDAP server. Ensure the LDAP_BIND_URI is using ldaps scheme.
|
||||
|
||||
::
|
||||
|
||||
LDAP_USE_TLS = True
|
||||
|
||||
.. data:: LDAP_CACERT_FILE
|
||||
:noindex:
|
||||
|
||||
Specify a Certificate Authority file containing PEM encoded trusted issuer certificates. This can be used if your LDAP server is using certificates issued by a private CA.
|
||||
|
||||
::
|
||||
|
||||
LDAP_CACERT_FILE = '/path/to/cacert/file'
|
||||
|
||||
.. data:: LDAP_REQUIRED_GROUP
|
||||
:noindex:
|
||||
|
||||
Lemur has pretty open permissions. You can define an LDAP group to specify who can access Lemur. Only members of this group will be able to login.
|
||||
|
||||
::
|
||||
|
||||
LDAP_REQUIRED_GROUP = 'Lemur LDAP Group Name'
|
||||
|
||||
.. data:: LDAP_GROUPS_TO_ROLES
|
||||
:noindex:
|
||||
|
||||
You can also define a dictionary of ldap groups mapped to lemur roles. This allows you to use ldap groups to manage access to owner/creator roles in Lemur
|
||||
|
||||
::
|
||||
|
||||
LDAP_GROUPS_TO_ROLES = {'lemur_admins': 'admin', 'Lemur Team DL Group': 'team@example.com'}
|
||||
|
||||
|
||||
Authentication Providers
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
If you are not using an authentication provider you do not need to configure any of these options.
|
||||
|
||||
For more information about how to use social logins, see: `Satellizer <https://github.com/sahat/satellizer>`_
|
||||
@ -236,7 +410,7 @@ For more information about how to use social logins, see: `Satellizer <https://g
|
||||
|
||||
::
|
||||
|
||||
ACTIVE_PROVIDERS = ["ping", "google"]
|
||||
ACTIVE_PROVIDERS = ["ping", "google", "oauth2"]
|
||||
|
||||
.. data:: PING_SECRET
|
||||
:noindex:
|
||||
@ -295,6 +469,70 @@ For more information about how to use social logins, see: `Satellizer <https://g
|
||||
|
||||
PING_AUTH_ENDPOINT = "https://<yourpingserver>/oauth2/authorize"
|
||||
|
||||
.. data:: OAUTH2_SECRET
|
||||
:noindex:
|
||||
|
||||
::
|
||||
|
||||
OAUTH2_SECRET = 'somethingsecret'
|
||||
|
||||
.. data:: OAUTH2_ACCESS_TOKEN_URL
|
||||
:noindex:
|
||||
|
||||
::
|
||||
|
||||
OAUTH2_ACCESS_TOKEN_URL = "https://<youroauthserver> /oauth2/v1/authorize"
|
||||
|
||||
|
||||
.. data:: OAUTH2_USER_API_URL
|
||||
:noindex:
|
||||
|
||||
::
|
||||
|
||||
OAUTH2_USER_API_URL = "https://<youroauthserver>/oauth2/v1/userinfo"
|
||||
|
||||
.. data:: OAUTH2_JWKS_URL
|
||||
:noindex:
|
||||
|
||||
::
|
||||
|
||||
OAUTH2_JWKS_URL = "https://<youroauthserver>/oauth2/v1/keys"
|
||||
|
||||
.. data:: OAUTH2_NAME
|
||||
:noindex:
|
||||
|
||||
::
|
||||
|
||||
OAUTH2_NAME = "Example Oauth2 Provider"
|
||||
|
||||
.. data:: OAUTH2_CLIENT_ID
|
||||
:noindex:
|
||||
|
||||
::
|
||||
|
||||
OAUTH2_CLIENT_ID = "client-id"
|
||||
|
||||
.. data:: OAUTH2_REDIRECT_URI
|
||||
:noindex:
|
||||
|
||||
::
|
||||
|
||||
OAUTH2_REDIRECT_URI = "https://<yourlemurserver>/api/1/auth/oauth2"
|
||||
|
||||
.. data:: OAUTH2_AUTH_ENDPOINT
|
||||
:noindex:
|
||||
|
||||
::
|
||||
|
||||
OAUTH2_AUTH_ENDPOINT = "https://<youroauthserver>/oauth2/v1/authorize"
|
||||
|
||||
.. data:: OAUTH2_VERIFY_CERT
|
||||
:noindex:
|
||||
|
||||
::
|
||||
|
||||
OAUTH2_VERIFY_CERT = True
|
||||
|
||||
.. data:: GOOGLE_CLIENT_ID
|
||||
:noindex:
|
||||
|
||||
@ -310,6 +548,21 @@ For more information about how to use social logins, see: `Satellizer <https://g
|
||||
GOOGLE_SECRET = "somethingsecret"
|
||||
|
||||
|
||||
Metric Providers
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
If you are not using a metric provider you do not need to configure any of these options.
|
||||
|
||||
.. data:: ACTIVE_PROVIDERS
|
||||
:noindex:
|
||||
|
||||
A list of metric plugins slugs to be ativated.
|
||||
|
||||
::
|
||||
|
||||
METRIC_PROVIDERS = ['atlas-metric']
|
||||
|
||||
|
||||
Plugin Specific Options
|
||||
-----------------------
|
||||
|
||||
@ -370,7 +623,13 @@ The following configuration properties are required to use the Digicert issuer p
|
||||
.. data:: DIGICERT_URL
|
||||
:noindex:
|
||||
|
||||
This is the url for the Digicert API
|
||||
This is the url for the Digicert API (e.g. https://www.digicert.com)
|
||||
|
||||
|
||||
.. data:: DIGICERT_ORDER_TYPE
|
||||
:noindex:
|
||||
|
||||
This is the type of certificate to order. (e.g. ssl_plus, ssl_ev_plus see: https://www.digicert.com/services/v2/documentation/order/overview-submit)
|
||||
|
||||
|
||||
.. data:: DIGICERT_API_KEY
|
||||
@ -385,12 +644,6 @@ The following configuration properties are required to use the Digicert issuer p
|
||||
This is the Digicert organization ID tied to your API key
|
||||
|
||||
|
||||
.. data:: DIGICERT_INTERMEDIATE
|
||||
:noindex:
|
||||
|
||||
This is the intermediate to be used for your CA chain
|
||||
|
||||
|
||||
.. data:: DIGICERT_ROOT
|
||||
:noindex:
|
||||
|
||||
@ -403,11 +656,16 @@ The following configuration properties are required to use the Digicert issuer p
|
||||
This is the default validity (in years), if no end date is specified. (Default: 1)
|
||||
|
||||
|
||||
.. data:: DIGICERT_PRIVATE
|
||||
:noindex:
|
||||
|
||||
This is whether or not to issue a private certificate. (Default: False)
|
||||
|
||||
|
||||
CFSSL Issuer Plugin
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The following configuration properties are required to use the the CFSSL issuer plugin.
|
||||
The following configuration properties are required to use the CFSSL issuer plugin.
|
||||
|
||||
.. data:: CFSSL_URL
|
||||
:noindex:
|
||||
@ -481,7 +739,7 @@ STS-AssumeRole
|
||||
|
||||
|
||||
|
||||
Next we will create the the Lemur IAM role.
|
||||
Next we will create the Lemur IAM role.
|
||||
|
||||
.. note::
|
||||
|
||||
@ -731,7 +989,7 @@ and to get help on sub-commands
|
||||
Upgrading Lemur
|
||||
===============
|
||||
|
||||
To upgrade Lemur to the newest release you will need to ensure you have the lastest code and have run any needed
|
||||
To upgrade Lemur to the newest release you will need to ensure you have the latest code and have run any needed
|
||||
database migrations.
|
||||
|
||||
To get the latest code from github run
|
||||
@ -759,7 +1017,7 @@ After you have the latest version of the Lemur code base you must run any needed
|
||||
This will ensure that any needed tables or columns are created or destroyed.
|
||||
|
||||
.. note::
|
||||
Internally, this uses `Alembic <https://alembic.readthedocs.org/en/latest/>`_ to manage database migrations.
|
||||
Internally, this uses `Alembic <http://alembic.zzzcomputing.com/en/latest/>`_ to manage database migrations.
|
||||
|
||||
.. note::
|
||||
By default Alembic looks for the `migrations` folder in the current working directory.The migrations folder is
|
||||
@ -928,6 +1186,31 @@ Digicert
|
||||
https://github.com/opendns/lemur-digicert
|
||||
|
||||
|
||||
InfluxDB
|
||||
--------
|
||||
|
||||
:Authors:
|
||||
Titouan Christophe
|
||||
:Type:
|
||||
Metric
|
||||
:Description:
|
||||
Sends key metrics to InfluxDB
|
||||
:Links:
|
||||
https://github.com/titouanc/lemur-influxdb
|
||||
|
||||
Hashicorp Vault
|
||||
---------------
|
||||
|
||||
:Authors:
|
||||
Ron Cohen
|
||||
:Type:
|
||||
Issuer
|
||||
:Description:
|
||||
Adds support for basic Vault PKI secret backend.
|
||||
:Links:
|
||||
https://github.com/RcRonco/lemur_vault
|
||||
|
||||
|
||||
Have an extension that should be listed here? Submit a `pull request <https://github.com/netflix/lemur>`_ and we'll
|
||||
get it added.
|
||||
|
||||
@ -939,7 +1222,7 @@ Identity and Access Management
|
||||
|
||||
Lemur uses a Role Based Access Control (RBAC) mechanism to control which users have access to which resources. When a
|
||||
user is first created in Lemur they can be assigned one or more roles. These roles are typically dynamically created
|
||||
depending on a external identity provider (Google, LDAP, etc.,) or are hardcoded within Lemur and associated with special
|
||||
depending on an external identity provider (Google, LDAP, etc.), or are hardcoded within Lemur and associated with special
|
||||
meaning.
|
||||
|
||||
Within Lemur there are three main permissions: AdminPermission, CreatorPermission, OwnerPermission. Sub-permissions such
|
||||
|
14
docs/conf.py
14
docs/conf.py
@ -13,12 +13,24 @@
|
||||
# serve to show the default.
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
# Mock packages that cannot be installed on rtd
|
||||
on_rtd = os.environ.get('READTHEDOCS') == 'True'
|
||||
if on_rtd:
|
||||
class Mock(MagicMock):
|
||||
@classmethod
|
||||
def __getattr__(cls, name):
|
||||
return MagicMock()
|
||||
|
||||
MOCK_MODULES = ['ldap']
|
||||
sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES)
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
@ -47,7 +59,7 @@ master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = u'lemur'
|
||||
copyright = u'2015, Netflix Inc.'
|
||||
copyright = u'2018, Netflix Inc.'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
|
@ -48,7 +48,7 @@ of Lemur. You'll want to make sure you have a few things on your local system fi
|
||||
* pip
|
||||
* virtualenv (ideally virtualenvwrapper)
|
||||
* node.js (for npm and building css/javascript)
|
||||
* (Optional) Potgresql
|
||||
+* `PostgreSQL <https://lemur.readthedocs.io/en/latest/quickstart/index.html#setup-postgres>`_
|
||||
|
||||
Once you've got all that, the rest is simple:
|
||||
|
||||
@ -77,6 +77,7 @@ Create a default Lemur configuration just as if this were a production instance:
|
||||
|
||||
::
|
||||
|
||||
lemur create_config
|
||||
lemur init
|
||||
|
||||
You'll likely want to make some changes to the default configuration (we recommend developing against Postgres, for example). Once done, migrate your database using the following command:
|
||||
@ -86,7 +87,13 @@ You'll likely want to make some changes to the default configuration (we recomme
|
||||
lemur upgrade
|
||||
|
||||
|
||||
.. note:: The ``upgrade`` shortcut is simply a shorcut to Alembic's upgrade command.
|
||||
.. note:: The ``upgrade`` shortcut is simply a shortcut to Alembic's upgrade command.
|
||||
|
||||
|
||||
Running tests with Docker and docker-compose
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Alternatively you can use Docker and docker-compose for running the tests with ``docker-compose run test``.
|
||||
|
||||
|
||||
Coding Standards
|
||||
@ -113,6 +120,12 @@ HTML:
|
||||
2 Spaces
|
||||
|
||||
|
||||
Git hooks
|
||||
~~~~~~~~~
|
||||
|
||||
To help developers maintain the above standards, Lemur includes a configuration file for Yelp's `pre-commit <http://pre-commit.com/>`_. This is an optional dependency and is not required in order to contribute to Lemur.
|
||||
|
||||
|
||||
Running the Test Suite
|
||||
----------------------
|
||||
|
||||
@ -156,7 +169,7 @@ This is accomplished with a Gulp task:
|
||||
The gulp task compiles all the JS/CSS/HTML files and opens the Lemur welcome page in your default browsers. Additionally any changes to made to the JS/CSS/HTML with be reloaded in your browsers.
|
||||
|
||||
Developing with Flask
|
||||
----------------------
|
||||
---------------------
|
||||
|
||||
Because Lemur is just Flask, you can use all of the standard Flask functionality. The only difference is you'll be accessing commands that would normally go through manage.py using the ``lemur`` CLI helper instead.
|
||||
|
||||
@ -175,7 +188,7 @@ Schema changes should always introduce the new schema in a commit, and then intr
|
||||
|
||||
Removing columns and tables requires a slightly more painful flow, and should resemble the follow multi-commit flow:
|
||||
|
||||
- Remove all references to the column or table (but dont remove the Model itself)
|
||||
- Remove all references to the column or table (but don't remove the Model itself)
|
||||
- Remove the model code
|
||||
- Remove the table or column
|
||||
|
||||
@ -271,6 +284,31 @@ Domains
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. automodule:: lemur.endpoints.views
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Logs
|
||||
----
|
||||
|
||||
.. automodule:: lemur.logs.views
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
Sources
|
||||
-------
|
||||
|
||||
.. automodule:: lemur.sources.views
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
Internals
|
||||
=========
|
||||
@ -279,4 +317,3 @@ Internals
|
||||
:maxdepth: 2
|
||||
|
||||
internals/lemur
|
||||
|
||||
|
@ -1,15 +1,6 @@
|
||||
certificates Package
|
||||
====================
|
||||
|
||||
:mod:`exceptions` Module
|
||||
------------------------
|
||||
|
||||
.. automodule:: lemur.certificates.exceptions
|
||||
:noindex:
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
:mod:`models` Module
|
||||
--------------------
|
||||
|
||||
|
@ -10,15 +10,6 @@ lemur_verisign Package
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
:mod:`constants` Module
|
||||
-----------------------
|
||||
|
||||
.. automodule:: lemur.plugins.lemur_verisign.constants
|
||||
:noindex:
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
:mod:`plugin` Module
|
||||
--------------------
|
||||
|
||||
|
@ -97,3 +97,18 @@ Subpackages
|
||||
lemur.plugins
|
||||
lemur.roles
|
||||
lemur.users
|
||||
lemur.sources
|
||||
lemur.logs
|
||||
lemur.reporting
|
||||
lemur.tests
|
||||
lemur.deployment
|
||||
lemur.endpoints
|
||||
lemur.defaults
|
||||
lemur.plugins.lemur_acme
|
||||
lemur.plugins.lemur_atlas
|
||||
lemur.plugins.lemur_cryptography
|
||||
lemur.plugins.lemur_digicert
|
||||
lemur.plugins.lemur_java
|
||||
lemur.plugins.lemur_kubernetes
|
||||
lemur.plugins.lemur_openssl
|
||||
lemur.plugins.lemur_slack
|
||||
|
@ -25,7 +25,7 @@ if you want to pull the version using pkg_resources (which is what we recommend)
|
||||
try:
|
||||
VERSION = __import__('pkg_resources') \
|
||||
.get_distribution(__name__).version
|
||||
except Exception, e:
|
||||
except Exception as e:
|
||||
VERSION = 'unknown'
|
||||
|
||||
Inside of ``plugin.py``, you'll declare your Plugin class::
|
||||
@ -70,10 +70,18 @@ at multiple plugins within your package::
|
||||
},
|
||||
)
|
||||
|
||||
Once your plugin files are in place and the ``/www/lemur/setup.py`` file has been modified, you can load your plugin into your instance by reinstalling lemur:
|
||||
::
|
||||
|
||||
(lemur)$cd /www/lemur
|
||||
(lemur)$pip install -e .
|
||||
|
||||
That's it! Users will be able to install your plugin via ``pip install <package name>``.
|
||||
|
||||
.. SeeAlso:: For more information about python packages see `Python Packaging <https://packaging.python.org/en/latest/distributing.html>`_
|
||||
|
||||
.. SeeAlso:: For an example of a plugin operation outside of Lemur's core, see `lemur-digicert <https://github.com/opendns/lemur-digicert>`_
|
||||
|
||||
.. _PluginInterfaces:
|
||||
|
||||
Plugin Interfaces
|
||||
@ -92,10 +100,16 @@ If you have a third party or internal service that creates authorities (EJBCA, e
|
||||
it can treat any issuer plugin as both a source of creating new certificates as well as new authorities.
|
||||
|
||||
|
||||
The `IssuerPlugin` exposes two functions::
|
||||
The `IssuerPlugin` exposes four functions functions::
|
||||
|
||||
def create_certificate(self, options):
|
||||
def create_certificate(self, csr, issuer_options):
|
||||
# requests.get('a third party')
|
||||
def revoke_certificate(self, certificate, comments):
|
||||
# requests.put('a third party')
|
||||
def get_ordered_certificate(self, order_id):
|
||||
# requests.get('already existing certificate')
|
||||
def canceled_ordered_certificate(self, pending_cert, **kwargs):
|
||||
# requests.put('cancel an order that has yet to be issued')
|
||||
|
||||
Lemur will pass a dictionary of all possible options for certificate creation. Including a valid CSR, and the raw options associated with the request.
|
||||
|
||||
@ -131,6 +145,19 @@ The `IssuerPlugin` doesn't have any options like Destination, Source, and Notifi
|
||||
any fields you might need to submit a request to a third party. If there are additional options you need
|
||||
in your plugin feel free to open an issue, or look into adding additional options to issuers yourself.
|
||||
|
||||
Asynchronous Certificates
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
An issuer may take some time to actually issue a certificate for an order. In this case, a `PendingCertificate` is returned, which holds information to recreate a `Certificate` object at a later time. Then, `get_ordered_certificate()` should be run periodically via `python manage.py pending_certs fetch -i all` to attempt to retrieve an ordered certificate::
|
||||
|
||||
def get_ordered_ceriticate(self, order_id):
|
||||
# order_id is the external id of the order, not the external_id of the certificate
|
||||
# retrieve an order, and check if there is an issued certificate attached to it
|
||||
|
||||
`cancel_ordered_certificate()` should be implemented to allow an ordered certificate to be canceled before it is issued::
|
||||
def cancel_ordered_certificate(self, pending_cert, **kwargs):
|
||||
# pending_cert should contain the necessary information to match an order
|
||||
# kwargs can be given to provide information to the issuer for canceling
|
||||
|
||||
Destination
|
||||
-----------
|
||||
|
||||
@ -145,7 +172,7 @@ in the plugins base class like so::
|
||||
|
||||
The DestinationPlugin requires only one function to be implemented::
|
||||
|
||||
def upload(self, cert, private_key, cert_chain, options, **kwargs):
|
||||
def upload(self, name, body, private_key, cert_chain, options, **kwargs):
|
||||
# request.post('a third party')
|
||||
|
||||
Additionally the DestinationPlugin allows the plugin author to add additional options
|
||||
@ -154,25 +181,25 @@ that can be used to help define sub-destinations.
|
||||
For example, if we look at the aws-destination plugin we can see that it defines an `accountNumber` option::
|
||||
|
||||
options = [
|
||||
{
|
||||
'name': 'accountNumber',
|
||||
'type': 'int',
|
||||
'required': True,
|
||||
'validation': '/^[0-9]{12,12}$/',
|
||||
'helpMessage': 'Must be a valid AWS account number!',
|
||||
}
|
||||
{
|
||||
'name': 'accountNumber',
|
||||
'type': 'int',
|
||||
'required': True,
|
||||
'validation': '/^[0-9]{12,12}$/',
|
||||
'helpMessage': 'Must be a valid AWS account number!',
|
||||
}
|
||||
]
|
||||
|
||||
By defining an `accountNumber` we can make this plugin handle many N number of AWS accounts instead of just one.
|
||||
|
||||
The schema for defining plugin options are pretty straightforward:
|
||||
|
||||
- **Name**: name of the variable you wish to present the user, snake case (snakeCase) is preferrred as Lemur
|
||||
- **Name**: name of the variable you wish to present the user, snake case (snakeCase) is preferred as Lemur
|
||||
will parse these and create pretty variable titles
|
||||
- **Type** there are currently four supported variable types
|
||||
- **Int** creates an html integer box for the user to enter integers into
|
||||
- **Str** creates a html text input box
|
||||
- **Boolean** creates a checkbox for the user to signify truithyness
|
||||
- **Boolean** creates a checkbox for the user to signify truthiness
|
||||
- **Select** creates a select box that gives the user a list of options
|
||||
- When used a `available` key must be provided with a list of selectable options
|
||||
- **Required** determines if this option is required, this **must be a boolean value**
|
||||
@ -188,7 +215,7 @@ Notification
|
||||
------------
|
||||
|
||||
Lemur includes the ability to create Email notifications by **default**. These notifications
|
||||
currently come in the form of expiration noticies. Lemur periodically checks certifications expiration dates and
|
||||
currently come in the form of expiration notices. Lemur periodically checks certifications expiration dates and
|
||||
determines if a given certificate is eligible for notification. There are currently only two parameters used to
|
||||
determine if a certificate is eligible; validity expiration (date the certificate is no longer valid) and the number
|
||||
of days the current date (UTC) is from that expiration date.
|
||||
@ -199,10 +226,10 @@ are trying to create a new notification type (audit, failed logins, etc.) this w
|
||||
You would also then need to build additional code to trigger the new notification type.
|
||||
|
||||
The second is `ExpirationNotificationPlugin`, this object inherits from `NotificationPlugin` object.
|
||||
You will most likely want to base your plugin on, if you want to add new channels for expiration notices (Slack, Hipcat, Jira, etc.). It adds default options that are required by
|
||||
by all expiration notifications (interval, unit). This interface expects for the child to define the following function::
|
||||
You will most likely want to base your plugin on, if you want to add new channels for expiration notices (Slack, HipChat, Jira, etc.). It adds default options that are required by
|
||||
all expiration notifications (interval, unit). This interface expects for the child to define the following function::
|
||||
|
||||
def send(self):
|
||||
def send(self, notification_type, message, targets, options, **kwargs):
|
||||
# request.post("some alerting infrastructure")
|
||||
|
||||
|
||||
@ -210,10 +237,10 @@ Source
|
||||
------
|
||||
|
||||
When building Lemur we realized that although it would be nice if every certificate went through Lemur to get issued, but this is not
|
||||
always be the case. Often times there are third parties that will issue certificates on your behalf and these can get deployed
|
||||
always be the case. Oftentimes there are third parties that will issue certificates on your behalf and these can get deployed
|
||||
to infrastructure without any interaction with Lemur. In an attempt to combat this and try to track every certificate, Lemur has a notion of
|
||||
certificate **Sources**. Lemur will contact the source at periodic intervals and attempt to **sync** against the source. This means downloading or discovering any
|
||||
certificate Lemur does not know about and adding the certificate to it's inventory to be tracked and alerted on.
|
||||
certificate Lemur does not know about and adding the certificate to its inventory to be tracked and alerted on.
|
||||
|
||||
The `SourcePlugin` object has one default option of `pollRate`. This controls the number of seconds which to get new certificates.
|
||||
|
||||
@ -225,12 +252,12 @@ The `SourcePlugin` object has one default option of `pollRate`. This controls th
|
||||
|
||||
The `SourcePlugin` object requires implementation of one function::
|
||||
|
||||
def get_certificates(self, **kwargs):
|
||||
def get_certificates(self, options, **kwargs):
|
||||
# request.get("some source of certificates")
|
||||
|
||||
|
||||
.. note::
|
||||
Often times to facilitate code re-use it makes sense put source and destination plugins into one package.
|
||||
Oftentimes to facilitate code re-use it makes sense put source and destination plugins into one package.
|
||||
|
||||
|
||||
Export
|
||||
@ -270,9 +297,9 @@ Augment your setup.py to ensure at least the following:
|
||||
|
||||
setup(
|
||||
# ...
|
||||
install_requires=[
|
||||
install_requires=[
|
||||
'lemur',
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
@ -6,7 +6,7 @@ Common Problems
|
||||
|
||||
In my startup logs I see *'Aborting... Lemur cannot locate db encryption key, is LEMUR_ENCRYPTION_KEYS set?'*
|
||||
You likely have not correctly configured **LEMUR_ENCRYPTION_KEYS**. See
|
||||
:doc:`administration/index` for more information.
|
||||
:doc:`administration` for more information.
|
||||
|
||||
|
||||
I am seeing Lemur's javascript load in my browser but not the CSS.
|
||||
|
@ -18,7 +18,7 @@ that Lemur can then manage.
|
||||
|
||||
.. figure:: create_authority.png
|
||||
|
||||
Enter a authority name and short description about the authority. Enter an owner,
|
||||
Enter an authority name and short description about the authority. Enter an owner,
|
||||
and certificate common name. Depending on the authority and the authority/issuer plugin
|
||||
these values may or may not be used.
|
||||
|
||||
@ -56,7 +56,7 @@ Import an Existing Certificate
|
||||
|
||||
.. figure:: upload_certificate.png
|
||||
|
||||
Enter a owner, short description and public certificate. If there are intermediates and private keys
|
||||
Enter an owner, short description and public certificate. If there are intermediates and private keys
|
||||
Lemur will track them just as it does if the certificate were created through Lemur. Lemur generates
|
||||
a certificate name but you can override that by passing a value to the `Custom Name` field.
|
||||
|
||||
|
@ -54,7 +54,7 @@ Doing a Release
|
||||
doing-a-release
|
||||
|
||||
FAQ
|
||||
----
|
||||
---
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
@ -37,20 +37,20 @@ Entropy
|
||||
-------
|
||||
|
||||
Lemur generates private keys for the certificates it creates. This means that it is vitally important that Lemur has enough entropy to draw from. To generate private keys Lemur uses the python library `Cryptography <https://cryptography.io>`_. In turn Cryptography uses OpenSSL bindings to generate
|
||||
keys just like you might from the OpenSSL command line. OpenSSL draws it's initial entropy from system during startup and uses PRNGs to generate a stream of random bytes (as output by /dev/urandom) whenever it needs to do a cryptographic operation.
|
||||
keys just like you might from the OpenSSL command line. OpenSSL draws its initial entropy from system during startup and uses PRNGs to generate a stream of random bytes (as output by /dev/urandom) whenever it needs to do a cryptographic operation.
|
||||
|
||||
What does all this mean? Well in order for the keys
|
||||
that Lemur generates to be strong, the system needs to interact with the outside world. This is typically accomplished through the systems hardware (thermal, sound, video user-input, etc.) since the physical world is much more "random" than the computer world.
|
||||
|
||||
If you are running Lemur on its own server with its own hardware "bare metal" then the entropy of the system is typically "good enough" for generating keys. If however you are using an VM on shared hardware there is a potential that your initial seed data (data that was initially
|
||||
fed to the PRNG) is not very good. What's more VMs have been known to be unable to inject more entropy into the system once it has been started. This is because there is typically very little interaction with the server once it has been started.
|
||||
If you are running Lemur on its own server with its own hardware "bare metal" then the entropy of the system is typically "good enough" for generating keys. If however you are using a VM on shared hardware there is a potential that your initial seed data (data that was initially
|
||||
fed to the PRNG) is not very good. What's more, VMs have been known to be unable to inject more entropy into the system once it has been started. This is because there is typically very little interaction with the server once it has been started.
|
||||
|
||||
The amount of effort you wish to expend ensuring that Lemur has good entropy to draw from is up to your specific risk tolerance and how Lemur is configured.
|
||||
|
||||
If you wish to generate more entropy for your system we would suggest you take a look at the following resources:
|
||||
|
||||
- `WES-entropy-client <https://github.com/WhitewoodCrypto/WES-entropy-client>`_
|
||||
- `haveaged <http://www.issihosts.com/haveged/>`_
|
||||
- `haveged <http://www.issihosts.com/haveged/>`_
|
||||
|
||||
For additional information about OpenSSL entropy issues:
|
||||
|
||||
@ -72,7 +72,7 @@ Nginx is a very popular choice to serve a Python project:
|
||||
Nginx doesn't run any Python process, it only serves requests from outside to
|
||||
the Python server.
|
||||
|
||||
Therefore there are two steps:
|
||||
Therefore, there are two steps:
|
||||
|
||||
- Run the Python process.
|
||||
- Run Nginx.
|
||||
@ -217,13 +217,30 @@ An example apache config::
|
||||
# HSTS (mod_headers is required) (15768000 seconds = 6 months)
|
||||
Header always set Strict-Transport-Security "max-age=15768000"
|
||||
...
|
||||
|
||||
# Set the lemur DocumentRoot to static/dist
|
||||
DocumentRoot /www/lemur/lemur/static/dist
|
||||
|
||||
# Uncomment to force http 1.0 connections to proxy
|
||||
# SetEnv force-proxy-request-1.0 1
|
||||
|
||||
#Don't keep proxy connections alive
|
||||
SetEnv proxy-nokeepalive 1
|
||||
|
||||
# Only need to do reverse proxy
|
||||
ProxyRequests Off
|
||||
|
||||
# Proxy requests to the api to the lemur service (and sanitize redirects from it)
|
||||
ProxyPass "/api" "http://127.0.0.1:8000/api"
|
||||
ProxyPassReverse "/api" "http://127.0.0.1:8000/api"
|
||||
|
||||
</VirtualHost>
|
||||
|
||||
Also included in the configurations above are several best practices when it comes to deploying TLS. Things like enabling
|
||||
HSTS, disabling vulnerable ciphers are all good ideas when it comes to deploying Lemur into a production environment.
|
||||
|
||||
.. note::
|
||||
This is a rather incomplete apache config for running Lemur (needs mod_wsgi etc.,), if you have a working apache config please let us know!
|
||||
This is a rather incomplete apache config for running Lemur (needs mod_wsgi etc.), if you have a working apache config please let us know!
|
||||
|
||||
.. seealso::
|
||||
`Mozilla SSL Configuration Generator <https://mozilla.github.io/server-side-tls/ssl-config-generator/>`_
|
||||
@ -240,10 +257,10 @@ most of the time), but here is a quick overview on how to use it.
|
||||
Create a configuration file named supervisor.ini::
|
||||
|
||||
[unix_http_server]
|
||||
file=/tmp/supervisor.sock;
|
||||
file=/tmp/supervisor.sock
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///tmp/supervisor.sock;
|
||||
serverurl=unix:///tmp/supervisor.sock
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
|
||||
@ -314,6 +331,6 @@ How often you run these commands is largely up to the user. `notify` and `check_
|
||||
|
||||
Example cron entries::
|
||||
|
||||
0 22 * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur notify
|
||||
*/15 * * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur sync -s all
|
||||
0 22 * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur check_revoked
|
||||
0 22 * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur notify expirations
|
||||
*/15 * * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur source sync -s all
|
||||
0 22 * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur certificate check_revoked
|
||||
|
@ -12,11 +12,11 @@ Dependencies
|
||||
Some basic prerequisites which you'll need in order to run Lemur:
|
||||
|
||||
* A UNIX-based operating system (we test on Ubuntu, develop on OS X)
|
||||
* Python 2.7
|
||||
* PostgreSQL
|
||||
* Python 3.5 or greater
|
||||
* PostgreSQL 9.4 or greater
|
||||
* Nginx
|
||||
|
||||
.. note:: Lemur was built with in AWS in mind. This means that things such as databases (RDS), mail (SES), and TLS (ELB), are largely handled for us. Lemur does **not** require AWS to function. Our guides and documentation try to be be as generic as possible and are not intended to document every step of launching Lemur into a given environment.
|
||||
.. note:: Lemur was built with in AWS in mind. This means that things such as databases (RDS), mail (SES), and TLS (ELB), are largely handled for us. Lemur does **not** require AWS to function. Our guides and documentation try to be as generic as possible and are not intended to document every step of launching Lemur into a given environment.
|
||||
|
||||
|
||||
Installing Build Dependencies
|
||||
@ -27,10 +27,13 @@ If installing Lemur on a bare Ubuntu OS you will need to grab the following pack
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo apt-get update
|
||||
$ sudo apt-get install nodejs-legacy python-pip python-dev libpq-dev build-essential libssl-dev libffi-dev nginx git supervisor npm postgresql
|
||||
$ sudo apt-get install nodejs nodejs-legacy python-pip python-dev python3-dev libpq-dev build-essential libssl-dev libffi-dev libsasl2-dev libldap2-dev nginx git supervisor npm postgresql
|
||||
|
||||
.. note:: PostgreSQL is only required if your database is going to be on the same host as the webserver. npm is needed if you're installing Lemur from the source (e.g., from git).
|
||||
|
||||
.. note:: Installing node from a package manager may creat the nodejs bin at /usr/bin/nodejs instead of /usr/bin/node If that is the case run the following
|
||||
$ sudo ln -s /user/bin/nodejs /usr/bin/node
|
||||
|
||||
Now, install Python ``virtualenv`` package:
|
||||
|
||||
.. code-block:: bash
|
||||
@ -52,6 +55,10 @@ Clone Lemur inside the just created directory and give yourself write permission
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo useradd lemur
|
||||
$ sudo passwd lemur
|
||||
$ sudo mkdir /home/lemur
|
||||
$ sudo chown lemur:lemur /home/lemur
|
||||
$ sudo git clone https://github.com/Netflix/lemur
|
||||
$ sudo chown -R lemur lemur/
|
||||
|
||||
@ -59,7 +66,8 @@ Create the virtual environment, activate it and enter the Lemur's directory:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ virtualenv lemur
|
||||
$ su lemur
|
||||
$ virtualenv -p python3 lemur
|
||||
$ source /www/lemur/bin/activate
|
||||
$ cd lemur
|
||||
|
||||
@ -79,11 +87,23 @@ And then run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ make develop
|
||||
$ make release
|
||||
|
||||
.. note:: This command will install npm dependencies as well as compile static assets.
|
||||
|
||||
|
||||
You may also run with the urlContextPath variable set. If this is set it will add the desired context path for subsequent calls back to lemur. This will only edit the front end code for calls back to the server, you will have to make sure the server knows about these routes.
|
||||
::
|
||||
|
||||
Example:
|
||||
urlContextPath=lemur
|
||||
/api/1/auth/providers -> /lemur/api/1/auth/providers
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ make release urlContextPath={desired context path}
|
||||
|
||||
|
||||
Creating a configuration
|
||||
------------------------
|
||||
|
||||
@ -105,9 +125,24 @@ Update your configuration
|
||||
|
||||
Once created, you will need to update the configuration file with information about your environment, such as which database to talk to, where keys are stored etc.
|
||||
|
||||
.. note:: If you are unfamiliar with with the SQLALCHEMY_DATABASE_URI string it can be broken up like so:
|
||||
.. code-block:: bash
|
||||
|
||||
$ vi ~/.lemur/lemur.conf.py
|
||||
|
||||
.. note:: If you are unfamiliar with the SQLALCHEMY_DATABASE_URI string it can be broken up like so:
|
||||
``postgresql://userame:password@<database-fqdn>:<database-port>/<database-name>``
|
||||
|
||||
Before Lemur will run you need to fill in a few required variables in the configuration file:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
LEMUR_SECURITY_TEAM_EMAIL
|
||||
#/the e-mail address needs to be enclosed in quotes
|
||||
LEMUR_DEFAULT_COUNTRY
|
||||
LEMUR_DEFAULT_STATE
|
||||
LEMUR_DEFAULT_LOCATION
|
||||
LEMUR_DEFAULT_ORGANIZATION
|
||||
LEMUR_DEFAULT_ORGANIZATIONAL_UNIT
|
||||
|
||||
Setup Postgres
|
||||
--------------
|
||||
@ -119,9 +154,8 @@ First, set a password for the postgres user. For this guide, we will use ``lemu
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo -u postgres -i
|
||||
# \password postgres
|
||||
Enter new password: lemur
|
||||
Enter it again: lemur
|
||||
$ psql
|
||||
postgres=# CREATE USER lemur WITH PASSWORD 'lemur';
|
||||
|
||||
Once successful, type CTRL-D to exit the Postgres shell.
|
||||
|
||||
@ -134,7 +168,10 @@ Next, we will create our new database:
|
||||
.. _InitializingLemur:
|
||||
|
||||
.. note::
|
||||
For this guide we assume you will use the `postgres` user to connect to your database, when deploying to a VM or container this is often all you will need. If you have a shared database it is recommend you give Lemur it's own user.
|
||||
For this guide we assume you will use the `postgres` user to connect to your database, when deploying to a VM or container this is often all you will need. If you have a shared database it is recommend you give Lemur its own user.
|
||||
|
||||
.. note::
|
||||
Postgres 9.4 or greater is required as Lemur relies advanced data columns (e.g. JSON Column type)
|
||||
|
||||
Initializing Lemur
|
||||
------------------
|
||||
@ -234,8 +271,8 @@ Configuring Supervisor couldn't be more simple. Just point it to the ``lemur`` e
|
||||
autostart=true
|
||||
autorestart=true
|
||||
redirect_stderr=true
|
||||
stdout_logfile syslog
|
||||
stderr_logfile syslog
|
||||
stdout_logfile=syslog
|
||||
stderr_logfile=syslog
|
||||
|
||||
See :ref:`Using Supervisor <UsingSupervisor>` for more details on using Supervisor.
|
||||
|
||||
|
@ -1,54 +0,0 @@
|
||||
alabaster==0.7.8
|
||||
alembic==0.8.6
|
||||
aniso8601==1.1.0
|
||||
arrow==0.7.0
|
||||
Babel==2.3.4
|
||||
bcrypt==2.0.0
|
||||
beautifulsoup4==4.4.1
|
||||
blinker==1.4
|
||||
boto==2.38.0
|
||||
cffi==1.7.0
|
||||
cryptography==1.3.2
|
||||
docutils==0.12
|
||||
enum34==1.1.6
|
||||
Flask==0.10.1
|
||||
Flask-Bcrypt==0.7.1
|
||||
Flask-Mail==0.9.1
|
||||
Flask-Migrate==1.7.0
|
||||
Flask-Principal==0.4.0
|
||||
Flask-RESTful==0.3.3
|
||||
Flask-Script==2.0.5
|
||||
Flask-SQLAlchemy==2.1
|
||||
future==0.15.2
|
||||
gunicorn==19.4.1
|
||||
idna==2.1
|
||||
imagesize==0.7.1
|
||||
inflection==0.3.1
|
||||
ipaddress==1.0.16
|
||||
itsdangerous==0.24
|
||||
Jinja2==2.8
|
||||
lockfile==0.12.2
|
||||
Mako==1.0.4
|
||||
MarkupSafe==0.23
|
||||
marshmallow==2.4.0
|
||||
marshmallow-sqlalchemy==0.8.0
|
||||
psycopg2==2.6.1
|
||||
pyasn1==0.1.9
|
||||
pycparser==2.14
|
||||
pycrypto==2.6.1
|
||||
Pygments==2.1.3
|
||||
PyJWT==1.4.0
|
||||
pyOpenSSL==0.15.1
|
||||
python-dateutil==2.5.3
|
||||
python-editor==1.0.1
|
||||
pytz==2016.4
|
||||
requests==2.9.1
|
||||
six==1.10.0
|
||||
snowballstemmer==1.2.1
|
||||
Sphinx==1.4.4
|
||||
sphinx-rtd-theme==0.1.9
|
||||
sphinxcontrib-httpdomain==1.5.0
|
||||
SQLAlchemy==1.0.13
|
||||
SQLAlchemy-Utils==0.31.4
|
||||
Werkzeug==0.11.10
|
||||
xmltodict==0.9.2
|
@ -60,7 +60,7 @@ and public disclosure may be shortened considerably.
|
||||
|
||||
The list of people and organizations who receives advanced notification of
|
||||
security issues is not, and will not, be made public. This list generally
|
||||
consists of high profile downstream distributors and is entirely at the
|
||||
consists of high-profile downstream distributors and is entirely at the
|
||||
discretion of the ``lemur`` team.
|
||||
|
||||
.. _`master`: https://github.com/Netflix/lemur
|
||||
|
@ -1,13 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
var gulp = require('gulp'),
|
||||
minifycss = require('gulp-minify-css'),
|
||||
concat = require('gulp-concat'),
|
||||
less = require('gulp-less'),
|
||||
gulpif = require('gulp-if'),
|
||||
order = require('gulp-order'),
|
||||
gutil = require('gulp-util'),
|
||||
rename = require('gulp-rename'),
|
||||
foreach = require('gulp-foreach'),
|
||||
debug = require('gulp-debug'),
|
||||
path =require('path'),
|
||||
merge = require('merge-stream'),
|
||||
del = require('del'),
|
||||
@ -27,7 +26,8 @@ var gulp = require('gulp'),
|
||||
minifyHtml = require('gulp-minify-html'),
|
||||
bowerFiles = require('main-bower-files'),
|
||||
karma = require('karma'),
|
||||
replace = require('gulp-replace');
|
||||
replace = require('gulp-replace'),
|
||||
argv = require('yargs').argv;
|
||||
|
||||
gulp.task('default', ['clean'], function () {
|
||||
gulp.start('fonts', 'styles');
|
||||
@ -89,9 +89,9 @@ gulp.task('dev:styles', function () {
|
||||
.pipe(gulpif(isBootswatchFile, foreach(function (stream, file) {
|
||||
var themeName = path.basename(path.dirname(file.path)),
|
||||
content = replaceAll(baseContent, '$theme$', themeName),
|
||||
file = string_src('bootstrap-' + themeName + '.less', content);
|
||||
file2 = string_src('bootstrap-' + themeName + '.less', content);
|
||||
|
||||
return file;
|
||||
return file2;
|
||||
})))
|
||||
.pipe(less())
|
||||
.pipe(gulpif(isBootstrapFile, foreach(function (stream, file) {
|
||||
@ -101,7 +101,7 @@ gulp.task('dev:styles', function () {
|
||||
// http://stackoverflow.com/questions/21719833/gulp-how-to-add-src-files-in-the-middle-of-a-pipe
|
||||
// https://github.com/gulpjs/gulp/blob/master/docs/recipes/using-multiple-sources-in-one-task.md
|
||||
return merge(stream, gulp.src(['.tmp/styles/font-awesome.css', '.tmp/styles/lemur.css']))
|
||||
.pipe(concat('style-' + themeName + ".css"));
|
||||
.pipe(concat('style-' + themeName + '.css'));
|
||||
})))
|
||||
.pipe(plumber())
|
||||
.pipe(concat('styles.css'))
|
||||
@ -113,7 +113,7 @@ gulp.task('dev:styles', function () {
|
||||
|
||||
// http://stackoverflow.com/questions/1144783/replacing-all-occurrences-of-a-string-in-javascript
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
|
||||
return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1');
|
||||
}
|
||||
|
||||
function replaceAll(string, find, replace) {
|
||||
@ -123,7 +123,7 @@ function replaceAll(string, find, replace) {
|
||||
function string_src(filename, string) {
|
||||
var src = require('stream').Readable({ objectMode: true });
|
||||
src._read = function () {
|
||||
this.push(new gutil.File({ cwd: "", base: "", path: filename, contents: new Buffer(string) }));
|
||||
this.push(new gutil.File({ cwd: '', base: '', path: filename, contents: new Buffer(string) }));
|
||||
this.push(null);
|
||||
};
|
||||
return src;
|
||||
@ -144,26 +144,18 @@ gulp.task('build:extras', function () {
|
||||
function injectHtml(isDev) {
|
||||
return gulp.src('lemur/static/app/index.html')
|
||||
.pipe(
|
||||
inject(gulp.src(bowerFiles({ base: 'app' }), {
|
||||
read: false
|
||||
}), {
|
||||
inject(gulp.src(bowerFiles({ base: 'app' })), {
|
||||
starttag: '<!-- inject:bower:{{ext}} -->',
|
||||
addRootSlash: false,
|
||||
ignorePath: isDev ? ['lemur/static/app/', '.tmp/'] : null
|
||||
})
|
||||
)
|
||||
.pipe(inject(gulp.src(['lemur/static/app/angular/**/*.js'], {
|
||||
read: false
|
||||
}), {
|
||||
read: false,
|
||||
.pipe(inject(gulp.src(['lemur/static/app/angular/**/*.js']), {
|
||||
starttag: '<!-- inject:{{ext}} -->',
|
||||
addRootSlash: false,
|
||||
ignorePath: isDev ? ['lemur/static/app/', '.tmp/'] : null
|
||||
}))
|
||||
.pipe(inject(gulp.src(['.tmp/styles/**/*.css'], {
|
||||
read: false
|
||||
}), {
|
||||
read: false,
|
||||
.pipe(inject(gulp.src(['.tmp/styles/**/*.css']), {
|
||||
starttag: '<!-- inject:{{ext}} -->',
|
||||
addRootSlash: false,
|
||||
ignorePath: isDev ? ['lemur/static/app/', '.tmp/'] : null
|
||||
@ -171,13 +163,11 @@ function injectHtml(isDev) {
|
||||
.pipe(
|
||||
gulpif(!isDev,
|
||||
inject(gulp.src('lemur/static/dist/ngviews/ngviews.min.js'), {
|
||||
read: false,
|
||||
starttag: '<!-- inject:ngviews -->',
|
||||
addRootSlash: false
|
||||
})
|
||||
)
|
||||
)
|
||||
.pipe(gulp.dest('.tmp/'));
|
||||
).pipe(gulp.dest('.tmp/'));
|
||||
}
|
||||
|
||||
gulp.task('dev:inject', ['dev:styles', 'dev:scripts'], function () {
|
||||
@ -200,23 +190,17 @@ gulp.task('build:ngviews', function () {
|
||||
});
|
||||
|
||||
gulp.task('build:html', ['dev:styles', 'dev:scripts', 'build:ngviews', 'build:inject'], function () {
|
||||
var jsFilter = filter('**/*.js');
|
||||
var cssFilter = filter('**/*.css');
|
||||
|
||||
var assets = useref.assets();
|
||||
var jsFilter = filter(['**/*.js'], {'restore': true});
|
||||
var cssFilter = filter(['**/*.css'], {'restore': true});
|
||||
|
||||
return gulp.src('.tmp/index.html')
|
||||
.pipe(assets)
|
||||
.pipe(rev())
|
||||
.pipe(jsFilter)
|
||||
.pipe(ngAnnotate())
|
||||
.pipe(jsFilter.restore())
|
||||
.pipe(jsFilter.restore)
|
||||
.pipe(cssFilter)
|
||||
.pipe(csso())
|
||||
.pipe(cssFilter.restore())
|
||||
.pipe(assets.restore())
|
||||
.pipe(cssFilter.restore)
|
||||
.pipe(useref())
|
||||
.pipe(revReplace())
|
||||
.pipe(gulp.dest('lemur/static/dist'))
|
||||
.pipe(size());
|
||||
});
|
||||
@ -242,10 +226,40 @@ gulp.task('package:strip', function () {
|
||||
.pipe(replace('http:\/\/localhost:3000', ''))
|
||||
.pipe(replace('http:\/\/localhost:8000', ''))
|
||||
.pipe(useref())
|
||||
.pipe(revReplace())
|
||||
.pipe(gulp.dest('lemur/static/dist/scripts'))
|
||||
.pipe(size());
|
||||
});
|
||||
|
||||
gulp.task('addUrlContextPath',['addUrlContextPath:revreplace'], function(){
|
||||
var urlContextPathExists = argv.urlContextPath ? true : false;
|
||||
['lemur/static/dist/scripts/main*.js',
|
||||
'lemur/static/dist/angular/**/*.html']
|
||||
.forEach(function(file){
|
||||
return gulp.src(file)
|
||||
.pipe(gulpif(urlContextPathExists, replace('api/', argv.urlContextPath + '/api/')))
|
||||
.pipe(gulpif(urlContextPathExists, replace('angular/', argv.urlContextPath + '/angular/')))
|
||||
.pipe(gulp.dest(function(file){
|
||||
return file.base;
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
gulp.task('addUrlContextPath:revision', function(){
|
||||
return gulp.src(['lemur/static/dist/**/*.css','lemur/static/dist/**/*.js'])
|
||||
.pipe(rev())
|
||||
.pipe(gulp.dest('lemur/static/dist'))
|
||||
.pipe(rev.manifest())
|
||||
.pipe(gulp.dest('lemur/static/dist'))
|
||||
})
|
||||
|
||||
gulp.task('addUrlContextPath:revreplace', ['addUrlContextPath:revision'], function(){
|
||||
var manifest = gulp.src("lemur/static/dist/rev-manifest.json");
|
||||
var urlContextPathExists = argv.urlContextPath ? true : false;
|
||||
return gulp.src( "lemur/static/dist/index.html")
|
||||
.pipe(gulpif(urlContextPathExists, revReplace({prefix: argv.urlContextPath + '/', manifest: manifest}, revReplace({manifest: manifest}))))
|
||||
.pipe(gulp.dest('lemur/static/dist'));
|
||||
})
|
||||
|
||||
|
||||
gulp.task('build', ['build:ngviews', 'build:inject', 'build:images', 'build:fonts', 'build:html', 'build:extras']);
|
||||
gulp.task('package', ['package:strip']);
|
||||
gulp.task('package', ['addUrlContextPath', 'package:strip']);
|
@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ['PYFLAKES_NODOCTEST'] = '1'
|
||||
|
||||
# pep8.py uses sys.argv to find setup.cfg
|
||||
sys.argv = [os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)]
|
||||
|
||||
# git usurbs your bin path for hooks and will always run system python
|
||||
if 'VIRTUAL_ENV' in os.environ:
|
||||
site_packages = glob.glob(
|
||||
'%s/lib/*/site-packages' % os.environ['VIRTUAL_ENV'])[0]
|
||||
sys.path.insert(0, site_packages)
|
||||
|
||||
|
||||
def py_lint(files_modified):
|
||||
from flake8.engine import get_style_guide
|
||||
|
||||
# remove non-py files and files which no longer exist
|
||||
files_modified = filter(lambda x: x.endswith('.py'), files_modified)
|
||||
|
||||
flake8_style = get_style_guide(parse_argv=True)
|
||||
report = flake8_style.check_files(files_modified)
|
||||
|
||||
return report.total_errors != 0
|
||||
|
||||
|
||||
def main():
|
||||
from flake8.hooks import run
|
||||
|
||||
gitcmd = "git diff-index --cached --name-only HEAD"
|
||||
|
||||
_, files_modified, _ = run(gitcmd)
|
||||
|
||||
files_modified = filter(lambda x: os.path.exists(x), files_modified)
|
||||
|
||||
if py_lint(files_modified):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
@ -9,10 +9,10 @@ __title__ = "lemur"
|
||||
__summary__ = ("Certificate management and orchestration service")
|
||||
__uri__ = "https://github.com/Netflix/lemur"
|
||||
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.7.0"
|
||||
|
||||
__author__ = "The Lemur developers"
|
||||
__email__ = "security@netflix.com"
|
||||
|
||||
__license__ = "Apache License, Version 2.0"
|
||||
__copyright__ = "Copyright 2015 {0}".format(__author__)
|
||||
__copyright__ = "Copyright 2018 {0}".format(__author__)
|
||||
|
@ -1,14 +1,15 @@
|
||||
"""
|
||||
.. module: lemur
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
||||
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function
|
||||
import time
|
||||
from flask import g, request
|
||||
|
||||
from lemur import factory
|
||||
from lemur.extensions import metrics
|
||||
@ -25,6 +26,10 @@ from lemur.plugins.views import mod as plugins_bp
|
||||
from lemur.notifications.views import mod as notifications_bp
|
||||
from lemur.sources.views import mod as sources_bp
|
||||
from lemur.endpoints.views import mod as endpoints_bp
|
||||
from lemur.logs.views import mod as logs_bp
|
||||
from lemur.api_keys.views import mod as api_key_bp
|
||||
from lemur.pending_certificates.views import mod as pending_certificates_bp
|
||||
from lemur.dns_providers.views import mod as dns_providers_bp
|
||||
|
||||
from lemur.__about__ import (
|
||||
__author__, __copyright__, __email__, __license__, __summary__, __title__,
|
||||
@ -49,7 +54,11 @@ LEMUR_BLUEPRINTS = (
|
||||
plugins_bp,
|
||||
notifications_bp,
|
||||
sources_bp,
|
||||
endpoints_bp
|
||||
endpoints_bp,
|
||||
logs_bp,
|
||||
api_key_bp,
|
||||
pending_certificates_bp,
|
||||
dns_providers_bp,
|
||||
)
|
||||
|
||||
|
||||
@ -66,21 +75,39 @@ def configure_hook(app):
|
||||
:return:
|
||||
"""
|
||||
from flask import jsonify
|
||||
from werkzeug.exceptions import default_exceptions
|
||||
from lemur.decorators import crossdomain
|
||||
if app.config.get('CORS'):
|
||||
@app.after_request
|
||||
@crossdomain(origin=u"http://localhost:3000", methods=['PUT', 'HEAD', 'GET', 'POST', 'OPTIONS', 'DELETE'])
|
||||
def after(response):
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_error(e):
|
||||
code = 500
|
||||
if isinstance(e, HTTPException):
|
||||
code = e.code
|
||||
|
||||
app.logger.exception(e)
|
||||
return jsonify(error=str(e)), code
|
||||
|
||||
@app.before_request
|
||||
def before_request():
|
||||
g.request_start_time = time.time()
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
# Return early if we don't have the start time
|
||||
if not hasattr(g, 'request_start_time'):
|
||||
return response
|
||||
|
||||
def make_json_handler(code):
|
||||
def json_handler(error):
|
||||
metrics.send('{}_status_code'.format(code), 'counter', 1)
|
||||
response = jsonify(message=str(error))
|
||||
response.status_code = code
|
||||
return response
|
||||
return json_handler
|
||||
# Get elapsed time in milliseconds
|
||||
elapsed = time.time() - g.request_start_time
|
||||
elapsed = int(round(1000 * elapsed))
|
||||
|
||||
for code, value in default_exceptions.items():
|
||||
app.error_handler_spec[None][code] = make_json_handler(code)
|
||||
# Collect request/response tags
|
||||
tags = {
|
||||
'endpoint': request.endpoint,
|
||||
'request_method': request.method.lower(),
|
||||
'status_code': response.status_code
|
||||
}
|
||||
|
||||
# Record our response time metric
|
||||
metrics.send('response_time', 'TIMER', elapsed, metric_tags=tags)
|
||||
metrics.send('status_code_{}'.format(response.status_code), 'counter', 1)
|
||||
return response
|
||||
|
@ -1,64 +0,0 @@
|
||||
"""
|
||||
.. module: lemur.analyze.service
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
# def analyze(endpoints, truststores):
|
||||
# results = {"headings": ["Endpoint"],
|
||||
# "results": [],
|
||||
# "time": datetime.now().strftime("#Y%m%d %H:%M:%S")}
|
||||
#
|
||||
# for store in truststores:
|
||||
# results['headings'].append(os.path.basename(store))
|
||||
#
|
||||
# for endpoint in endpoints:
|
||||
# result_row = [endpoint]
|
||||
# for store in truststores:
|
||||
# result = {'details': []}
|
||||
#
|
||||
# tests = []
|
||||
# for region, ip in REGIONS.items():
|
||||
# try:
|
||||
# domain = dns.name.from_text(endpoint)
|
||||
# if not domain.is_absolute():
|
||||
# domain = domain.concatenate(dns.name.root)
|
||||
#
|
||||
# my_resolver = dns.resolver.Resolver()
|
||||
# my_resolver.nameservers = [ip]
|
||||
# answer = my_resolver.query(domain)
|
||||
#
|
||||
# #force the testing of regional enpoints by changing the dns server
|
||||
# response = requests.get('https://' + str(answer[0]), verify=store)
|
||||
# tests.append('pass')
|
||||
# result['details'].append("{}: SSL testing completed without errors".format(region))
|
||||
#
|
||||
# except SSLError as e:
|
||||
# log.debug(e)
|
||||
# if 'hostname' in str(e):
|
||||
# tests.append('pass')
|
||||
# result['details'].append(
|
||||
# "{}: This test passed ssl negotiation but failed hostname verification because \
|
||||
# the hostname is not included in the certificate".format(region))
|
||||
# elif 'certificate verify failed' in str(e):
|
||||
# tests.append('fail')
|
||||
# result['details'].append("{}: This test failed to verify the SSL certificate".format(region))
|
||||
# else:
|
||||
# tests.append('fail')
|
||||
# result['details'].append("{}: {}".format(region, str(e)))
|
||||
#
|
||||
# except Exception as e:
|
||||
# log.debug(e)
|
||||
# tests.append('fail')
|
||||
# result['details'].append("{}: {}".format(region, str(e)))
|
||||
#
|
||||
# #any failing tests fails the whole endpoint
|
||||
# if 'fail' in tests:
|
||||
# result['test'] = 'fail'
|
||||
# else:
|
||||
# result['test'] = 'pass'
|
||||
#
|
||||
# result_row.append(result)
|
||||
# results['results'].append(result_row)
|
||||
# return results
|
||||
#
|
41
lemur/api_keys/cli.py
Normal file
41
lemur/api_keys/cli.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""
|
||||
.. module: lemur.api_keys.cli
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Eric Coan <kungfury@instructure.com>
|
||||
"""
|
||||
from flask_script import Manager
|
||||
from lemur.api_keys import service as api_key_service
|
||||
from lemur.auth.service import create_token
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
manager = Manager(usage="Handles all api key related tasks.")
|
||||
|
||||
|
||||
@manager.option('-u', '--user-id', dest='uid', help='The User ID this access key belongs too.')
|
||||
@manager.option('-n', '--name', dest='name', help='The name of this API Key.')
|
||||
@manager.option('-t', '--ttl', dest='ttl', help='The TTL of this API Key. -1 for forever.')
|
||||
def create(uid, name, ttl):
|
||||
"""
|
||||
Create a new api key for a user.
|
||||
:return:
|
||||
"""
|
||||
print("[+] Creating a new api key.")
|
||||
key = api_key_service.create(user_id=uid, name=name,
|
||||
ttl=ttl, issued_at=int(datetime.utcnow().timestamp()), revoked=False)
|
||||
print("[+] Successfully created a new api key. Generating a JWT...")
|
||||
jwt = create_token(uid, key.id, key.ttl)
|
||||
print("[+] Your JWT is: {jwt}".format(jwt=jwt))
|
||||
|
||||
|
||||
@manager.option('-a', '--api-key-id', dest='aid', help='The API Key ID to revoke.')
|
||||
def revoke(aid):
|
||||
"""
|
||||
Revokes an api key for a user.
|
||||
:return:
|
||||
"""
|
||||
print("[-] Revoking the API Key api key.")
|
||||
api_key_service.revoke(aid=aid)
|
||||
print("[+] Successfully revoked the api key")
|
25
lemur/api_keys/models.py
Normal file
25
lemur/api_keys/models.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""
|
||||
.. module: lemur.api_keys.models
|
||||
:platform: Unix
|
||||
:synopsis: This module contains all of the models need to create an api key within Lemur.
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Eric Coan <kungfury@instructure.com>
|
||||
"""
|
||||
from sqlalchemy import BigInteger, Boolean, Column, ForeignKey, Integer, String
|
||||
|
||||
from lemur.database import db
|
||||
|
||||
|
||||
class ApiKey(db.Model):
|
||||
__tablename__ = 'api_keys'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String)
|
||||
user_id = Column(Integer, ForeignKey('users.id'))
|
||||
ttl = Column(BigInteger)
|
||||
issued_at = Column(BigInteger)
|
||||
revoked = Column(Boolean)
|
||||
|
||||
def __repr__(self):
|
||||
return "ApiKey(name={name}, user_id={user_id}, ttl={ttl}, issued_at={iat}, revoked={revoked})".format(
|
||||
user_id=self.user_id, name=self.name, ttl=self.ttl, iat=self.issued_at, revoked=self.revoked)
|
57
lemur/api_keys/schemas.py
Normal file
57
lemur/api_keys/schemas.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""
|
||||
.. module: lemur.api_keys.schemas
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Eric Coan <kungfury@instructure.com>
|
||||
"""
|
||||
from flask import g
|
||||
from marshmallow import fields
|
||||
|
||||
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
|
||||
from lemur.users.schemas import UserNestedOutputSchema, UserInputSchema
|
||||
|
||||
|
||||
def current_user_id():
|
||||
return {'id': g.current_user.id, 'email': g.current_user.email, 'username': g.current_user.username}
|
||||
|
||||
|
||||
class ApiKeyInputSchema(LemurInputSchema):
|
||||
name = fields.String(required=False)
|
||||
user = fields.Nested(UserInputSchema, missing=current_user_id, default=current_user_id)
|
||||
ttl = fields.Integer()
|
||||
|
||||
|
||||
class ApiKeyRevokeSchema(LemurInputSchema):
|
||||
id = fields.Integer(required=True)
|
||||
name = fields.String()
|
||||
user = fields.Nested(UserInputSchema, required=True)
|
||||
revoked = fields.Boolean()
|
||||
ttl = fields.Integer()
|
||||
issued_at = fields.Integer(required=False)
|
||||
|
||||
|
||||
class UserApiKeyInputSchema(LemurInputSchema):
|
||||
name = fields.String(required=False)
|
||||
ttl = fields.Integer()
|
||||
|
||||
|
||||
class ApiKeyOutputSchema(LemurOutputSchema):
|
||||
jwt = fields.String()
|
||||
|
||||
|
||||
class ApiKeyDescribedOutputSchema(LemurOutputSchema):
|
||||
id = fields.Integer()
|
||||
name = fields.String()
|
||||
user = fields.Nested(UserNestedOutputSchema)
|
||||
ttl = fields.Integer()
|
||||
issued_at = fields.Integer()
|
||||
revoked = fields.Boolean()
|
||||
|
||||
|
||||
api_key_input_schema = ApiKeyInputSchema()
|
||||
api_key_revoke_schema = ApiKeyRevokeSchema()
|
||||
api_key_output_schema = ApiKeyOutputSchema()
|
||||
api_keys_output_schema = ApiKeyDescribedOutputSchema(many=True)
|
||||
api_key_described_output_schema = ApiKeyDescribedOutputSchema()
|
||||
user_api_key_input_schema = UserApiKeyInputSchema()
|
97
lemur/api_keys/service.py
Normal file
97
lemur/api_keys/service.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""
|
||||
.. module: lemur.api_keys.service
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Eric Coan <kungfury@instructure.com>
|
||||
"""
|
||||
from lemur import database
|
||||
from lemur.api_keys.models import ApiKey
|
||||
|
||||
|
||||
def get(aid):
|
||||
"""
|
||||
Retrieves an api key by its ID.
|
||||
:param aid: The access key id to get.
|
||||
:return:
|
||||
"""
|
||||
return database.get(ApiKey, aid)
|
||||
|
||||
|
||||
def delete(access_key):
|
||||
"""
|
||||
Delete an access key. This is one way to remove a key, though you probably should just set revoked.
|
||||
:param access_key:
|
||||
:return:
|
||||
"""
|
||||
database.delete(access_key)
|
||||
|
||||
|
||||
def revoke(aid):
|
||||
"""
|
||||
Revokes an api key.
|
||||
:param aid:
|
||||
:return:
|
||||
"""
|
||||
api_key = get(aid)
|
||||
setattr(api_key, 'revoked', False)
|
||||
|
||||
return database.update(api_key)
|
||||
|
||||
|
||||
def get_all_api_keys():
|
||||
"""
|
||||
Retrieves all Api Keys.
|
||||
:return:
|
||||
"""
|
||||
return ApiKey.query.all()
|
||||
|
||||
|
||||
def create(**kwargs):
|
||||
"""
|
||||
Creates a new API Key.
|
||||
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
api_key = ApiKey(**kwargs)
|
||||
database.create(api_key)
|
||||
return api_key
|
||||
|
||||
|
||||
def update(api_key, **kwargs):
|
||||
"""
|
||||
Updates an api key.
|
||||
:param api_key:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
for key, value in kwargs.items():
|
||||
setattr(api_key, key, value)
|
||||
|
||||
return database.update(api_key)
|
||||
|
||||
|
||||
def render(args):
|
||||
"""
|
||||
Helper to parse REST Api requests
|
||||
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
query = database.session_query(ApiKey)
|
||||
user_id = args.pop('user_id', None)
|
||||
aid = args.pop('id', None)
|
||||
has_permission = args.pop('has_permission', False)
|
||||
requesting_user_id = args.pop('requesting_user_id')
|
||||
|
||||
if user_id:
|
||||
query = query.filter(ApiKey.user_id == user_id)
|
||||
|
||||
if aid:
|
||||
query = query.filter(ApiKey.id == aid)
|
||||
|
||||
if not has_permission:
|
||||
query = query.filter(ApiKey.user_id == requesting_user_id)
|
||||
|
||||
return database.sort_and_page(query, ApiKey, args)
|
579
lemur/api_keys/views.py
Normal file
579
lemur/api_keys/views.py
Normal file
@ -0,0 +1,579 @@
|
||||
"""
|
||||
.. module: lemur.api_keys.views
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Eric Coan <kungfury@instructure.com>
|
||||
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, g
|
||||
from flask_restful import reqparse, Api
|
||||
|
||||
from lemur.api_keys import service
|
||||
from lemur.auth.service import AuthenticatedResource, create_token
|
||||
from lemur.auth.permissions import ApiKeyCreatorPermission
|
||||
|
||||
from lemur.common.schema import validate_schema
|
||||
from lemur.common.utils import paginated_parser
|
||||
|
||||
from lemur.api_keys.schemas import api_key_input_schema, api_key_revoke_schema, api_key_output_schema, \
|
||||
api_keys_output_schema, api_key_described_output_schema, user_api_key_input_schema
|
||||
|
||||
mod = Blueprint('api_keys', __name__)
|
||||
api = Api(mod)
|
||||
|
||||
|
||||
class ApiKeyList(AuthenticatedResource):
|
||||
""" Defines the 'api_keys' endpoint """
|
||||
|
||||
def __init__(self):
|
||||
super(ApiKeyList, self).__init__()
|
||||
|
||||
@validate_schema(None, api_keys_output_schema)
|
||||
def get(self):
|
||||
"""
|
||||
.. http:get:: /keys
|
||||
|
||||
The current list of api keys, that you can see.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /keys HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "custom name",
|
||||
"user_id": 1,
|
||||
"ttl": -1,
|
||||
"issued_at": 12,
|
||||
"revoked": false
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: asc or desc
|
||||
:query page: int default is 1
|
||||
:query count: count number. default is 10
|
||||
:query user_id: a user to filter by.
|
||||
:query id: an access key to filter by.
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
parser = paginated_parser.copy()
|
||||
args = parser.parse_args()
|
||||
args['has_permission'] = ApiKeyCreatorPermission().can()
|
||||
args['requesting_user_id'] = g.current_user.id
|
||||
return service.render(args)
|
||||
|
||||
@validate_schema(api_key_input_schema, api_key_output_schema)
|
||||
def post(self, data=None):
|
||||
"""
|
||||
.. http:post:: /keys
|
||||
|
||||
Creates an API Key.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /keys HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"name": "my custom name",
|
||||
"user_id": 1,
|
||||
"ttl": -1
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
|
||||
"jwt": ""
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
if not ApiKeyCreatorPermission().can():
|
||||
if data['user']['id'] != g.current_user.id:
|
||||
return dict(message="You are not authorized to create tokens for: {0}".format(data['user']['username'])), 403
|
||||
|
||||
access_token = service.create(name=data['name'], user_id=data['user']['id'], ttl=data['ttl'],
|
||||
revoked=False, issued_at=int(datetime.utcnow().timestamp()))
|
||||
return dict(jwt=create_token(access_token.user_id, access_token.id, access_token.ttl))
|
||||
|
||||
|
||||
class ApiKeyUserList(AuthenticatedResource):
|
||||
""" Defines the 'keys' endpoint on the 'users' endpoint. """
|
||||
|
||||
def __init__(self):
|
||||
super(ApiKeyUserList, self).__init__()
|
||||
|
||||
@validate_schema(None, api_keys_output_schema)
|
||||
def get(self, user_id):
|
||||
"""
|
||||
.. http:get:: /users/:user_id/keys
|
||||
|
||||
The current list of api keys for a user, that you can see.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /users/1/keys HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "custom name",
|
||||
"user_id": 1,
|
||||
"ttl": -1,
|
||||
"issued_at": 12,
|
||||
"revoked": false
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: asc or desc
|
||||
:query page: int default is 1
|
||||
:query count: count number. default is 10
|
||||
:query id: an access key to filter by.
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
parser = paginated_parser.copy()
|
||||
args = parser.parse_args()
|
||||
args['has_permission'] = ApiKeyCreatorPermission().can()
|
||||
args['requesting_user_id'] = g.current_user.id
|
||||
args['user_id'] = user_id
|
||||
return service.render(args)
|
||||
|
||||
@validate_schema(user_api_key_input_schema, api_key_output_schema)
|
||||
def post(self, user_id, data=None):
|
||||
"""
|
||||
.. http:post:: /users/:user_id/keys
|
||||
|
||||
Creates an API Key for a user.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /users/1/keys HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"name": "my custom name"
|
||||
"ttl": -1
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
|
||||
"jwt": ""
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
if not ApiKeyCreatorPermission().can():
|
||||
if user_id != g.current_user.id:
|
||||
return dict(message="You are not authorized to create tokens for: {0}".format(user_id)), 403
|
||||
|
||||
access_token = service.create(name=data['name'], user_id=user_id, ttl=data['ttl'],
|
||||
revoked=False, issued_at=int(datetime.utcnow().timestamp()))
|
||||
return dict(jwt=create_token(access_token.user_id, access_token.id, access_token.ttl))
|
||||
|
||||
|
||||
class ApiKeys(AuthenticatedResource):
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(ApiKeys, self).__init__()
|
||||
|
||||
@validate_schema(None, api_key_output_schema)
|
||||
def get(self, aid):
|
||||
"""
|
||||
.. http:get:: /keys/1
|
||||
|
||||
Fetch one api key
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /keys/1 HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"jwt": ""
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
access_key = service.get(aid)
|
||||
|
||||
if access_key is None:
|
||||
return dict(message="This token does not exist!"), 404
|
||||
|
||||
if access_key.user_id != g.current_user.id:
|
||||
if not ApiKeyCreatorPermission().can():
|
||||
return dict(message="You are not authorized to view this token!"), 403
|
||||
|
||||
return dict(jwt=create_token(access_key.user_id, access_key.id, access_key.ttl))
|
||||
|
||||
@validate_schema(api_key_revoke_schema, api_key_output_schema)
|
||||
def put(self, aid, data=None):
|
||||
"""
|
||||
.. http:put:: /keys/1
|
||||
|
||||
update one api key
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PUT /keys/1 HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"name": "new_name",
|
||||
"revoked": false,
|
||||
"ttl": -1
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"jwt": ""
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
access_key = service.get(aid)
|
||||
if access_key is None:
|
||||
return dict(message="This token does not exist!"), 404
|
||||
|
||||
if access_key.user_id != g.current_user.id:
|
||||
if not ApiKeyCreatorPermission().can():
|
||||
return dict(message="You are not authorized to update this token!"), 403
|
||||
|
||||
service.update(access_key, name=data['name'], revoked=data['revoked'], ttl=data['ttl'])
|
||||
return dict(jwt=create_token(access_key.user_id, access_key.id, access_key.ttl))
|
||||
|
||||
def delete(self, aid):
|
||||
"""
|
||||
.. http:delete:: /keys/1
|
||||
|
||||
deletes one api key
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /keys/1 HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"result": true
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
access_key = service.get(aid)
|
||||
if access_key is None:
|
||||
return dict(message="This token does not exist!"), 404
|
||||
|
||||
if access_key.user_id != g.current_user.id:
|
||||
if not ApiKeyCreatorPermission().can():
|
||||
return dict(message="You are not authorized to delete this token!"), 403
|
||||
|
||||
service.delete(access_key)
|
||||
return {'result': True}
|
||||
|
||||
|
||||
class UserApiKeys(AuthenticatedResource):
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(UserApiKeys, self).__init__()
|
||||
|
||||
@validate_schema(None, api_key_output_schema)
|
||||
def get(self, uid, aid):
|
||||
"""
|
||||
.. http:get:: /users/1/keys/1
|
||||
|
||||
Fetch one api key
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /users/1/api_keys/1 HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"jwt": ""
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
if uid != g.current_user.id:
|
||||
if not ApiKeyCreatorPermission().can():
|
||||
return dict(message="You are not authorized to view this token!"), 403
|
||||
|
||||
access_key = service.get(aid)
|
||||
|
||||
if access_key is None:
|
||||
return dict(message="This token does not exist!"), 404
|
||||
|
||||
if access_key.user_id != uid:
|
||||
return dict(message="You are not authorized to view this token!"), 403
|
||||
|
||||
return dict(jwt=create_token(access_key.user_id, access_key.id, access_key.ttl))
|
||||
|
||||
@validate_schema(api_key_revoke_schema, api_key_output_schema)
|
||||
def put(self, uid, aid, data=None):
|
||||
"""
|
||||
.. http:put:: /users/1/keys/1
|
||||
|
||||
update one api key
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PUT /users/1/keys/1 HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"name": "new_name",
|
||||
"revoked": false,
|
||||
"ttl": -1
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"jwt": ""
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
if uid != g.current_user.id:
|
||||
if not ApiKeyCreatorPermission().can():
|
||||
return dict(message="You are not authorized to view this token!"), 403
|
||||
|
||||
access_key = service.get(aid)
|
||||
if access_key is None:
|
||||
return dict(message="This token does not exist!"), 404
|
||||
|
||||
if access_key.user_id != uid:
|
||||
return dict(message="You are not authorized to update this token!"), 403
|
||||
|
||||
service.update(access_key, name=data['name'], revoked=data['revoked'], ttl=data['ttl'])
|
||||
return dict(jwt=create_token(access_key.user_id, access_key.id, access_key.ttl))
|
||||
|
||||
def delete(self, uid, aid):
|
||||
"""
|
||||
.. http:delete:: /users/1/keys/1
|
||||
|
||||
deletes one api key
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /users/1/keys/1 HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"result": true
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
if uid != g.current_user.id:
|
||||
if not ApiKeyCreatorPermission().can():
|
||||
return dict(message="You are not authorized to view this token!"), 403
|
||||
|
||||
access_key = service.get(aid)
|
||||
if access_key is None:
|
||||
return dict(message="This token does not exist!"), 404
|
||||
|
||||
if access_key.user_id != uid:
|
||||
return dict(message="You are not authorized to delete this token!"), 403
|
||||
|
||||
service.delete(access_key)
|
||||
return {'result': True}
|
||||
|
||||
|
||||
class ApiKeysDescribed(AuthenticatedResource):
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(ApiKeysDescribed, self).__init__()
|
||||
|
||||
@validate_schema(None, api_key_described_output_schema)
|
||||
def get(self, aid):
|
||||
"""
|
||||
.. http:get:: /keys/1/described
|
||||
|
||||
Fetch one api key
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /keys/1 HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"name": "hoi",
|
||||
"user_id": 2,
|
||||
"ttl": -1,
|
||||
"issued_at": 1222222,
|
||||
"revoked": false
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
access_key = service.get(aid)
|
||||
if access_key is None:
|
||||
return dict(message="This token does not exist!"), 404
|
||||
|
||||
if access_key.user_id != g.current_user.id:
|
||||
if not ApiKeyCreatorPermission().can():
|
||||
return dict(message="You are not authorized to view this token!"), 403
|
||||
|
||||
return access_key
|
||||
|
||||
|
||||
api.add_resource(ApiKeyList, '/keys', endpoint='api_keys')
|
||||
api.add_resource(ApiKeys, '/keys/<int:aid>', endpoint='api_key')
|
||||
api.add_resource(ApiKeysDescribed, '/keys/<int:aid>/described', endpoint='api_key_described')
|
||||
api.add_resource(ApiKeyUserList, '/users/<int:user_id>/keys', endpoint='user_api_keys')
|
||||
api.add_resource(UserApiKeys, '/users/<int:uid>/keys/<int:aid>', endpoint='user_api_key')
|
187
lemur/auth/ldap.py
Normal file
187
lemur/auth/ldap.py
Normal file
@ -0,0 +1,187 @@
|
||||
"""
|
||||
.. module: lemur.auth.ldap
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Ian Stahnke <ian.stahnke@myob.com>
|
||||
"""
|
||||
import ldap
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from lemur.users import service as user_service
|
||||
from lemur.roles import service as role_service
|
||||
from lemur.common.utils import validate_conf, get_psuedo_random_string
|
||||
|
||||
|
||||
class LdapPrincipal():
|
||||
"""
|
||||
Provides methods for authenticating against an LDAP server.
|
||||
"""
|
||||
def __init__(self, args):
|
||||
self._ldap_validate_conf()
|
||||
# setup ldap config
|
||||
if not args['username']:
|
||||
raise Exception("missing ldap username")
|
||||
if not args['password']:
|
||||
self.error_message = "missing ldap password"
|
||||
raise Exception("missing ldap password")
|
||||
self.ldap_principal = args['username']
|
||||
self.ldap_email_domain = current_app.config.get("LDAP_EMAIL_DOMAIN", None)
|
||||
if '@' not in self.ldap_principal:
|
||||
self.ldap_principal = '%s@%s' % (self.ldap_principal, self.ldap_email_domain)
|
||||
self.ldap_username = args['username']
|
||||
if '@' in self.ldap_username:
|
||||
self.ldap_username = args['username'].split("@")[0]
|
||||
self.ldap_password = args['password']
|
||||
self.ldap_server = current_app.config.get('LDAP_BIND_URI', None)
|
||||
self.ldap_base_dn = current_app.config.get("LDAP_BASE_DN", None)
|
||||
self.ldap_use_tls = current_app.config.get("LDAP_USE_TLS", False)
|
||||
self.ldap_cacert_file = current_app.config.get("LDAP_CACERT_FILE", None)
|
||||
self.ldap_default_role = current_app.config.get("LEMUR_DEFAULT_ROLE", None)
|
||||
self.ldap_required_group = current_app.config.get("LDAP_REQUIRED_GROUP", None)
|
||||
self.ldap_groups_to_roles = current_app.config.get("LDAP_GROUPS_TO_ROLES", None)
|
||||
self.ldap_attrs = ['memberOf']
|
||||
self.ldap_client = None
|
||||
self.ldap_groups = None
|
||||
|
||||
def _update_user(self, roles):
|
||||
"""
|
||||
create or update a local user instance.
|
||||
"""
|
||||
# try to get user from local database
|
||||
user = user_service.get_by_email(self.ldap_principal)
|
||||
|
||||
# create them a local account
|
||||
if not user:
|
||||
user = user_service.create(
|
||||
self.ldap_username,
|
||||
get_psuedo_random_string(),
|
||||
self.ldap_principal,
|
||||
True,
|
||||
'', # thumbnailPhotoUrl
|
||||
list(roles)
|
||||
)
|
||||
else:
|
||||
# we add 'lemur' specific roles, so they do not get marked as removed
|
||||
for ur in user.roles:
|
||||
if not ur.third_party:
|
||||
roles.add(ur)
|
||||
|
||||
# update any changes to the user
|
||||
user_service.update(
|
||||
user.id,
|
||||
self.ldap_username,
|
||||
self.ldap_principal,
|
||||
user.active,
|
||||
user.profile_picture,
|
||||
list(roles)
|
||||
)
|
||||
return user
|
||||
|
||||
def _authorize(self):
|
||||
"""
|
||||
check groups and roles to confirm access.
|
||||
return a list of roles if ok.
|
||||
raise an exception on error.
|
||||
"""
|
||||
if not self.ldap_principal:
|
||||
return None
|
||||
|
||||
if self.ldap_required_group:
|
||||
# ensure the user has the required group in their group list
|
||||
if self.ldap_required_group not in self.ldap_groups:
|
||||
return None
|
||||
|
||||
roles = set()
|
||||
if self.ldap_default_role:
|
||||
role = role_service.get_by_name(self.ldap_default_role)
|
||||
if role:
|
||||
if not role.third_party:
|
||||
role = role.set_third_party(role.id, third_party_status=True)
|
||||
roles.add(role)
|
||||
|
||||
# update their 'roles'
|
||||
role = role_service.get_by_name(self.ldap_principal)
|
||||
if not role:
|
||||
description = "auto generated role based on owner: {0}".format(self.ldap_principal)
|
||||
role = role_service.create(self.ldap_principal, description=description,
|
||||
third_party=True)
|
||||
if not role.third_party:
|
||||
role = role_service.set_third_party(role.id, third_party_status=True)
|
||||
roles.add(role)
|
||||
if not self.ldap_groups_to_roles:
|
||||
return roles
|
||||
|
||||
for ldap_group_name, role_name in self.ldap_groups_to_roles.items():
|
||||
role = role_service.get_by_name(role_name)
|
||||
if role:
|
||||
if ldap_group_name in self.ldap_groups:
|
||||
current_app.logger.debug("assigning role {0} to ldap user {1}".format(self.ldap_principal, role))
|
||||
if not role.third_party:
|
||||
role = role_service.set_third_party(role.id, third_party_status=True)
|
||||
roles.add(role)
|
||||
return roles
|
||||
|
||||
def authenticate(self):
|
||||
"""
|
||||
orchestrate the ldap login.
|
||||
raise an exception on error.
|
||||
"""
|
||||
self._bind()
|
||||
roles = self._authorize()
|
||||
if not roles:
|
||||
raise Exception('ldap authorization failed')
|
||||
return self._update_user(roles)
|
||||
|
||||
def _bind(self):
|
||||
"""
|
||||
authenticate an ldap user.
|
||||
list groups for a user.
|
||||
raise an exception on error.
|
||||
"""
|
||||
if '@' not in self.ldap_principal:
|
||||
self.ldap_principal = '%s@%s' % (self.ldap_principal, self.ldap_email_domain)
|
||||
ldap_filter = 'userPrincipalName=%s' % self.ldap_principal
|
||||
|
||||
# query ldap for auth
|
||||
try:
|
||||
# build a client
|
||||
if not self.ldap_client:
|
||||
self.ldap_client = ldap.initialize(self.ldap_server)
|
||||
# perform a synchronous bind
|
||||
self.ldap_client.set_option(ldap.OPT_REFERRALS, 0)
|
||||
if self.ldap_use_tls:
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
self.ldap_client.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
|
||||
self.ldap_client.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND)
|
||||
self.ldap_client.set_option(ldap.OPT_X_TLS_DEMAND, True)
|
||||
self.ldap_client.set_option(ldap.OPT_DEBUG_LEVEL, 255)
|
||||
if self.ldap_cacert_file:
|
||||
self.ldap_client.set_option(ldap.OPT_X_TLS_CACERTFILE, self.ldap_cacert_file)
|
||||
self.ldap_client.simple_bind_s(self.ldap_principal, self.ldap_password)
|
||||
except ldap.INVALID_CREDENTIALS:
|
||||
self.ldap_client.unbind()
|
||||
raise Exception('The supplied ldap credentials are invalid')
|
||||
except ldap.SERVER_DOWN:
|
||||
raise Exception('ldap server unavailable')
|
||||
except ldap.LDAPError as e:
|
||||
raise Exception("ldap error: {0}".format(e))
|
||||
|
||||
lgroups = self.ldap_client.search_s(self.ldap_base_dn,
|
||||
ldap.SCOPE_SUBTREE, ldap_filter, self.ldap_attrs)[0][1]['memberOf']
|
||||
# lgroups is a list of utf-8 encoded strings
|
||||
# convert to a single string of groups to allow matching
|
||||
self.ldap_groups = b''.join(lgroups).decode('ascii')
|
||||
self.ldap_client.unbind()
|
||||
|
||||
def _ldap_validate_conf(self):
|
||||
"""
|
||||
Confirms required ldap config settings exist.
|
||||
"""
|
||||
required_vars = [
|
||||
'LDAP_BIND_URI',
|
||||
'LDAP_BASE_DN',
|
||||
'LDAP_EMAIL_DOMAIN',
|
||||
]
|
||||
validate_conf(current_app, required_vars)
|
@ -2,22 +2,19 @@
|
||||
.. module: lemur.auth.permissions
|
||||
:platform: Unix
|
||||
:synopsis: This module defines all the permission used within Lemur
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from functools import partial
|
||||
from collections import namedtuple
|
||||
|
||||
from flask.ext.principal import Permission, RoleNeed
|
||||
from flask_principal import Permission, RoleNeed
|
||||
|
||||
# Permissions
|
||||
operator_permission = Permission(RoleNeed('operator'))
|
||||
admin_permission = Permission(RoleNeed('admin'))
|
||||
|
||||
CertificateCreator = namedtuple('certificate', ['method', 'value'])
|
||||
CertificateCreatorNeed = partial(CertificateCreator, 'key')
|
||||
|
||||
CertificateOwner = namedtuple('certificate', ['method', 'value'])
|
||||
CertificateOwnerNeed = partial(CertificateOwner, 'role')
|
||||
|
||||
@ -28,14 +25,19 @@ class SensitiveDomainPermission(Permission):
|
||||
|
||||
|
||||
class CertificatePermission(Permission):
|
||||
def __init__(self, certificate_id, owner, roles):
|
||||
needs = [RoleNeed('admin'), CertificateCreatorNeed(certificate_id), RoleNeed(owner)]
|
||||
def __init__(self, owner, roles):
|
||||
needs = [RoleNeed('admin'), RoleNeed(owner), RoleNeed('creator')]
|
||||
for r in roles:
|
||||
needs.append(CertificateOwnerNeed(str(r)))
|
||||
|
||||
super(CertificatePermission, self).__init__(*needs)
|
||||
|
||||
|
||||
class ApiKeyCreatorPermission(Permission):
|
||||
def __init__(self):
|
||||
super(ApiKeyCreatorPermission, self).__init__(RoleNeed('admin'))
|
||||
|
||||
|
||||
RoleMember = namedtuple('role', ['method', 'value'])
|
||||
RoleMemberNeed = partial(RoleMember, 'member')
|
||||
|
||||
|
@ -3,12 +3,11 @@
|
||||
:platform: Unix
|
||||
:synopsis: This module contains all of the authentication duties for
|
||||
lemur
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
||||
"""
|
||||
import sys
|
||||
import jwt
|
||||
import json
|
||||
import binascii
|
||||
@ -18,18 +17,18 @@ from datetime import datetime, timedelta
|
||||
|
||||
from flask import g, current_app, jsonify, request
|
||||
|
||||
from flask.ext.restful import Resource
|
||||
from flask.ext.principal import identity_loaded, RoleNeed, UserNeed
|
||||
from flask_restful import Resource
|
||||
from flask_principal import identity_loaded, RoleNeed, UserNeed
|
||||
|
||||
from flask.ext.principal import Identity, identity_changed
|
||||
from flask_principal import Identity, identity_changed
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
||||
|
||||
from lemur.users import service as user_service
|
||||
from lemur.auth.permissions import CertificateCreatorNeed, \
|
||||
AuthorityCreatorNeed, RoleMemberNeed
|
||||
from lemur.api_keys import service as api_key_service
|
||||
from lemur.auth.permissions import AuthorityCreatorNeed, RoleMemberNeed
|
||||
|
||||
|
||||
def get_rsa_public_key(n, e):
|
||||
@ -40,12 +39,8 @@ def get_rsa_public_key(n, e):
|
||||
:param e:
|
||||
:return: a RSA Public Key in PEM format
|
||||
"""
|
||||
if sys.version_info >= (3, 0):
|
||||
n = int(binascii.hexlify(jwt.utils.base64url_decode(bytes(n, 'utf-8'))), 16)
|
||||
e = int(binascii.hexlify(jwt.utils.base64url_decode(bytes(e, 'utf-8'))), 16)
|
||||
else:
|
||||
n = int(binascii.hexlify(jwt.utils.base64url_decode(str(n))), 16)
|
||||
e = int(binascii.hexlify(jwt.utils.base64url_decode(str(e))), 16)
|
||||
n = int(binascii.hexlify(jwt.utils.base64url_decode(bytes(n, 'utf-8'))), 16)
|
||||
e = int(binascii.hexlify(jwt.utils.base64url_decode(bytes(e, 'utf-8'))), 16)
|
||||
|
||||
pub = RSAPublicNumbers(e, n).public_key(default_backend())
|
||||
return pub.public_bytes(
|
||||
@ -54,9 +49,9 @@ def get_rsa_public_key(n, e):
|
||||
)
|
||||
|
||||
|
||||
def create_token(user):
|
||||
def create_token(user, aid=None, ttl=None):
|
||||
"""
|
||||
Create a valid JWT for a given user, this token is then used to authenticate
|
||||
Create a valid JWT for a given user/api key, this token is then used to authenticate
|
||||
sessions until the token expires.
|
||||
|
||||
:param user:
|
||||
@ -64,17 +59,31 @@ def create_token(user):
|
||||
"""
|
||||
expiration_delta = timedelta(days=int(current_app.config.get('LEMUR_TOKEN_EXPIRATION', 1)))
|
||||
payload = {
|
||||
'sub': user.id,
|
||||
'iat': datetime.utcnow(),
|
||||
'exp': datetime.utcnow() + expiration_delta
|
||||
}
|
||||
|
||||
# Handle Just a User ID & User Object.
|
||||
if isinstance(user, int):
|
||||
payload['sub'] = user
|
||||
else:
|
||||
payload['sub'] = user.id
|
||||
if aid is not None:
|
||||
payload['aid'] = aid
|
||||
# Custom TTLs are only supported on Access Keys.
|
||||
if ttl is not None and aid is not None:
|
||||
# Tokens that are forever until revoked.
|
||||
if ttl == -1:
|
||||
del payload['exp']
|
||||
else:
|
||||
payload['exp'] = ttl
|
||||
token = jwt.encode(payload, current_app.config['LEMUR_TOKEN_SECRET'])
|
||||
return token.decode('unicode_escape')
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""
|
||||
Validates the JWT and ensures that is has not expired.
|
||||
Validates the JWT and ensures that is has not expired and the user is still active.
|
||||
|
||||
:param f:
|
||||
:return:
|
||||
@ -100,7 +109,22 @@ def login_required(f):
|
||||
except jwt.InvalidTokenError:
|
||||
return dict(message='Token is invalid'), 403
|
||||
|
||||
g.current_user = user_service.get(payload['sub'])
|
||||
if 'aid' in payload:
|
||||
access_key = api_key_service.get(payload['aid'])
|
||||
if access_key.revoked:
|
||||
return dict(message='Token has been revoked'), 403
|
||||
if access_key.ttl != -1:
|
||||
current_time = datetime.utcnow()
|
||||
expired_time = datetime.fromtimestamp(access_key.issued_at + access_key.ttl)
|
||||
if current_time >= expired_time:
|
||||
return dict(message='Token has expired'), 403
|
||||
|
||||
user = user_service.get(payload['sub'])
|
||||
|
||||
if not user.active:
|
||||
return dict(message='User is not currently active'), 403
|
||||
|
||||
g.current_user = user
|
||||
|
||||
if not g.current_user:
|
||||
return dict(message='You are not logged in'), 403
|
||||
@ -128,10 +152,7 @@ def fetch_token_header(token):
|
||||
raise jwt.DecodeError('Not enough segments')
|
||||
|
||||
try:
|
||||
if sys.version_info >= (3, 0):
|
||||
return json.loads(jwt.utils.base64url_decode(header_segment).decode('utf-8'))
|
||||
else:
|
||||
return json.loads(jwt.utils.base64url_decode(header_segment))
|
||||
return json.loads(jwt.utils.base64url_decode(header_segment).decode('utf-8'))
|
||||
except TypeError as e:
|
||||
current_app.logger.exception(e)
|
||||
raise jwt.DecodeError('Invalid header padding')
|
||||
@ -163,11 +184,6 @@ def on_identity_loaded(sender, identity):
|
||||
for authority in user.authorities:
|
||||
identity.provides.add(AuthorityCreatorNeed(authority.id))
|
||||
|
||||
# apply ownership of certificates
|
||||
if hasattr(user, 'certificates'):
|
||||
for certificate in user.certificates:
|
||||
identity.provides.add(CertificateCreatorNeed(certificate.id))
|
||||
|
||||
g.user = user
|
||||
|
||||
|
||||
|
@ -1,32 +1,199 @@
|
||||
"""
|
||||
.. module: lemur.auth.views
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import sys
|
||||
import jwt
|
||||
import base64
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, current_app
|
||||
|
||||
from flask.ext.restful import reqparse, Resource, Api
|
||||
from flask.ext.principal import Identity, identity_changed
|
||||
from flask_restful import reqparse, Resource, Api
|
||||
from flask_principal import Identity, identity_changed
|
||||
|
||||
from lemur.constants import SUCCESS_METRIC_STATUS, FAILURE_METRIC_STATUS
|
||||
from lemur.extensions import metrics
|
||||
from lemur.common.utils import get_psuedo_random_string
|
||||
|
||||
from lemur.users import service as user_service
|
||||
from lemur.roles import service as role_service
|
||||
from lemur.auth.service import create_token, fetch_token_header, get_rsa_public_key
|
||||
import lemur.auth.ldap as ldap
|
||||
|
||||
|
||||
mod = Blueprint('auth', __name__)
|
||||
api = Api(mod)
|
||||
|
||||
|
||||
def exchange_for_access_token(code, redirect_uri, client_id, secret, access_token_url=None, verify_cert=True):
|
||||
"""
|
||||
Exchanges authorization code for access token.
|
||||
|
||||
:param code:
|
||||
:param redirect_uri:
|
||||
:param client_id:
|
||||
:param secret:
|
||||
:param access_token_url:
|
||||
:param verify_cert:
|
||||
:return:
|
||||
:return:
|
||||
"""
|
||||
# take the information we have received from the provider to create a new request
|
||||
params = {
|
||||
'grant_type': 'authorization_code',
|
||||
'scope': 'openid email profile address',
|
||||
'code': code,
|
||||
'redirect_uri': redirect_uri,
|
||||
'client_id': client_id
|
||||
}
|
||||
|
||||
# the secret and cliendId will be given to you when you signup for the provider
|
||||
token = '{0}:{1}'.format(client_id, secret)
|
||||
|
||||
basic = base64.b64encode(bytes(token, 'utf-8'))
|
||||
headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'authorization': 'basic {0}'.format(basic.decode('utf-8'))
|
||||
}
|
||||
|
||||
# exchange authorization code for access token.
|
||||
r = requests.post(access_token_url, headers=headers, params=params, verify=verify_cert)
|
||||
if r.status_code == 400:
|
||||
r = requests.post(access_token_url, headers=headers, data=params, verify=verify_cert)
|
||||
id_token = r.json()['id_token']
|
||||
access_token = r.json()['access_token']
|
||||
|
||||
return id_token, access_token
|
||||
|
||||
|
||||
def validate_id_token(id_token, client_id, jwks_url):
|
||||
"""
|
||||
Ensures that the token we receive is valid.
|
||||
|
||||
:param id_token:
|
||||
:param client_id:
|
||||
:param jwks_url:
|
||||
:return:
|
||||
"""
|
||||
# fetch token public key
|
||||
header_data = fetch_token_header(id_token)
|
||||
|
||||
# retrieve the key material as specified by the token header
|
||||
r = requests.get(jwks_url)
|
||||
for key in r.json()['keys']:
|
||||
if key['kid'] == header_data['kid']:
|
||||
secret = get_rsa_public_key(key['n'], key['e'])
|
||||
algo = header_data['alg']
|
||||
break
|
||||
else:
|
||||
return dict(message='Key not found'), 401
|
||||
|
||||
# validate your token based on the key it was signed with
|
||||
try:
|
||||
jwt.decode(id_token, secret.decode('utf-8'), algorithms=[algo], audience=client_id)
|
||||
except jwt.DecodeError:
|
||||
return dict(message='Token is invalid'), 401
|
||||
except jwt.ExpiredSignatureError:
|
||||
return dict(message='Token has expired'), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return dict(message='Token is invalid'), 401
|
||||
|
||||
|
||||
def retrieve_user(user_api_url, access_token):
|
||||
"""
|
||||
Fetch user information from provided user api_url.
|
||||
|
||||
:param user_api_url:
|
||||
:param access_token:
|
||||
:return:
|
||||
"""
|
||||
user_params = dict(access_token=access_token, schema='profile')
|
||||
|
||||
# retrieve information about the current user.
|
||||
r = requests.get(user_api_url, params=user_params)
|
||||
profile = r.json()
|
||||
|
||||
user = user_service.get_by_email(profile['email'])
|
||||
return user, profile
|
||||
|
||||
|
||||
def create_user_roles(profile):
|
||||
"""Creates new roles based on profile information.
|
||||
|
||||
:param profile:
|
||||
:return:
|
||||
"""
|
||||
roles = []
|
||||
|
||||
# update their google 'roles'
|
||||
for group in profile['googleGroups']:
|
||||
role = role_service.get_by_name(group)
|
||||
if not role:
|
||||
role = role_service.create(group, description='This is a google group based role created by Lemur', third_party=True)
|
||||
if not role.third_party:
|
||||
role = role_service.set_third_party(role.id, third_party_status=True)
|
||||
roles.append(role)
|
||||
|
||||
role = role_service.get_by_name(profile['email'])
|
||||
|
||||
if not role:
|
||||
role = role_service.create(profile['email'], description='This is a user specific role', third_party=True)
|
||||
if not role.third_party:
|
||||
role = role_service.set_third_party(role.id, third_party_status=True)
|
||||
|
||||
roles.append(role)
|
||||
|
||||
# every user is an operator (tied to a default role)
|
||||
if current_app.config.get('LEMUR_DEFAULT_ROLE'):
|
||||
default = role_service.get_by_name(current_app.config['LEMUR_DEFAULT_ROLE'])
|
||||
if not default:
|
||||
default = role_service.create(current_app.config['LEMUR_DEFAULT_ROLE'], description='This is the default Lemur role.')
|
||||
if not default.third_party:
|
||||
role_service.set_third_party(default.id, third_party_status=True)
|
||||
roles.append(default)
|
||||
|
||||
return roles
|
||||
|
||||
|
||||
def update_user(user, profile, roles):
|
||||
"""Updates user with current profile information and associated roles.
|
||||
|
||||
:param user:
|
||||
:param profile:
|
||||
:param roles:
|
||||
"""
|
||||
|
||||
# if we get an sso user create them an account
|
||||
if not user:
|
||||
user = user_service.create(
|
||||
profile['email'],
|
||||
get_psuedo_random_string(),
|
||||
profile['email'],
|
||||
True,
|
||||
profile.get('thumbnailPhotoUrl'),
|
||||
roles
|
||||
)
|
||||
|
||||
else:
|
||||
# we add 'lemur' specific roles, so they do not get marked as removed
|
||||
for ur in user.roles:
|
||||
if not ur.third_party:
|
||||
roles.append(ur)
|
||||
|
||||
# update any changes to the user
|
||||
user_service.update(
|
||||
user.id,
|
||||
profile['email'],
|
||||
profile['email'],
|
||||
True,
|
||||
profile.get('thumbnailPhotoUrl'), # profile isn't google+ enabled
|
||||
roles
|
||||
)
|
||||
|
||||
|
||||
class Login(Resource):
|
||||
"""
|
||||
Provides an endpoint for Lemur's basic authentication. It takes a username and password
|
||||
@ -94,32 +261,54 @@ class Login(Resource):
|
||||
else:
|
||||
user = user_service.get_by_username(args['username'])
|
||||
|
||||
if user and user.check_password(args['password']):
|
||||
# default to local authentication
|
||||
if user and user.check_password(args['password']) and user.active:
|
||||
# Tell Flask-Principal the identity changed
|
||||
identity_changed.send(current_app._get_current_object(),
|
||||
identity=Identity(user.id))
|
||||
|
||||
metrics.send('successful_login', 'counter', 1)
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': SUCCESS_METRIC_STATUS})
|
||||
return dict(token=create_token(user))
|
||||
|
||||
metrics.send('invalid_login', 'counter', 1)
|
||||
return dict(message='The supplied credentials are invalid'), 401
|
||||
# try ldap login
|
||||
if current_app.config.get("LDAP_AUTH"):
|
||||
try:
|
||||
ldap_principal = ldap.LdapPrincipal(args)
|
||||
user = ldap_principal.authenticate()
|
||||
if user and user.active:
|
||||
# Tell Flask-Principal the identity changed
|
||||
identity_changed.send(current_app._get_current_object(),
|
||||
identity=Identity(user.id))
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': SUCCESS_METRIC_STATUS})
|
||||
return dict(token=create_token(user))
|
||||
except Exception as e:
|
||||
current_app.logger.error("ldap error: {0}".format(e))
|
||||
ldap_message = 'ldap error: %s' % e
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': FAILURE_METRIC_STATUS})
|
||||
return dict(message=ldap_message), 403
|
||||
|
||||
# if not valid user - no certificates for you
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': FAILURE_METRIC_STATUS})
|
||||
return dict(message='The supplied credentials are invalid'), 403
|
||||
|
||||
|
||||
class Ping(Resource):
|
||||
"""
|
||||
This class serves as an example of how one might implement an SSO provider for use with Lemur. In
|
||||
this example we use a OpenIDConnect authentication flow, that is essentially OAuth2 underneath. If you have an
|
||||
this example we use an OpenIDConnect authentication flow, that is essentially OAuth2 underneath. If you have an
|
||||
OAuth2 provider you want to use Lemur there would be two steps:
|
||||
|
||||
1. Define your own class that inherits from :class:`flask.ext.restful.Resource` and create the HTTP methods the \
|
||||
provider uses for it's callbacks.
|
||||
1. Define your own class that inherits from :class:`flask_restful.Resource` and create the HTTP methods the \
|
||||
provider uses for its callbacks.
|
||||
2. Add or change the Lemur AngularJS Configuration to point to your new provider
|
||||
"""
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(Ping, self).__init__()
|
||||
|
||||
def get(self):
|
||||
return 'Redirecting...'
|
||||
|
||||
def post(self):
|
||||
self.reqparse.add_argument('clientId', type=str, required=True, location='json')
|
||||
self.reqparse.add_argument('redirectUri', type=str, required=True, location='json')
|
||||
@ -127,121 +316,85 @@ class Ping(Resource):
|
||||
|
||||
args = self.reqparse.parse_args()
|
||||
|
||||
# take the information we have received from the provider to create a new request
|
||||
params = {
|
||||
'client_id': args['clientId'],
|
||||
'grant_type': 'authorization_code',
|
||||
'scope': 'openid email profile address',
|
||||
'redirect_uri': args['redirectUri'],
|
||||
'code': args['code']
|
||||
}
|
||||
|
||||
# you can either discover these dynamically or simply configure them
|
||||
access_token_url = current_app.config.get('PING_ACCESS_TOKEN_URL')
|
||||
user_api_url = current_app.config.get('PING_USER_API_URL')
|
||||
|
||||
# the secret and cliendId will be given to you when you signup for the provider
|
||||
token = '{0}:{1}'.format(args['clientId'], current_app.config.get("PING_SECRET"))
|
||||
secret = current_app.config.get('PING_SECRET')
|
||||
|
||||
if sys.version_info >= (3, 0):
|
||||
basic = base64.b64encode(bytes(token, 'utf-8'))
|
||||
headers = {'authorization': 'basic {0}'.format(basic.decode('utf-8'))}
|
||||
else:
|
||||
basic = base64.b64encode(token, 'utf-8')
|
||||
headers = {'authorization': 'basic {0}'.format(basic)}
|
||||
id_token, access_token = exchange_for_access_token(
|
||||
args['code'],
|
||||
args['redirectUri'],
|
||||
args['clientId'],
|
||||
secret,
|
||||
access_token_url=access_token_url
|
||||
)
|
||||
|
||||
# exchange authorization code for access token.
|
||||
|
||||
r = requests.post(access_token_url, headers=headers, params=params)
|
||||
id_token = r.json()['id_token']
|
||||
access_token = r.json()['access_token']
|
||||
|
||||
# fetch token public key
|
||||
header_data = fetch_token_header(id_token)
|
||||
jwks_url = current_app.config.get('PING_JWKS_URL')
|
||||
validate_id_token(id_token, args['clientId'], jwks_url)
|
||||
|
||||
# retrieve the key material as specified by the token header
|
||||
r = requests.get(jwks_url)
|
||||
for key in r.json()['keys']:
|
||||
if key['kid'] == header_data['kid']:
|
||||
secret = get_rsa_public_key(key['n'], key['e'])
|
||||
algo = header_data['alg']
|
||||
break
|
||||
else:
|
||||
return dict(message='Key not found'), 403
|
||||
user, profile = retrieve_user(user_api_url, access_token)
|
||||
roles = create_user_roles(profile)
|
||||
update_user(user, profile, roles)
|
||||
|
||||
# validate your token based on the key it was signed with
|
||||
try:
|
||||
if sys.version_info >= (3, 0):
|
||||
jwt.decode(id_token, secret.decode('utf-8'), algorithms=[algo], audience=args['clientId'])
|
||||
else:
|
||||
jwt.decode(id_token, secret, algorithms=[algo], audience=args['clientId'])
|
||||
except jwt.DecodeError:
|
||||
return dict(message='Token is invalid'), 403
|
||||
except jwt.ExpiredSignatureError:
|
||||
return dict(message='Token has expired'), 403
|
||||
except jwt.InvalidTokenError:
|
||||
return dict(message='Token is invalid'), 403
|
||||
|
||||
user_params = dict(access_token=access_token, schema='profile')
|
||||
|
||||
# retrieve information about the current user.
|
||||
r = requests.get(user_api_url, params=user_params)
|
||||
profile = r.json()
|
||||
|
||||
user = user_service.get_by_email(profile['email'])
|
||||
metrics.send('successful_login', 'counter', 1)
|
||||
|
||||
# update their google 'roles'
|
||||
roles = []
|
||||
|
||||
for group in profile['googleGroups']:
|
||||
role = role_service.get_by_name(group)
|
||||
if not role:
|
||||
role = role_service.create(group, description='This is a google group based role created by Lemur')
|
||||
roles.append(role)
|
||||
|
||||
role = role_service.get_by_name(profile['email'])
|
||||
if not role:
|
||||
role = role_service.create(profile['email'], description='This is a user specific role')
|
||||
roles.append(role)
|
||||
|
||||
# if we get an sso user create them an account
|
||||
if not user:
|
||||
# every user is an operator (tied to a default role)
|
||||
if current_app.config.get('LEMUR_DEFAULT_ROLE'):
|
||||
v = role_service.get_by_name(current_app.config.get('LEMUR_DEFAULT_ROLE'))
|
||||
if v:
|
||||
roles.append(v)
|
||||
|
||||
user = user_service.create(
|
||||
profile['email'],
|
||||
get_psuedo_random_string(),
|
||||
profile['email'],
|
||||
True,
|
||||
profile.get('thumbnailPhotoUrl'),
|
||||
roles
|
||||
)
|
||||
|
||||
else:
|
||||
# we add 'lemur' specific roles, so they do not get marked as removed
|
||||
for ur in user.roles:
|
||||
if ur.authority_id:
|
||||
roles.append(ur)
|
||||
|
||||
# update any changes to the user
|
||||
user_service.update(
|
||||
user.id,
|
||||
profile['email'],
|
||||
profile['email'],
|
||||
True,
|
||||
profile.get('thumbnailPhotoUrl'), # incase profile isn't google+ enabled
|
||||
roles
|
||||
)
|
||||
if not user or not user.active:
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': FAILURE_METRIC_STATUS})
|
||||
return dict(message='The supplied credentials are invalid'), 403
|
||||
|
||||
# Tell Flask-Principal the identity changed
|
||||
identity_changed.send(current_app._get_current_object(), identity=Identity(user.id))
|
||||
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': SUCCESS_METRIC_STATUS})
|
||||
return dict(token=create_token(user))
|
||||
|
||||
|
||||
class OAuth2(Resource):
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(OAuth2, self).__init__()
|
||||
|
||||
def get(self):
|
||||
return 'Redirecting...'
|
||||
|
||||
def post(self):
|
||||
self.reqparse.add_argument('clientId', type=str, required=True, location='json')
|
||||
self.reqparse.add_argument('redirectUri', type=str, required=True, location='json')
|
||||
self.reqparse.add_argument('code', type=str, required=True, location='json')
|
||||
|
||||
args = self.reqparse.parse_args()
|
||||
|
||||
# you can either discover these dynamically or simply configure them
|
||||
access_token_url = current_app.config.get('OAUTH2_ACCESS_TOKEN_URL')
|
||||
user_api_url = current_app.config.get('OAUTH2_USER_API_URL')
|
||||
verify_cert = current_app.config.get('OAUTH2_VERIFY_CERT')
|
||||
|
||||
secret = current_app.config.get('OAUTH2_SECRET')
|
||||
|
||||
id_token, access_token = exchange_for_access_token(
|
||||
args['code'],
|
||||
args['redirectUri'],
|
||||
args['clientId'],
|
||||
secret,
|
||||
access_token_url=access_token_url,
|
||||
verify_cert=verify_cert
|
||||
)
|
||||
|
||||
jwks_url = current_app.config.get('PING_JWKS_URL')
|
||||
validate_id_token(id_token, args['clientId'], jwks_url)
|
||||
|
||||
user, profile = retrieve_user(user_api_url, access_token)
|
||||
roles = create_user_roles(profile)
|
||||
update_user(user, profile, roles)
|
||||
|
||||
if not user.active:
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': FAILURE_METRIC_STATUS})
|
||||
return dict(message='The supplied credentials are invalid'), 403
|
||||
|
||||
# Tell Flask-Principal the identity changed
|
||||
identity_changed.send(current_app._get_current_object(), identity=Identity(user.id))
|
||||
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': SUCCESS_METRIC_STATUS})
|
||||
|
||||
return dict(token=create_token(user))
|
||||
|
||||
|
||||
@ -280,10 +433,16 @@ class Google(Resource):
|
||||
|
||||
user = user_service.get_by_email(profile['email'])
|
||||
|
||||
if not (user and user.active):
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': FAILURE_METRIC_STATUS})
|
||||
return dict(message='The supplied credentials are invalid.'), 403
|
||||
|
||||
if user:
|
||||
metrics.send('successful_login', 'counter', 1)
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': SUCCESS_METRIC_STATUS})
|
||||
return dict(token=create_token(user))
|
||||
|
||||
metrics.send('login', 'counter', 1, metric_tags={'status': FAILURE_METRIC_STATUS})
|
||||
|
||||
|
||||
class Providers(Resource):
|
||||
def get(self):
|
||||
@ -313,10 +472,27 @@ class Providers(Resource):
|
||||
'type': '2.0'
|
||||
})
|
||||
|
||||
elif provider == "oauth2":
|
||||
active_providers.append({
|
||||
'name': current_app.config.get("OAUTH2_NAME"),
|
||||
'url': current_app.config.get('OAUTH2_REDIRECT_URI'),
|
||||
'redirectUri': current_app.config.get("OAUTH2_REDIRECT_URI"),
|
||||
'clientId': current_app.config.get("OAUTH2_CLIENT_ID"),
|
||||
'responseType': 'code',
|
||||
'scope': ['openid', 'email', 'profile', 'groups'],
|
||||
'scopeDelimiter': ' ',
|
||||
'authorizationEndpoint': current_app.config.get("OAUTH2_AUTH_ENDPOINT"),
|
||||
'requiredUrlParams': ['scope', 'state', 'nonce'],
|
||||
'state': 'STATE',
|
||||
'nonce': get_psuedo_random_string(),
|
||||
'type': '2.0'
|
||||
})
|
||||
|
||||
return active_providers
|
||||
|
||||
|
||||
api.add_resource(Login, '/auth/login', endpoint='login')
|
||||
api.add_resource(Ping, '/auth/ping', endpoint='ping')
|
||||
api.add_resource(Google, '/auth/google', endpoint='google')
|
||||
api.add_resource(OAuth2, '/auth/oauth2', endpoint='oauth2')
|
||||
api.add_resource(Providers, '/auth/providers', endpoint='providers')
|
||||
|
@ -1,8 +1,8 @@
|
||||
"""
|
||||
.. module: lemur.authorities.models
|
||||
:platform: unix
|
||||
:synopsis: This module contains all of the models need to create a authority within Lemur.
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:synopsis: This module contains all of the models need to create an authority within Lemur.
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
@ -32,6 +32,9 @@ class Authority(db.Model):
|
||||
authority_certificate = relationship("Certificate", backref='root_authority', uselist=False, foreign_keys='Certificate.root_authority_id')
|
||||
certificates = relationship("Certificate", backref='authority', foreign_keys='Certificate.authority_id')
|
||||
|
||||
authority_pending_certificate = relationship("PendingCertificate", backref='root_authority', uselist=False, foreign_keys='PendingCertificate.root_authority_id')
|
||||
pending_certificates = relationship('PendingCertificate', backref='authority', foreign_keys='PendingCertificate.authority_id')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.owner = kwargs['owner']
|
||||
self.roles = kwargs.get('roles', [])
|
||||
@ -39,6 +42,7 @@ class Authority(db.Model):
|
||||
self.description = kwargs.get('description')
|
||||
self.authority_certificate = kwargs['authority_certificate']
|
||||
self.plugin_name = kwargs['plugin']['slug']
|
||||
self.options = kwargs.get('options')
|
||||
|
||||
@property
|
||||
def plugin(self):
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""
|
||||
.. module: lemur.authorities.schemas
|
||||
:platform: unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
@ -23,7 +23,7 @@ class AuthorityInputSchema(LemurInputSchema):
|
||||
name = fields.String(required=True)
|
||||
owner = fields.Email(required=True)
|
||||
description = fields.String()
|
||||
common_name = fields.String(required=True, validate=validators.sensitive_domain)
|
||||
common_name = fields.String(required=True, validate=validators.common_name)
|
||||
|
||||
validity_start = ArrowDateTime()
|
||||
validity_end = ArrowDateTime()
|
||||
@ -60,7 +60,7 @@ class AuthorityInputSchema(LemurInputSchema):
|
||||
def validate_subca(self, data):
|
||||
if data['type'] == 'subca':
|
||||
if not data.get('parent'):
|
||||
raise ValidationError("If generating a subca parent 'authority' must be specified.")
|
||||
raise ValidationError("If generating a subca, parent 'authority' must be specified.")
|
||||
|
||||
@pre_load
|
||||
def ensure_dates(self, data):
|
||||
@ -70,7 +70,7 @@ class AuthorityInputSchema(LemurInputSchema):
|
||||
class AuthorityUpdateSchema(LemurInputSchema):
|
||||
owner = fields.Email(required=True)
|
||||
description = fields.String()
|
||||
active = fields.Boolean()
|
||||
active = fields.Boolean(missing=True)
|
||||
roles = fields.Nested(AssociatedRoleSchema(many=True))
|
||||
|
||||
|
||||
|
@ -3,14 +3,16 @@
|
||||
:platform: Unix
|
||||
:synopsis: This module contains all of the services level functions used to
|
||||
administer authorities in Lemur
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
||||
"""
|
||||
from flask import g
|
||||
|
||||
import json
|
||||
|
||||
from lemur import database
|
||||
from lemur.common.utils import truthiness
|
||||
from lemur.extensions import metrics
|
||||
from lemur.authorities.models import Authority
|
||||
from lemur.roles import service as role_service
|
||||
@ -18,9 +20,9 @@ from lemur.roles import service as role_service
|
||||
from lemur.certificates.service import upload
|
||||
|
||||
|
||||
def update(authority_id, description=None, owner=None, active=None, roles=None):
|
||||
def update(authority_id, description, owner, active, roles):
|
||||
"""
|
||||
Update a an authority with new values.
|
||||
Update an authority with new values.
|
||||
|
||||
:param authority_id:
|
||||
:param roles: roles that are allowed to use this authority
|
||||
@ -28,14 +30,11 @@ def update(authority_id, description=None, owner=None, active=None, roles=None):
|
||||
"""
|
||||
authority = get(authority_id)
|
||||
|
||||
if roles:
|
||||
authority.roles = roles
|
||||
|
||||
if active:
|
||||
authority.active = active
|
||||
|
||||
authority.roles = roles
|
||||
authority.active = active
|
||||
authority.description = description
|
||||
authority.owner = owner
|
||||
|
||||
return database.update(authority)
|
||||
|
||||
|
||||
@ -53,13 +52,14 @@ def mint(**kwargs):
|
||||
elif len(values) == 4:
|
||||
body, private_key, chain, roles = values
|
||||
|
||||
roles = create_authority_roles(roles, kwargs['owner'], kwargs['plugin']['plugin_object'].title)
|
||||
roles = create_authority_roles(roles, kwargs['owner'], kwargs['plugin']['plugin_object'].title, kwargs['creator'])
|
||||
return body, private_key, chain, roles
|
||||
|
||||
|
||||
def create_authority_roles(roles, owner, plugin_title):
|
||||
def create_authority_roles(roles, owner, plugin_title, creator):
|
||||
"""
|
||||
Creates all of the necessary authority roles.
|
||||
:param creator:
|
||||
:param roles:
|
||||
:return:
|
||||
"""
|
||||
@ -75,7 +75,7 @@ def create_authority_roles(roles, owner, plugin_title):
|
||||
|
||||
# the user creating the authority should be able to administer it
|
||||
if role.username == 'admin':
|
||||
g.current_user.roles.append(role)
|
||||
creator.roles.append(role)
|
||||
|
||||
role_objs.append(role)
|
||||
|
||||
@ -95,10 +95,9 @@ def create(**kwargs):
|
||||
"""
|
||||
Creates a new authority.
|
||||
"""
|
||||
kwargs['creator'] = g.user.email
|
||||
body, private_key, chain, roles = mint(**kwargs)
|
||||
|
||||
g.user.roles = list(set(list(g.user.roles) + roles))
|
||||
kwargs['creator'].roles = list(set(list(kwargs['creator'].roles) + roles))
|
||||
|
||||
kwargs['body'] = body
|
||||
kwargs['private_key'] = private_key
|
||||
@ -111,10 +110,12 @@ def create(**kwargs):
|
||||
|
||||
cert = upload(**kwargs)
|
||||
kwargs['authority_certificate'] = cert
|
||||
if kwargs.get('plugin', {}).get('plugin_options', []):
|
||||
kwargs['options'] = json.dumps(kwargs['plugin']['plugin_options'])
|
||||
|
||||
authority = Authority(**kwargs)
|
||||
authority = database.create(authority)
|
||||
g.user.authorities.append(authority)
|
||||
kwargs['creator'].authorities.append(authority)
|
||||
|
||||
metrics.send('authority_created', 'counter', 1, metric_tags=dict(owner=authority.owner))
|
||||
return authority
|
||||
@ -151,17 +152,17 @@ def get_by_name(authority_name):
|
||||
return database.get(Authority, authority_name, field='name')
|
||||
|
||||
|
||||
def get_authority_role(ca_name):
|
||||
def get_authority_role(ca_name, creator=None):
|
||||
"""
|
||||
Attempts to get the authority role for a given ca uses current_user
|
||||
as a basis for accomplishing that.
|
||||
|
||||
:param ca_name:
|
||||
"""
|
||||
if g.current_user.is_admin:
|
||||
return role_service.get_by_name("{0}_admin".format(ca_name))
|
||||
else:
|
||||
return role_service.get_by_name("{0}_operator".format(ca_name))
|
||||
if creator:
|
||||
if creator.is_admin:
|
||||
return role_service.get_by_name("{0}_admin".format(ca_name))
|
||||
return role_service.get_by_name("{0}_operator".format(ca_name))
|
||||
|
||||
|
||||
def render(args):
|
||||
@ -175,18 +176,18 @@ def render(args):
|
||||
|
||||
if filt:
|
||||
terms = filt.split(';')
|
||||
if 'active' in filt: # this is really weird but strcmp seems to not work here??
|
||||
query = query.filter(Authority.active == terms[1])
|
||||
if 'active' in filt:
|
||||
query = query.filter(Authority.active == truthiness(terms[1]))
|
||||
else:
|
||||
query = database.filter(query, Authority, terms)
|
||||
|
||||
# we make sure that a user can only use an authority they either own are are a member of - admins can see all
|
||||
if not g.current_user.is_admin:
|
||||
# we make sure that a user can only use an authority they either own are a member of - admins can see all
|
||||
if not args['user'].is_admin:
|
||||
authority_ids = []
|
||||
for authority in g.current_user.authorities:
|
||||
for authority in args['user'].authorities:
|
||||
authority_ids.append(authority.id)
|
||||
|
||||
for role in g.current_user.roles:
|
||||
for role in args['user'].roles:
|
||||
for authority in role.authorities:
|
||||
authority_ids.append(authority.id)
|
||||
query = query.filter(Authority.id.in_(authority_ids))
|
||||
|
@ -1,12 +1,12 @@
|
||||
"""
|
||||
.. module: lemur.authorities.views
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask import Blueprint
|
||||
from flask.ext.restful import reqparse, Api
|
||||
from flask import Blueprint, g
|
||||
from flask_restful import reqparse, Api
|
||||
|
||||
from lemur.common.utils import paginated_parser
|
||||
from lemur.common.schema import validate_schema
|
||||
@ -95,7 +95,7 @@ class AuthoritiesList(AuthenticatedResource):
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: acs or desc
|
||||
:query sortDir: asc or desc
|
||||
:query page: int default is 1
|
||||
:query filter: key value pair. format is k;v
|
||||
:query count: count number default is 10
|
||||
@ -107,6 +107,7 @@ class AuthoritiesList(AuthenticatedResource):
|
||||
"""
|
||||
parser = paginated_parser.copy()
|
||||
args = parser.parse_args()
|
||||
args['user'] = g.current_user
|
||||
return service.render(args)
|
||||
|
||||
@validate_schema(authority_input_schema, authority_output_schema)
|
||||
@ -218,6 +219,7 @@ class AuthoritiesList(AuthenticatedResource):
|
||||
:statuscode 403: unauthenticated
|
||||
:statuscode 200: no error
|
||||
"""
|
||||
data['creator'] = g.current_user
|
||||
return service.create(**data)
|
||||
|
||||
|
||||
@ -283,7 +285,7 @@ class Authorities(AuthenticatedResource):
|
||||
"""
|
||||
.. http:put:: /authorities/1
|
||||
|
||||
Update a authority
|
||||
Update an authority
|
||||
|
||||
**Example request**:
|
||||
|
||||
@ -503,6 +505,7 @@ class AuthorityVisualizations(AuthenticatedResource):
|
||||
authority = service.get(authority_id)
|
||||
return dict(name=authority.name, children=[{"name": c.name} for c in authority.certificates])
|
||||
|
||||
|
||||
api.add_resource(AuthoritiesList, '/authorities', endpoint='authorities')
|
||||
api.add_resource(Authorities, '/authorities/<int:authority_id>', endpoint='authority')
|
||||
api.add_resource(AuthorityVisualizations, '/authorities/<int:authority_id>/visualize', endpoint='authority_visualizations')
|
||||
|
0
lemur/authorizations/__init__.py
Normal file
0
lemur/authorizations/__init__.py
Normal file
34
lemur/authorizations/models.py
Normal file
34
lemur/authorizations/models.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""
|
||||
.. module: lemur.authorizations.models
|
||||
:platform: unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Netflix Secops <secops@netflix.com>
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy_utils import JSONType
|
||||
from lemur.database import db
|
||||
|
||||
from lemur.plugins.base import plugins
|
||||
|
||||
|
||||
class Authorization(db.Model):
|
||||
__tablename__ = 'pending_dns_authorizations'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
account_number = Column(String(128))
|
||||
domains = Column(JSONType)
|
||||
dns_provider_type = Column(String(128))
|
||||
options = Column(JSONType)
|
||||
|
||||
@property
|
||||
def plugin(self):
|
||||
return plugins.get(self.plugin_name)
|
||||
|
||||
def __repr__(self):
|
||||
return "Authorization(id={id})".format(label=self.id)
|
||||
|
||||
def __init__(self, account_number, domains, dns_provider_type, options=None):
|
||||
self.account_number = account_number
|
||||
self.domains = domains
|
||||
self.dns_provider_type = dns_provider_type
|
||||
self.options = options
|
24
lemur/authorizations/service.py
Normal file
24
lemur/authorizations/service.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""
|
||||
.. module: lemur.pending_certificates.service
|
||||
Copyright (c) 2018 and onwards Netflix, Inc. All rights reserved.
|
||||
.. moduleauthor:: Secops <secops@netflix.com>
|
||||
"""
|
||||
from lemur import database
|
||||
|
||||
from lemur.authorizations.models import Authorization
|
||||
|
||||
|
||||
def get(authorization_id):
|
||||
"""
|
||||
Retrieve dns authorization by ID
|
||||
"""
|
||||
return database.get(Authorization, authorization_id)
|
||||
|
||||
|
||||
def create(account_number, domains, dns_provider_type, options=None):
|
||||
"""
|
||||
Creates a new dns authorization.
|
||||
"""
|
||||
|
||||
authorization = Authorization(account_number, domains, dns_provider_type, options)
|
||||
return database.create(authorization)
|
373
lemur/certificates/cli.py
Normal file
373
lemur/certificates/cli.py
Normal file
@ -0,0 +1,373 @@
|
||||
"""
|
||||
.. module: lemur.certificate.cli
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import sys
|
||||
import multiprocessing
|
||||
from tabulate import tabulate
|
||||
from sqlalchemy import or_
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from flask_script import Manager
|
||||
from flask_principal import Identity, identity_changed
|
||||
|
||||
|
||||
from lemur import database
|
||||
from lemur.extensions import sentry
|
||||
from lemur.extensions import metrics
|
||||
from lemur.plugins.base import plugins
|
||||
from lemur.constants import SUCCESS_METRIC_STATUS, FAILURE_METRIC_STATUS
|
||||
from lemur.deployment import service as deployment_service
|
||||
from lemur.endpoints import service as endpoint_service
|
||||
from lemur.notifications.messaging import send_rotation_notification
|
||||
from lemur.domains.models import Domain
|
||||
from lemur.authorities.models import Authority
|
||||
from lemur.certificates.schemas import CertificateOutputSchema
|
||||
from lemur.certificates.models import Certificate
|
||||
from lemur.certificates.service import (
|
||||
reissue_certificate,
|
||||
get_certificate_primitives,
|
||||
get_all_pending_reissue,
|
||||
get_by_name,
|
||||
get_all_certs,
|
||||
get
|
||||
)
|
||||
|
||||
from lemur.certificates.verify import verify_string
|
||||
|
||||
manager = Manager(usage="Handles all certificate related tasks.")
|
||||
|
||||
|
||||
def print_certificate_details(details):
|
||||
"""
|
||||
Print the certificate details with formatting.
|
||||
:param details:
|
||||
:return:
|
||||
"""
|
||||
details, errors = CertificateOutputSchema().dump(details)
|
||||
print("[+] Re-issuing certificate with the following details: ")
|
||||
print(
|
||||
"\t[+] Common Name: {common_name}\n"
|
||||
"\t[+] Subject Alternate Names: {sans}\n"
|
||||
"\t[+] Authority: {authority_name}\n"
|
||||
"\t[+] Validity Start: {validity_start}\n"
|
||||
"\t[+] Validity End: {validity_end}\n".format(
|
||||
common_name=details['commonName'],
|
||||
sans=",".join(x['value'] for x in details['extensions']['subAltNames']['names']) or None,
|
||||
authority_name=details['authority']['name'],
|
||||
validity_start=details['validityStart'],
|
||||
validity_end=details['validityEnd']
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_certificate(certificate_name):
|
||||
"""
|
||||
Ensuring that the specified certificate exists.
|
||||
:param certificate_name:
|
||||
:return:
|
||||
"""
|
||||
if certificate_name:
|
||||
cert = get_by_name(certificate_name)
|
||||
|
||||
if not cert:
|
||||
print("[-] No certificate found with name: {0}".format(certificate_name))
|
||||
sys.exit(1)
|
||||
|
||||
return cert
|
||||
|
||||
|
||||
def validate_endpoint(endpoint_name):
|
||||
"""
|
||||
Ensuring that the specified endpoint exists.
|
||||
:param endpoint_name:
|
||||
:return:
|
||||
"""
|
||||
if endpoint_name:
|
||||
endpoint = endpoint_service.get_by_name(endpoint_name)
|
||||
|
||||
if not endpoint:
|
||||
print("[-] No endpoint found with name: {0}".format(endpoint_name))
|
||||
sys.exit(1)
|
||||
|
||||
return endpoint
|
||||
|
||||
|
||||
def request_rotation(endpoint, certificate, message, commit):
|
||||
"""
|
||||
Rotates a certificate and handles any exceptions during
|
||||
execution.
|
||||
:param endpoint:
|
||||
:param certificate:
|
||||
:param message:
|
||||
:param commit:
|
||||
:return:
|
||||
"""
|
||||
status = FAILURE_METRIC_STATUS
|
||||
if commit:
|
||||
try:
|
||||
deployment_service.rotate_certificate(endpoint, certificate)
|
||||
|
||||
if message:
|
||||
send_rotation_notification(certificate)
|
||||
|
||||
status = SUCCESS_METRIC_STATUS
|
||||
|
||||
except Exception as e:
|
||||
print(
|
||||
"[!] Failed to rotate endpoint {0} to certificate {1} reason: {2}".format(
|
||||
endpoint.name,
|
||||
certificate.name,
|
||||
e
|
||||
)
|
||||
)
|
||||
|
||||
metrics.send('endpoint_rotation', 'counter', 1, metric_tags={'status': status})
|
||||
|
||||
|
||||
def request_reissue(certificate, commit):
|
||||
"""
|
||||
Reissuing certificate and handles any exceptions.
|
||||
:param certificate:
|
||||
:param commit:
|
||||
:return:
|
||||
"""
|
||||
status = FAILURE_METRIC_STATUS
|
||||
try:
|
||||
print("[+] {0} is eligible for re-issuance".format(certificate.name))
|
||||
|
||||
# set the lemur identity for all cli commands
|
||||
identity_changed.send(current_app._get_current_object(), identity=Identity(1))
|
||||
|
||||
details = get_certificate_primitives(certificate)
|
||||
print_certificate_details(details)
|
||||
|
||||
if commit:
|
||||
new_cert = reissue_certificate(certificate, replace=True)
|
||||
print("[+] New certificate named: {0}".format(new_cert.name))
|
||||
|
||||
status = SUCCESS_METRIC_STATUS
|
||||
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.exception("Error reissuing certificate.", exc_info=True)
|
||||
print(
|
||||
"[!] Failed to reissue certificates. Reason: {}".format(
|
||||
e
|
||||
)
|
||||
)
|
||||
|
||||
metrics.send('certificate_reissue', 'counter', 1, metric_tags={'status': status})
|
||||
|
||||
|
||||
@manager.option('-e', '--endpoint', dest='endpoint_name', help='Name of the endpoint you wish to rotate.')
|
||||
@manager.option('-n', '--new-certificate', dest='new_certificate_name', help='Name of the certificate you wish to rotate to.')
|
||||
@manager.option('-o', '--old-certificate', dest='old_certificate_name', help='Name of the certificate you wish to rotate.')
|
||||
@manager.option('-a', '--notify', dest='message', action='store_true', help='Send a rotation notification to the certificates owner.')
|
||||
@manager.option('-c', '--commit', dest='commit', action='store_true', default=False, help='Persist changes.')
|
||||
def rotate(endpoint_name, new_certificate_name, old_certificate_name, message, commit):
|
||||
"""
|
||||
Rotates an endpoint and reissues it if it has not already been replaced. If it has
|
||||
been replaced, will use the replacement certificate for the rotation.
|
||||
"""
|
||||
if commit:
|
||||
print("[!] Running in COMMIT mode.")
|
||||
|
||||
print("[+] Starting endpoint rotation.")
|
||||
|
||||
status = FAILURE_METRIC_STATUS
|
||||
|
||||
try:
|
||||
old_cert = validate_certificate(old_certificate_name)
|
||||
new_cert = validate_certificate(new_certificate_name)
|
||||
endpoint = validate_endpoint(endpoint_name)
|
||||
|
||||
if endpoint and new_cert:
|
||||
print("[+] Rotating endpoint: {0} to certificate {1}".format(endpoint.name, new_cert.name))
|
||||
request_rotation(endpoint, new_cert, message, commit)
|
||||
|
||||
elif old_cert and new_cert:
|
||||
print("[+] Rotating all endpoints from {0} to {1}".format(old_cert.name, new_cert.name))
|
||||
|
||||
for endpoint in old_cert.endpoints:
|
||||
print("[+] Rotating {0}".format(endpoint.name))
|
||||
request_rotation(endpoint, new_cert, message, commit)
|
||||
|
||||
else:
|
||||
print("[+] Rotating all endpoints that have new certificates available")
|
||||
for endpoint in endpoint_service.get_all_pending_rotation():
|
||||
if len(endpoint.certificate.replaced) == 1:
|
||||
print("[+] Rotating {0} to {1}".format(endpoint.name, endpoint.certificate.replaced[0].name))
|
||||
request_rotation(endpoint, endpoint.certificate.replaced[0], message, commit)
|
||||
else:
|
||||
metrics.send('endpoint_rotation', 'counter', 1, metric_tags={'status': FAILURE_METRIC_STATUS})
|
||||
print("[!] Failed to rotate endpoint {0} reason: Multiple replacement certificates found.".format(
|
||||
endpoint.name
|
||||
))
|
||||
|
||||
status = SUCCESS_METRIC_STATUS
|
||||
print("[+] Done!")
|
||||
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
|
||||
metrics.send('endpoint_rotation_job', 'counter', 1, metric_tags={'status': status})
|
||||
|
||||
|
||||
@manager.option('-o', '--old-certificate', dest='old_certificate_name', help='Name of the certificate you wish to reissue.')
|
||||
@manager.option('-c', '--commit', dest='commit', action='store_true', default=False, help='Persist changes.')
|
||||
def reissue(old_certificate_name, commit):
|
||||
"""
|
||||
Reissues certificate with the same parameters as it was originally issued with.
|
||||
If not time period is provided, reissues certificate as valid from today to
|
||||
today + length of original.
|
||||
"""
|
||||
if commit:
|
||||
print("[!] Running in COMMIT mode.")
|
||||
|
||||
print("[+] Starting certificate re-issuance.")
|
||||
|
||||
status = FAILURE_METRIC_STATUS
|
||||
|
||||
try:
|
||||
old_cert = validate_certificate(old_certificate_name)
|
||||
|
||||
if not old_cert:
|
||||
for certificate in get_all_pending_reissue():
|
||||
request_reissue(certificate, commit)
|
||||
else:
|
||||
request_reissue(old_cert, commit)
|
||||
|
||||
status = SUCCESS_METRIC_STATUS
|
||||
print("[+] Done!")
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.exception("Error reissuing certificate.", exc_info=True)
|
||||
print(
|
||||
"[!] Failed to reissue certificates. Reason: {}".format(
|
||||
e
|
||||
)
|
||||
)
|
||||
|
||||
metrics.send('certificate_reissue_job', 'counter', 1, metric_tags={'status': status})
|
||||
|
||||
|
||||
@manager.option('-f', '--fqdns', dest='fqdns', help='FQDNs to query. Multiple fqdns specified via comma.')
|
||||
@manager.option('-i', '--issuer', dest='issuer', help='Issuer to query for.')
|
||||
@manager.option('-o', '--owner', dest='owner', help='Owner to query for.')
|
||||
@manager.option('-e', '--expired', dest='expired', type=bool, default=False, help='Include expired certificates.')
|
||||
def query(fqdns, issuer, owner, expired):
|
||||
"""Prints certificates that match the query params."""
|
||||
table = []
|
||||
|
||||
q = database.session_query(Certificate)
|
||||
|
||||
sub_query = database.session_query(Authority.id) \
|
||||
.filter(Authority.name.ilike('%{0}%'.format(issuer))) \
|
||||
.subquery()
|
||||
|
||||
q = q.filter(
|
||||
or_(
|
||||
Certificate.issuer.ilike('%{0}%'.format(issuer)),
|
||||
Certificate.authority_id.in_(sub_query)
|
||||
)
|
||||
)
|
||||
|
||||
q = q.filter(Certificate.owner.ilike('%{0}%'.format(owner)))
|
||||
|
||||
if not expired:
|
||||
q = q.filter(Certificate.expired == False) # noqa
|
||||
|
||||
for f in fqdns.split(','):
|
||||
q = q.filter(
|
||||
or_(
|
||||
Certificate.cn.ilike('%{0}%'.format(f)),
|
||||
Certificate.domains.any(Domain.name.ilike('%{0}%'.format(f)))
|
||||
)
|
||||
)
|
||||
|
||||
for c in q.all():
|
||||
table.append([c.id, c.name, c.owner, c.issuer])
|
||||
|
||||
print(tabulate(table, headers=['Id', 'Name', 'Owner', 'Issuer'], tablefmt='csv'))
|
||||
|
||||
|
||||
def worker(data, commit, reason):
|
||||
parts = [x for x in data.split(' ') if x]
|
||||
try:
|
||||
cert = get(int(parts[0].strip()))
|
||||
plugin = plugins.get(cert.authority.plugin_name)
|
||||
|
||||
print('[+] Revoking certificate. Id: {0} Name: {1}'.format(cert.id, cert.name))
|
||||
if commit:
|
||||
plugin.revoke_certificate(cert, reason)
|
||||
|
||||
metrics.send('certificate_revoke', 'counter', 1, metric_tags={'status': SUCCESS_METRIC_STATUS})
|
||||
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
metrics.send('certificate_revoke', 'counter', 1, metric_tags={'status': FAILURE_METRIC_STATUS})
|
||||
print(
|
||||
"[!] Failed to revoke certificates. Reason: {}".format(
|
||||
e
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@manager.command
|
||||
def clear_pending():
|
||||
"""
|
||||
Function clears all pending certificates.
|
||||
:return:
|
||||
"""
|
||||
v = plugins.get('verisign-issuer')
|
||||
v.clear_pending_certificates()
|
||||
|
||||
|
||||
@manager.option('-p', '--path', dest='path', help='Absolute file path to a Lemur query csv.')
|
||||
@manager.option('-r', '--reason', dest='reason', help='Reason to revoke certificate.')
|
||||
@manager.option('-c', '--commit', dest='commit', action='store_true', default=False, help='Persist changes.')
|
||||
def revoke(path, reason, commit):
|
||||
"""
|
||||
Revokes given certificate.
|
||||
"""
|
||||
if commit:
|
||||
print("[!] Running in COMMIT mode.")
|
||||
|
||||
print("[+] Starting certificate revocation.")
|
||||
|
||||
with open(path, 'r') as f:
|
||||
args = [[x, commit, reason] for x in f.readlines()[2:]]
|
||||
|
||||
with multiprocessing.Pool(processes=3) as pool:
|
||||
pool.starmap(worker, args)
|
||||
|
||||
|
||||
@manager.command
|
||||
def check_revoked():
|
||||
"""
|
||||
Function attempts to update Lemur's internal cache with revoked
|
||||
certificates. This is called periodically by Lemur. It checks both
|
||||
CRLs and OCSP to see if a certificate is revoked. If Lemur is unable
|
||||
encounters an issue with verification it marks the certificate status
|
||||
as `unknown`.
|
||||
"""
|
||||
for cert in get_all_certs():
|
||||
try:
|
||||
if cert.chain:
|
||||
status = verify_string(cert.body, cert.chain)
|
||||
else:
|
||||
status = verify_string(cert.body, "")
|
||||
|
||||
cert.status = 'valid' if status else 'revoked'
|
||||
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.exception(e)
|
||||
cert.status = 'unknown'
|
||||
|
||||
database.update(cert)
|
@ -1,88 +0,0 @@
|
||||
"""
|
||||
.. module: lemur.certificates.exceptions
|
||||
:synopsis: Defines all monterey specific exceptions
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask import current_app
|
||||
from lemur.exceptions import LemurException
|
||||
|
||||
|
||||
class UnknownAuthority(LemurException):
|
||||
def __init__(self, authority):
|
||||
self.code = 404
|
||||
self.authority = authority
|
||||
self.data = {"message": "The authority specified '{}' is not a valid authority".format(self.authority)}
|
||||
|
||||
current_app.logger.warning(self)
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.data['message'])
|
||||
|
||||
|
||||
class InsufficientDomains(LemurException):
|
||||
def __init__(self):
|
||||
self.code = 400
|
||||
self.data = {"message": "Need at least one domain specified in order create a certificate"}
|
||||
|
||||
current_app.logger.warning(self)
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.data['message'])
|
||||
|
||||
|
||||
class InvalidCertificate(LemurException):
|
||||
def __init__(self):
|
||||
self.code = 400
|
||||
self.data = {"message": "Need at least one domain specified in order create a certificate"}
|
||||
|
||||
current_app.logger.warning(self)
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.data['message'])
|
||||
|
||||
|
||||
class UnableToCreateCSR(LemurException):
|
||||
def __init__(self):
|
||||
self.code = 500
|
||||
self.data = {"message": "Unable to generate CSR"}
|
||||
|
||||
current_app.logger.error(self)
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.data['message'])
|
||||
|
||||
|
||||
class UnableToCreatePrivateKey(LemurException):
|
||||
def __init__(self):
|
||||
self.code = 500
|
||||
self.data = {"message": "Unable to generate Private Key"}
|
||||
|
||||
current_app.logger.error(self)
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.data['message'])
|
||||
|
||||
|
||||
class MissingFiles(LemurException):
|
||||
def __init__(self, path):
|
||||
self.code = 500
|
||||
self.path = path
|
||||
self.data = {"path": self.path, "message": "Expecting missing files"}
|
||||
|
||||
current_app.logger.error(self)
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.data['message'])
|
||||
|
||||
|
||||
class NoPersistanceFound(LemurException):
|
||||
def __init__(self):
|
||||
self.code = 500
|
||||
self.data = {"code": 500, "message": "No peristence method found, Lemur cannot persist sensitive information"}
|
||||
|
||||
current_app.logger.error(self)
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.data['message'])
|
38
lemur/certificates/hooks.py
Normal file
38
lemur/certificates/hooks.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""
|
||||
Debugging hooks for dumping imported or generated CSR and certificate details to stdout via OpenSSL.
|
||||
|
||||
.. module: lemur.certificates.hooks
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Marti Raudsepp, see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Marti Raudsepp <marti@juffo.org>
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from lemur.certificates.service import csr_created, csr_imported, certificate_issued, certificate_imported
|
||||
|
||||
|
||||
def csr_dump_handler(sender, csr, **kwargs):
|
||||
try:
|
||||
subprocess.run(['openssl', 'req', '-text', '-noout', '-reqopt', 'no_sigdump,no_pubkey'],
|
||||
input=csr.encode('utf8'))
|
||||
except Exception as err:
|
||||
current_app.logger.warning("Error inspecting CSR: %s", err)
|
||||
|
||||
|
||||
def cert_dump_handler(sender, certificate, **kwargs):
|
||||
try:
|
||||
subprocess.run(['openssl', 'x509', '-text', '-noout', '-certopt', 'no_sigdump,no_pubkey'],
|
||||
input=certificate.body.encode('utf8'))
|
||||
except Exception as err:
|
||||
current_app.logger.warning("Error inspecting certificate: %s", err)
|
||||
|
||||
|
||||
def activate_debug_dump():
|
||||
csr_created.connect(csr_dump_handler)
|
||||
csr_imported.connect(csr_dump_handler)
|
||||
certificate_issued.connect(cert_dump_handler)
|
||||
certificate_imported.connect(cert_dump_handler)
|
@ -1,46 +1,96 @@
|
||||
"""
|
||||
.. module: lemur.certificates.models
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import datetime
|
||||
import arrow
|
||||
from datetime import timedelta
|
||||
|
||||
import lemur.common.utils
|
||||
from flask import current_app
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from idna.core import InvalidCodepoint
|
||||
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql.expression import case
|
||||
from sqlalchemy.sql.expression import case, extract
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy import event, Integer, ForeignKey, String, DateTime, PassiveDefault, func, Column, Text, Boolean
|
||||
from sqlalchemy import event, Integer, ForeignKey, String, PassiveDefault, func, Column, Text, Boolean
|
||||
|
||||
from sqlalchemy_utils.types.arrow import ArrowType
|
||||
|
||||
import lemur.common.utils
|
||||
|
||||
from lemur.database import db
|
||||
from lemur.extensions import sentry
|
||||
|
||||
from lemur.utils import Vault
|
||||
from lemur.common import defaults
|
||||
|
||||
from lemur.plugins.base import plugins
|
||||
|
||||
from lemur.extensions import metrics
|
||||
from lemur.constants import SUCCESS_METRIC_STATUS, FAILURE_METRIC_STATUS
|
||||
|
||||
from lemur.models import certificate_associations, certificate_source_associations, \
|
||||
certificate_destination_associations, certificate_notification_associations, \
|
||||
certificate_replacement_associations, roles_certificates
|
||||
from lemur.plugins.base import plugins
|
||||
from lemur.utils import Vault
|
||||
certificate_replacement_associations, roles_certificates, pending_cert_replacement_associations
|
||||
|
||||
from lemur.common import defaults
|
||||
from lemur.domains.models import Domain
|
||||
from lemur.policies.models import RotationPolicy
|
||||
|
||||
|
||||
def get_or_increase_name(name):
|
||||
name = '-'.join(name.strip().split(' '))
|
||||
count = Certificate.query.filter(Certificate.name.ilike('{0}%'.format(name))).count()
|
||||
def get_sequence(name):
|
||||
if '-' not in name:
|
||||
return name, None
|
||||
|
||||
if count >= 1:
|
||||
return name + '-' + str(count)
|
||||
parts = name.split('-')
|
||||
|
||||
return name
|
||||
# see if we have an int at the end of our name
|
||||
try:
|
||||
seq = int(parts[-1])
|
||||
except ValueError:
|
||||
return name, None
|
||||
|
||||
# we might have a date at the end of our name
|
||||
if len(parts[-1]) == 8:
|
||||
return name, None
|
||||
|
||||
root = '-'.join(parts[:-1])
|
||||
return root, seq
|
||||
|
||||
|
||||
def get_or_increase_name(name, serial):
|
||||
certificates = Certificate.query.filter(Certificate.name.ilike('{0}%'.format(name))).all()
|
||||
|
||||
if not certificates:
|
||||
return name
|
||||
|
||||
serial_name = '{0}-{1}'.format(name, hex(int(serial))[2:].upper())
|
||||
certificates = Certificate.query.filter(Certificate.name.ilike('{0}%'.format(serial_name))).all()
|
||||
|
||||
if not certificates:
|
||||
return serial_name
|
||||
|
||||
ends = [0]
|
||||
root, end = get_sequence(serial_name)
|
||||
for cert in certificates:
|
||||
root, end = get_sequence(cert.name)
|
||||
if end:
|
||||
ends.append(end)
|
||||
|
||||
return '{0}-{1}'.format(root, max(ends) + 1)
|
||||
|
||||
|
||||
class Certificate(db.Model):
|
||||
__tablename__ = 'certificates'
|
||||
id = Column(Integer, primary_key=True)
|
||||
external_id = Column(String(128))
|
||||
owner = Column(String(128), nullable=False)
|
||||
name = Column(String(128), unique=True)
|
||||
name = Column(String(256), unique=True)
|
||||
description = Column(String(1024))
|
||||
notify = Column(Boolean, default=True)
|
||||
|
||||
@ -52,32 +102,44 @@ class Certificate(db.Model):
|
||||
serial = Column(String(128))
|
||||
cn = Column(String(128))
|
||||
deleted = Column(Boolean, index=True)
|
||||
dns_provider_id = Column(Integer(), ForeignKey('dns_providers.id', ondelete='cascade'), nullable=True)
|
||||
|
||||
not_before = Column(DateTime)
|
||||
not_after = Column(DateTime)
|
||||
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
|
||||
not_before = Column(ArrowType)
|
||||
not_after = Column(ArrowType)
|
||||
date_created = Column(ArrowType, PassiveDefault(func.now()), nullable=False)
|
||||
|
||||
signing_algorithm = Column(String(128))
|
||||
status = Column(String(128))
|
||||
bits = Column(Integer())
|
||||
san = Column(String(1024)) # TODO this should be migrated to boolean
|
||||
|
||||
rotation = Column(Boolean, default=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id'))
|
||||
authority_id = Column(Integer, ForeignKey('authorities.id', ondelete="CASCADE"))
|
||||
root_authority_id = Column(Integer, ForeignKey('authorities.id', ondelete="CASCADE"))
|
||||
rotation_policy_id = Column(Integer, ForeignKey('rotation_policies.id'))
|
||||
|
||||
notifications = relationship("Notification", secondary=certificate_notification_associations, backref='certificate')
|
||||
destinations = relationship("Destination", secondary=certificate_destination_associations, backref='certificate')
|
||||
sources = relationship("Source", secondary=certificate_source_associations, backref='certificate')
|
||||
domains = relationship("Domain", secondary=certificate_associations, backref="certificate")
|
||||
roles = relationship("Role", secondary=roles_certificates, backref="certificate")
|
||||
replaces = relationship("Certificate",
|
||||
notifications = relationship('Notification', secondary=certificate_notification_associations, backref='certificate')
|
||||
destinations = relationship('Destination', secondary=certificate_destination_associations, backref='certificate')
|
||||
sources = relationship('Source', secondary=certificate_source_associations, backref='certificate')
|
||||
domains = relationship('Domain', secondary=certificate_associations, backref='certificate')
|
||||
roles = relationship('Role', secondary=roles_certificates, backref='certificate')
|
||||
replaces = relationship('Certificate',
|
||||
secondary=certificate_replacement_associations,
|
||||
primaryjoin=id == certificate_replacement_associations.c.certificate_id, # noqa
|
||||
secondaryjoin=id == certificate_replacement_associations.c.replaced_certificate_id, # noqa
|
||||
backref='replaced')
|
||||
|
||||
endpoints = relationship("Endpoint", backref='certificate')
|
||||
replaced_by_pending = relationship('PendingCertificate',
|
||||
secondary=pending_cert_replacement_associations,
|
||||
backref='pending_replace',
|
||||
viewonly=True)
|
||||
|
||||
logs = relationship('Log', backref='certificate')
|
||||
endpoints = relationship('Endpoint', backref='certificate')
|
||||
rotation_policy = relationship("RotationPolicy")
|
||||
|
||||
sensitive_fields = ('private_key',)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
cert = lemur.common.utils.parse_certificate(kwargs['body'])
|
||||
@ -87,12 +149,14 @@ class Certificate(db.Model):
|
||||
self.san = defaults.san(cert)
|
||||
self.not_before = defaults.not_before(cert)
|
||||
self.not_after = defaults.not_after(cert)
|
||||
self.serial = defaults.serial(cert)
|
||||
|
||||
# when destinations are appended they require a valid name.
|
||||
if kwargs.get('name'):
|
||||
self.name = get_or_increase_name(kwargs['name'])
|
||||
self.name = get_or_increase_name(defaults.text_to_slug(kwargs['name']), self.serial)
|
||||
else:
|
||||
self.name = get_or_increase_name(defaults.certificate_name(self.cn, self.issuer, self.not_before, self.not_after, self.san))
|
||||
self.name = get_or_increase_name(
|
||||
defaults.certificate_name(self.cn, self.issuer, self.not_before, self.not_after, self.san), self.serial)
|
||||
|
||||
self.owner = kwargs['owner']
|
||||
self.body = kwargs['body'].strip()
|
||||
@ -108,10 +172,14 @@ class Certificate(db.Model):
|
||||
self.notifications = kwargs.get('notifications', [])
|
||||
self.description = kwargs.get('description')
|
||||
self.roles = list(set(kwargs.get('roles', [])))
|
||||
self.replaces = kwargs.get('replacements', [])
|
||||
self.replaces = kwargs.get('replaces', [])
|
||||
self.rotation = kwargs.get('rotation')
|
||||
self.rotation_policy = kwargs.get('rotation_policy')
|
||||
self.signing_algorithm = defaults.signing_algorithm(cert)
|
||||
self.bits = defaults.bitstrength(cert)
|
||||
self.serial = defaults.serial(cert)
|
||||
self.external_id = kwargs.get('external_id')
|
||||
self.authority_id = kwargs.get('authority_id')
|
||||
self.dns_provider_id = kwargs.get('dns_provider_id')
|
||||
|
||||
for domain in defaults.domains(cert):
|
||||
self.domains.append(Domain(name=domain))
|
||||
@ -120,16 +188,65 @@ class Certificate(db.Model):
|
||||
def active(self):
|
||||
return self.notify
|
||||
|
||||
@property
|
||||
def organization(self):
|
||||
cert = lemur.common.utils.parse_certificate(self.body)
|
||||
return defaults.organization(cert)
|
||||
|
||||
@property
|
||||
def organizational_unit(self):
|
||||
cert = lemur.common.utils.parse_certificate(self.body)
|
||||
return defaults.organizational_unit(cert)
|
||||
|
||||
@property
|
||||
def country(self):
|
||||
cert = lemur.common.utils.parse_certificate(self.body)
|
||||
return defaults.country(cert)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
cert = lemur.common.utils.parse_certificate(self.body)
|
||||
return defaults.state(cert)
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
cert = lemur.common.utils.parse_certificate(self.body)
|
||||
return defaults.location(cert)
|
||||
|
||||
@property
|
||||
def key_type(self):
|
||||
cert = lemur.common.utils.parse_certificate(self.body)
|
||||
if isinstance(cert.public_key(), rsa.RSAPublicKey):
|
||||
return 'RSA{key_size}'.format(key_size=cert.public_key().key_size)
|
||||
|
||||
@property
|
||||
def validity_remaining(self):
|
||||
return abs(self.not_after - arrow.utcnow())
|
||||
|
||||
@property
|
||||
def validity_range(self):
|
||||
return self.not_after - self.not_before
|
||||
|
||||
@property
|
||||
def subject(self):
|
||||
cert = lemur.common.utils.parse_certificate(self.body)
|
||||
return cert.subject
|
||||
|
||||
@property
|
||||
def public_key(self):
|
||||
cert = lemur.common.utils.parse_certificate(self.body)
|
||||
return cert.public_key()
|
||||
|
||||
@hybrid_property
|
||||
def expired(self):
|
||||
if self.not_after <= datetime.datetime.now():
|
||||
if self.not_after <= arrow.utcnow():
|
||||
return True
|
||||
|
||||
@expired.expression
|
||||
def expired(cls):
|
||||
return case(
|
||||
[
|
||||
(cls.now_after <= datetime.datetime.now(), True)
|
||||
(cls.not_after <= arrow.utcnow(), True)
|
||||
],
|
||||
else_=False
|
||||
)
|
||||
@ -148,15 +265,91 @@ class Certificate(db.Model):
|
||||
else_=False
|
||||
)
|
||||
|
||||
def get_arn(self, account_number):
|
||||
@hybrid_property
|
||||
def in_rotation_window(self):
|
||||
"""
|
||||
Generate a valid AWS IAM arn
|
||||
|
||||
:rtype : str
|
||||
:param account_number:
|
||||
Determines if a certificate is available for rotation based
|
||||
on the rotation policy associated.
|
||||
:return:
|
||||
"""
|
||||
return "arn:aws:iam::{}:server-certificate/{}".format(account_number, self.name)
|
||||
now = arrow.utcnow()
|
||||
end = now + timedelta(days=self.rotation_policy.days)
|
||||
|
||||
if self.not_after <= end:
|
||||
return True
|
||||
|
||||
@in_rotation_window.expression
|
||||
def in_rotation_window(cls):
|
||||
"""
|
||||
Determines if a certificate is available for rotation based
|
||||
on the rotation policy associated.
|
||||
:return:
|
||||
"""
|
||||
return case(
|
||||
[
|
||||
(extract('day', cls.not_after - func.now()) <= RotationPolicy.days, True)
|
||||
],
|
||||
else_=False
|
||||
)
|
||||
|
||||
@property
|
||||
def extensions(self):
|
||||
# setup default values
|
||||
return_extensions = {
|
||||
'sub_alt_names': {'names': []}
|
||||
}
|
||||
|
||||
try:
|
||||
cert = lemur.common.utils.parse_certificate(self.body)
|
||||
for extension in cert.extensions:
|
||||
value = extension.value
|
||||
if isinstance(value, x509.BasicConstraints):
|
||||
return_extensions['basic_constraints'] = value
|
||||
|
||||
elif isinstance(value, x509.SubjectAlternativeName):
|
||||
return_extensions['sub_alt_names']['names'] = value
|
||||
|
||||
elif isinstance(value, x509.ExtendedKeyUsage):
|
||||
return_extensions['extended_key_usage'] = value
|
||||
|
||||
elif isinstance(value, x509.KeyUsage):
|
||||
return_extensions['key_usage'] = value
|
||||
|
||||
elif isinstance(value, x509.SubjectKeyIdentifier):
|
||||
return_extensions['subject_key_identifier'] = {'include_ski': True}
|
||||
|
||||
elif isinstance(value, x509.AuthorityInformationAccess):
|
||||
return_extensions['certificate_info_access'] = {'include_aia': True}
|
||||
|
||||
elif isinstance(value, x509.AuthorityKeyIdentifier):
|
||||
aki = {
|
||||
'use_key_identifier': False,
|
||||
'use_authority_cert': False
|
||||
}
|
||||
|
||||
if value.key_identifier:
|
||||
aki['use_key_identifier'] = True
|
||||
|
||||
if value.authority_cert_issuer:
|
||||
aki['use_authority_cert'] = True
|
||||
|
||||
return_extensions['authority_key_identifier'] = aki
|
||||
|
||||
elif isinstance(value, x509.CRLDistributionPoints):
|
||||
return_extensions['crl_distribution_points'] = {'include_crl_dp': value}
|
||||
|
||||
# TODO: Not supporting custom OIDs yet. https://github.com/Netflix/lemur/issues/665
|
||||
else:
|
||||
current_app.logger.warning('Custom OIDs not yet supported for clone operation.')
|
||||
except InvalidCodepoint as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.warning('Unable to parse extensions due to underscore in dns name')
|
||||
except ValueError as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.warning('Unable to parse')
|
||||
current_app.logger.exception(e)
|
||||
|
||||
return return_extensions
|
||||
|
||||
def __repr__(self):
|
||||
return "Certificate(name={name})".format(name=self.name)
|
||||
@ -165,7 +358,7 @@ class Certificate(db.Model):
|
||||
@event.listens_for(Certificate.destinations, 'append')
|
||||
def update_destinations(target, value, initiator):
|
||||
"""
|
||||
Attempt to upload the new certificate to the new destination
|
||||
Attempt to upload certificate to the new destination
|
||||
|
||||
:param target:
|
||||
:param value:
|
||||
@ -173,11 +366,16 @@ def update_destinations(target, value, initiator):
|
||||
:return:
|
||||
"""
|
||||
destination_plugin = plugins.get(value.plugin_name)
|
||||
|
||||
status = FAILURE_METRIC_STATUS
|
||||
try:
|
||||
destination_plugin.upload(target.name, target.body, target.private_key, target.chain, value.options)
|
||||
if target.private_key:
|
||||
destination_plugin.upload(target.name, target.body, target.private_key, target.chain, value.options)
|
||||
status = SUCCESS_METRIC_STATUS
|
||||
except Exception as e:
|
||||
current_app.logger.exception(e)
|
||||
sentry.captureException()
|
||||
|
||||
metrics.send('destination_upload', 'counter', 1,
|
||||
metric_tags={'status': status, 'certificate': target.name, 'destination': value.label})
|
||||
|
||||
|
||||
@event.listens_for(Certificate.replaces, 'append')
|
||||
@ -191,20 +389,3 @@ def update_replacement(target, value, initiator):
|
||||
:return:
|
||||
"""
|
||||
value.notify = False
|
||||
|
||||
|
||||
# @event.listens_for(Certificate, 'before_update')
|
||||
# def protect_active(mapper, connection, target):
|
||||
# """
|
||||
# When a certificate has a replacement do not allow it to be marked as 'active'
|
||||
#
|
||||
# :param connection:
|
||||
# :param mapper:
|
||||
# :param target:
|
||||
# :return:
|
||||
# """
|
||||
# if target.active:
|
||||
# if not target.notify:
|
||||
# raise Exception(
|
||||
# "Cannot silence notification for a certificate Lemur has been found to be currently deployed onto endpoints"
|
||||
# )
|
||||
|
@ -1,51 +1,63 @@
|
||||
"""
|
||||
.. module: lemur.certificates.schemas
|
||||
:platform: unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask import current_app
|
||||
from marshmallow import fields, validates_schema, post_load, pre_load
|
||||
from marshmallow import fields, validate, validates_schema, post_load, pre_load
|
||||
from marshmallow.exceptions import ValidationError
|
||||
|
||||
from lemur.schemas import AssociatedAuthoritySchema, AssociatedDestinationSchema, AssociatedCertificateSchema, \
|
||||
AssociatedNotificationSchema, PluginInputSchema, ExtensionSchema, AssociatedRoleSchema, EndpointNestedOutputSchema
|
||||
|
||||
from lemur.authorities.schemas import AuthorityNestedOutputSchema
|
||||
from lemur.destinations.schemas import DestinationNestedOutputSchema
|
||||
from lemur.notifications.schemas import NotificationNestedOutputSchema
|
||||
from lemur.roles.schemas import RoleNestedOutputSchema
|
||||
from lemur.domains.schemas import DomainNestedOutputSchema
|
||||
from lemur.users.schemas import UserNestedOutputSchema
|
||||
|
||||
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
|
||||
from lemur.common import validators, missing
|
||||
from lemur.common.fields import ArrowDateTime, Hex
|
||||
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
|
||||
from lemur.constants import CERTIFICATE_KEY_TYPES
|
||||
from lemur.destinations.schemas import DestinationNestedOutputSchema
|
||||
from lemur.domains.schemas import DomainNestedOutputSchema
|
||||
from lemur.notifications import service as notification_service
|
||||
|
||||
from lemur.common.fields import ArrowDateTime
|
||||
from lemur.notifications.schemas import NotificationNestedOutputSchema
|
||||
from lemur.policies.schemas import RotationPolicyNestedOutputSchema
|
||||
from lemur.roles.schemas import RoleNestedOutputSchema
|
||||
from lemur.schemas import (
|
||||
AssociatedAuthoritySchema,
|
||||
AssociatedDestinationSchema,
|
||||
AssociatedCertificateSchema,
|
||||
AssociatedNotificationSchema,
|
||||
AssociatedDnsProviderSchema,
|
||||
PluginInputSchema,
|
||||
ExtensionSchema,
|
||||
AssociatedRoleSchema,
|
||||
EndpointNestedOutputSchema,
|
||||
AssociatedRotationPolicySchema,
|
||||
)
|
||||
from lemur.users.schemas import UserNestedOutputSchema
|
||||
|
||||
|
||||
class CertificateSchema(LemurInputSchema):
|
||||
owner = fields.Email(required=True)
|
||||
description = fields.String()
|
||||
description = fields.String(missing='', allow_none=True)
|
||||
|
||||
|
||||
class CertificateCreationSchema(CertificateSchema):
|
||||
@post_load
|
||||
def default_notification(self, data):
|
||||
if not data['notifications']:
|
||||
notification_name = "DEFAULT_{0}".format(data['owner'].split('@')[0].upper())
|
||||
data['notifications'] += notification_service.create_default_expiration_notifications(notification_name, [data['owner']])
|
||||
|
||||
notification_name = 'DEFAULT_SECURITY'
|
||||
data['notifications'] += notification_service.create_default_expiration_notifications(notification_name, current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL'))
|
||||
data['notifications'] += notification_service.create_default_expiration_notifications(
|
||||
"DEFAULT_{0}".format(data['owner'].split('@')[0].upper()),
|
||||
[data['owner']],
|
||||
)
|
||||
data['notifications'] += notification_service.create_default_expiration_notifications(
|
||||
'DEFAULT_SECURITY',
|
||||
current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL')
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
class CertificateInputSchema(CertificateCreationSchema):
|
||||
name = fields.String()
|
||||
common_name = fields.String(required=True, validate=validators.sensitive_domain)
|
||||
common_name = fields.String(required=True, validate=validators.common_name)
|
||||
authority = fields.Nested(AssociatedAuthoritySchema, required=True)
|
||||
|
||||
validity_start = ArrowDateTime()
|
||||
@ -54,12 +66,20 @@ class CertificateInputSchema(CertificateCreationSchema):
|
||||
|
||||
destinations = fields.Nested(AssociatedDestinationSchema, missing=[], many=True)
|
||||
notifications = fields.Nested(AssociatedNotificationSchema, missing=[], many=True)
|
||||
replacements = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)
|
||||
replaces = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)
|
||||
replacements = fields.Nested(AssociatedCertificateSchema, missing=[], many=True) # deprecated
|
||||
roles = fields.Nested(AssociatedRoleSchema, missing=[], many=True)
|
||||
dns_provider = fields.Nested(AssociatedDnsProviderSchema, missing=None, allow_none=True, required=False)
|
||||
|
||||
csr = fields.String(validate=validators.csr)
|
||||
|
||||
key_type = fields.String(
|
||||
validate=validate.OneOf(CERTIFICATE_KEY_TYPES),
|
||||
missing='RSA2048')
|
||||
|
||||
notify = fields.Boolean(default=True)
|
||||
rotation = fields.Boolean()
|
||||
rotation_policy = fields.Nested(AssociatedRotationPolicySchema, missing={'name': 'default'}, default={'name': 'default'})
|
||||
|
||||
# certificate body fields
|
||||
organizational_unit = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_ORGANIZATIONAL_UNIT'))
|
||||
@ -70,23 +90,40 @@ class CertificateInputSchema(CertificateCreationSchema):
|
||||
|
||||
extensions = fields.Nested(ExtensionSchema)
|
||||
|
||||
@validates_schema
|
||||
def validate_authority(self, data):
|
||||
if not data['authority'].active:
|
||||
raise ValidationError("The authority is inactive.", ['authority'])
|
||||
|
||||
@validates_schema
|
||||
def validate_dates(self, data):
|
||||
validators.dates(data)
|
||||
|
||||
@pre_load
|
||||
def ensure_dates(self, data):
|
||||
def load_data(self, data):
|
||||
if data.get('replacements'):
|
||||
data['replaces'] = data['replacements'] # TODO remove when field is deprecated
|
||||
return missing.convert_validity_years(data)
|
||||
|
||||
|
||||
class CertificateEditInputSchema(CertificateSchema):
|
||||
notify = fields.Boolean()
|
||||
owner = fields.String()
|
||||
|
||||
notify = fields.Boolean()
|
||||
rotation = fields.Boolean()
|
||||
|
||||
destinations = fields.Nested(AssociatedDestinationSchema, missing=[], many=True)
|
||||
notifications = fields.Nested(AssociatedNotificationSchema, missing=[], many=True)
|
||||
replacements = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)
|
||||
replaces = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)
|
||||
replacements = fields.Nested(AssociatedCertificateSchema, missing=[], many=True) # deprecated
|
||||
roles = fields.Nested(AssociatedRoleSchema, missing=[], many=True)
|
||||
|
||||
@pre_load
|
||||
def load_data(self, data):
|
||||
if data.get('replacements'):
|
||||
data['replaces'] = data['replacements'] # TODO remove when field is deprecated
|
||||
return data
|
||||
|
||||
@post_load
|
||||
def enforce_notifications(self, data):
|
||||
"""
|
||||
@ -104,18 +141,32 @@ class CertificateEditInputSchema(CertificateSchema):
|
||||
class CertificateNestedOutputSchema(LemurOutputSchema):
|
||||
__envelope__ = False
|
||||
id = fields.Integer()
|
||||
active = fields.Boolean()
|
||||
name = fields.String()
|
||||
owner = fields.Email()
|
||||
creator = fields.Nested(UserNestedOutputSchema)
|
||||
description = fields.String()
|
||||
|
||||
status = fields.String()
|
||||
|
||||
bits = fields.Integer()
|
||||
body = fields.String()
|
||||
chain = fields.String()
|
||||
description = fields.String()
|
||||
name = fields.String()
|
||||
cn = fields.String()
|
||||
not_after = fields.DateTime()
|
||||
not_before = fields.DateTime()
|
||||
owner = fields.Email()
|
||||
status = fields.Boolean()
|
||||
creator = fields.Nested(UserNestedOutputSchema)
|
||||
active = fields.Boolean()
|
||||
|
||||
rotation = fields.Boolean()
|
||||
notify = fields.Boolean()
|
||||
rotation_policy = fields.Nested(RotationPolicyNestedOutputSchema)
|
||||
|
||||
# Note aliasing is the first step in deprecating these fields.
|
||||
cn = fields.String() # deprecated
|
||||
common_name = fields.String(attribute='cn')
|
||||
|
||||
not_after = fields.DateTime() # deprecated
|
||||
validity_end = ArrowDateTime(attribute='not_after')
|
||||
|
||||
not_before = fields.DateTime() # deprecated
|
||||
validity_start = ArrowDateTime(attribute='not_before')
|
||||
|
||||
issuer = fields.Nested(AuthorityNestedOutputSchema)
|
||||
|
||||
|
||||
@ -127,8 +178,7 @@ class CertificateCloneSchema(LemurOutputSchema):
|
||||
|
||||
class CertificateOutputSchema(LemurOutputSchema):
|
||||
id = fields.Integer()
|
||||
active = fields.Boolean()
|
||||
notify = fields.Boolean()
|
||||
external_id = fields.String()
|
||||
bits = fields.Integer()
|
||||
body = fields.String()
|
||||
chain = fields.String()
|
||||
@ -136,15 +186,35 @@ class CertificateOutputSchema(LemurOutputSchema):
|
||||
description = fields.String()
|
||||
issuer = fields.String()
|
||||
name = fields.String()
|
||||
dns_provider_id = fields.Integer(required=False, allow_none=True)
|
||||
|
||||
rotation = fields.Boolean()
|
||||
|
||||
# Note aliasing is the first step in deprecating these fields.
|
||||
notify = fields.Boolean()
|
||||
active = fields.Boolean(attribute='notify')
|
||||
|
||||
cn = fields.String()
|
||||
common_name = fields.String(attribute='cn')
|
||||
|
||||
not_after = fields.DateTime()
|
||||
validity_end = ArrowDateTime(attribute='not_after')
|
||||
|
||||
not_before = fields.DateTime()
|
||||
validity_start = ArrowDateTime(attribute='not_before')
|
||||
|
||||
owner = fields.Email()
|
||||
san = fields.Boolean()
|
||||
serial = fields.String()
|
||||
serial_hex = Hex(attribute='serial')
|
||||
signing_algorithm = fields.String()
|
||||
status = fields.Boolean()
|
||||
|
||||
status = fields.String()
|
||||
user = fields.Nested(UserNestedOutputSchema)
|
||||
|
||||
extensions = fields.Nested(ExtensionSchema)
|
||||
|
||||
# associated objects
|
||||
domains = fields.Nested(DomainNestedOutputSchema, many=True)
|
||||
destinations = fields.Nested(DestinationNestedOutputSchema, many=True)
|
||||
notifications = fields.Nested(NotificationNestedOutputSchema, many=True)
|
||||
@ -152,6 +222,8 @@ class CertificateOutputSchema(LemurOutputSchema):
|
||||
authority = fields.Nested(AuthorityNestedOutputSchema)
|
||||
roles = fields.Nested(RoleNestedOutputSchema, many=True)
|
||||
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])
|
||||
replaced_by = fields.Nested(CertificateNestedOutputSchema, many=True, attribute='replaced')
|
||||
rotation_policy = fields.Nested(RotationPolicyNestedOutputSchema)
|
||||
|
||||
|
||||
class CertificateUploadInputSchema(CertificateCreationSchema):
|
||||
@ -160,11 +232,11 @@ class CertificateUploadInputSchema(CertificateCreationSchema):
|
||||
|
||||
private_key = fields.String(validate=validators.private_key)
|
||||
body = fields.String(required=True, validate=validators.public_certificate)
|
||||
chain = fields.String(validate=validators.public_certificate) # TODO this could be multiple certificates
|
||||
chain = fields.String(validate=validators.public_certificate, missing=None, allow_none=True) # TODO this could be multiple certificates
|
||||
|
||||
destinations = fields.Nested(AssociatedDestinationSchema, missing=[], many=True)
|
||||
notifications = fields.Nested(AssociatedNotificationSchema, missing=[], many=True)
|
||||
replacements = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)
|
||||
replaces = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)
|
||||
roles = fields.Nested(AssociatedRoleSchema, missing=[], many=True)
|
||||
|
||||
@validates_schema
|
||||
@ -178,9 +250,26 @@ class CertificateExportInputSchema(LemurInputSchema):
|
||||
plugin = fields.Nested(PluginInputSchema)
|
||||
|
||||
|
||||
class CertificateNotificationOutputSchema(LemurOutputSchema):
|
||||
description = fields.String()
|
||||
issuer = fields.String()
|
||||
name = fields.String()
|
||||
owner = fields.Email()
|
||||
user = fields.Nested(UserNestedOutputSchema)
|
||||
validity_end = ArrowDateTime(attribute='not_after')
|
||||
replaced_by = fields.Nested(CertificateNestedOutputSchema, many=True, attribute='replaced')
|
||||
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])
|
||||
|
||||
|
||||
class CertificateRevokeSchema(LemurInputSchema):
|
||||
comments = fields.String()
|
||||
|
||||
|
||||
certificate_input_schema = CertificateInputSchema()
|
||||
certificate_output_schema = CertificateOutputSchema()
|
||||
certificates_output_schema = CertificateOutputSchema(many=True)
|
||||
certificate_upload_input_schema = CertificateUploadInputSchema()
|
||||
certificate_export_input_schema = CertificateExportInputSchema()
|
||||
certificate_edit_input_schema = CertificateEditInputSchema()
|
||||
certificate_notification_output_schema = CertificateNotificationOutputSchema()
|
||||
certificate_revoke_schema = CertificateRevokeSchema()
|
||||
|
@ -1,37 +1,46 @@
|
||||
"""
|
||||
.. module: service
|
||||
.. module: lemur.certificate.service
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import arrow
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from flask import g, current_app
|
||||
|
||||
from lemur import database
|
||||
from lemur.extensions import metrics
|
||||
from lemur.plugins.base import plugins
|
||||
from lemur.certificates.models import Certificate
|
||||
|
||||
from lemur.destinations.models import Destination
|
||||
from lemur.notifications.models import Notification
|
||||
from lemur.authorities.models import Authority
|
||||
from lemur.domains.models import Domain
|
||||
|
||||
from lemur.roles.models import Role
|
||||
from lemur.roles import service as role_service
|
||||
from flask import current_app
|
||||
from sqlalchemy import func, or_, not_, cast, Integer
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from lemur import database
|
||||
from lemur.extensions import metrics, signals
|
||||
from lemur.plugins.base import plugins
|
||||
from lemur.common.utils import generate_private_key, truthiness
|
||||
|
||||
from lemur.roles.models import Role
|
||||
from lemur.domains.models import Domain
|
||||
from lemur.authorities.models import Authority
|
||||
from lemur.destinations.models import Destination
|
||||
from lemur.certificates.models import Certificate
|
||||
from lemur.notifications.models import Notification
|
||||
from lemur.pending_certificates.models import PendingCertificate
|
||||
|
||||
from lemur.certificates.schemas import CertificateOutputSchema, CertificateInputSchema
|
||||
|
||||
from lemur.roles import service as role_service
|
||||
|
||||
|
||||
csr_created = signals.signal('csr_created', "CSR generated")
|
||||
csr_imported = signals.signal('csr_imported', "CSR imported from external source")
|
||||
certificate_issued = signals.signal('certificate_issued', "Authority issued a certificate")
|
||||
certificate_imported = signals.signal('certificate_imported', "Certificate imported from external source")
|
||||
|
||||
|
||||
def get(cert_id):
|
||||
"""
|
||||
Retrieves certificate by it's ID.
|
||||
Retrieves certificate by its ID.
|
||||
|
||||
:param cert_id:
|
||||
:return:
|
||||
@ -41,7 +50,7 @@ def get(cert_id):
|
||||
|
||||
def get_by_name(name):
|
||||
"""
|
||||
Retrieves certificate by it's Name.
|
||||
Retrieves certificate by its Name.
|
||||
|
||||
:param name:
|
||||
:return:
|
||||
@ -49,6 +58,18 @@ def get_by_name(name):
|
||||
return database.get(Certificate, name, field='name')
|
||||
|
||||
|
||||
def get_by_serial(serial):
|
||||
"""
|
||||
Retrieves certificate by it's Serial.
|
||||
:param serial:
|
||||
:return:
|
||||
"""
|
||||
if isinstance(serial, int):
|
||||
# although serial is a number, the DB column is String(128)
|
||||
serial = str(serial)
|
||||
return Certificate.query.filter(Certificate.serial == serial).all()
|
||||
|
||||
|
||||
def delete(cert_id):
|
||||
"""
|
||||
Delete's a certificate.
|
||||
@ -67,14 +88,30 @@ def get_all_certs():
|
||||
return Certificate.query.all()
|
||||
|
||||
|
||||
def get_by_source(source_label):
|
||||
def get_all_pending_cleaning(source):
|
||||
"""
|
||||
Retrieves all certificates from a given source.
|
||||
Retrieves all certificates that are available for cleaning.
|
||||
|
||||
:param source_label:
|
||||
:param source:
|
||||
:return:
|
||||
"""
|
||||
return Certificate.query.filter(Certificate.sources.any(label=source_label))
|
||||
return Certificate.query.filter(Certificate.sources.any(id=source.id))\
|
||||
.filter(not_(Certificate.endpoints.any())).all()
|
||||
|
||||
|
||||
def get_all_pending_reissue():
|
||||
"""
|
||||
Retrieves all certificates that need to be rotated.
|
||||
|
||||
Must be X days from expiration, uses the certificates rotation
|
||||
policy to determine how many days from expiration the certificate must be
|
||||
for rotation to be pending.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return Certificate.query.filter(Certificate.rotation == True)\
|
||||
.filter(not_(Certificate.replaced.any()))\
|
||||
.filter(Certificate.in_rotation_window == True).all() # noqa
|
||||
|
||||
|
||||
def find_duplicates(cert):
|
||||
@ -86,7 +123,10 @@ def find_duplicates(cert):
|
||||
:param cert:
|
||||
:return:
|
||||
"""
|
||||
return Certificate.query.filter_by(body=cert['body'].strip(), chain=cert['chain'].strip()).all()
|
||||
if cert['chain']:
|
||||
return Certificate.query.filter_by(body=cert['body'].strip(), chain=cert['chain'].strip()).all()
|
||||
else:
|
||||
return Certificate.query.filter_by(body=cert['body'].strip(), chain=None).all()
|
||||
|
||||
|
||||
def export(cert, export_plugin):
|
||||
@ -102,26 +142,16 @@ def export(cert, export_plugin):
|
||||
return plugin.export(cert.body, cert.chain, cert.private_key, export_plugin['pluginOptions'])
|
||||
|
||||
|
||||
def update(cert_id, owner, description, notify, destinations, notifications, replaces, roles):
|
||||
def update(cert_id, **kwargs):
|
||||
"""
|
||||
Updates a certificate
|
||||
:param cert_id:
|
||||
:param owner:
|
||||
:param description:
|
||||
:param notify:
|
||||
:param destinations:
|
||||
:param notifications:
|
||||
:param replaces:
|
||||
:return:
|
||||
"""
|
||||
cert = get(cert_id)
|
||||
cert.notify = notify
|
||||
cert.description = description
|
||||
cert.destinations = destinations
|
||||
cert.notifications = notifications
|
||||
cert.roles = roles
|
||||
cert.replaces = replaces
|
||||
cert.owner = owner
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(cert, key, value)
|
||||
|
||||
return database.update(cert)
|
||||
|
||||
@ -129,12 +159,18 @@ def update(cert_id, owner, description, notify, destinations, notifications, rep
|
||||
def create_certificate_roles(**kwargs):
|
||||
# create an role for the owner and assign it
|
||||
owner_role = role_service.get_by_name(kwargs['owner'])
|
||||
|
||||
if not owner_role:
|
||||
owner_role = role_service.create(
|
||||
kwargs['owner'],
|
||||
description="Auto generated role based on owner: {0}".format(kwargs['owner'])
|
||||
)
|
||||
|
||||
# ensure that the authority's owner is also associated with the certificate
|
||||
if kwargs.get('authority'):
|
||||
authority_owner_role = role_service.get_by_name(kwargs['authority'].owner)
|
||||
return [owner_role, authority_owner_role]
|
||||
|
||||
return [owner_role]
|
||||
|
||||
|
||||
@ -151,12 +187,14 @@ def mint(**kwargs):
|
||||
# allow the CSR to be specified by the user
|
||||
if not kwargs.get('csr'):
|
||||
csr, private_key = create_csr(**kwargs)
|
||||
csr_created.send(authority=authority, csr=csr)
|
||||
else:
|
||||
csr = str(kwargs.get('csr'))
|
||||
private_key = None
|
||||
csr_imported.send(authority=authority, csr=csr)
|
||||
|
||||
cert_body, cert_chain = issuer.create_certificate(csr, kwargs)
|
||||
return cert_body, private_key, cert_chain,
|
||||
cert_body, cert_chain, external_id = issuer.create_certificate(csr, kwargs)
|
||||
return cert_body, private_key, cert_chain, external_id, csr
|
||||
|
||||
|
||||
def import_certificate(**kwargs):
|
||||
@ -198,23 +236,23 @@ def upload(**kwargs):
|
||||
|
||||
cert = database.create(cert)
|
||||
|
||||
try:
|
||||
g.user.certificates.append(cert)
|
||||
except AttributeError:
|
||||
current_app.logger.debug("No user to associate uploaded certificate to.")
|
||||
kwargs['creator'].certificates.append(cert)
|
||||
|
||||
return database.update(cert)
|
||||
cert = database.update(cert)
|
||||
certificate_imported.send(certificate=cert, authority=cert.authority)
|
||||
return cert
|
||||
|
||||
|
||||
def create(**kwargs):
|
||||
"""
|
||||
Creates a new certificate.
|
||||
"""
|
||||
kwargs['creator'] = g.user.email
|
||||
cert_body, private_key, cert_chain = mint(**kwargs)
|
||||
cert_body, private_key, cert_chain, external_id, csr = mint(**kwargs)
|
||||
kwargs['body'] = cert_body
|
||||
kwargs['private_key'] = private_key
|
||||
kwargs['chain'] = cert_chain
|
||||
kwargs['external_id'] = external_id
|
||||
kwargs['csr'] = csr
|
||||
|
||||
roles = create_certificate_roles(**kwargs)
|
||||
|
||||
@ -223,13 +261,20 @@ def create(**kwargs):
|
||||
else:
|
||||
kwargs['roles'] = roles
|
||||
|
||||
cert = Certificate(**kwargs)
|
||||
if cert_body:
|
||||
cert = Certificate(**kwargs)
|
||||
kwargs['creator'].certificates.append(cert)
|
||||
else:
|
||||
cert = PendingCertificate(**kwargs)
|
||||
kwargs['creator'].pending_certificates.append(cert)
|
||||
|
||||
g.user.certificates.append(cert)
|
||||
cert.authority = kwargs['authority']
|
||||
|
||||
database.commit()
|
||||
|
||||
metrics.send('certificate_issued', 'counter', 1, metric_tags=dict(owner=cert.owner, issuer=cert.issuer))
|
||||
if isinstance(cert, Certificate):
|
||||
certificate_issued.send(certificate=cert, authority=cert.authority)
|
||||
metrics.send('certificate_issued', 'counter', 1, metric_tags=dict(owner=cert.owner, issuer=cert.issuer))
|
||||
return cert
|
||||
|
||||
|
||||
@ -253,40 +298,55 @@ def render(args):
|
||||
|
||||
if filt:
|
||||
terms = filt.split(';')
|
||||
term = '%{0}%'.format(terms[1])
|
||||
# Exact matches for quotes. Only applies to name, issuer, and cn
|
||||
if terms[1].startswith('"') and terms[1].endswith('"'):
|
||||
term = terms[1][1:-1]
|
||||
|
||||
if 'issuer' in terms:
|
||||
# we can't rely on issuer being correct in the cert directly so we combine queries
|
||||
sub_query = database.session_query(Authority.id)\
|
||||
.filter(Authority.name.ilike('%{0}%'.format(terms[1])))\
|
||||
.filter(Authority.name.ilike(term))\
|
||||
.subquery()
|
||||
|
||||
query = query.filter(
|
||||
or_(
|
||||
Certificate.issuer.ilike('%{0}%'.format(terms[1])),
|
||||
Certificate.issuer.ilike(term),
|
||||
Certificate.authority_id.in_(sub_query)
|
||||
)
|
||||
)
|
||||
return database.sort_and_page(query, Certificate, args)
|
||||
|
||||
elif 'destination' in terms:
|
||||
query = query.filter(Certificate.destinations.any(Destination.id == terms[1]))
|
||||
elif 'notify' in filt:
|
||||
query = query.filter(Certificate.notify == truthiness(terms[1]))
|
||||
elif 'active' in filt:
|
||||
query = query.filter(Certificate.active == terms[1])
|
||||
query = query.filter(Certificate.active == truthiness(terms[1]))
|
||||
elif 'cn' in terms:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Certificate.cn.ilike('%{0}%'.format(terms[1])),
|
||||
Certificate.domains.any(Domain.name.ilike('%{0}%'.format(terms[1])))
|
||||
Certificate.cn.ilike(term),
|
||||
Certificate.domains.any(Domain.name.ilike(term))
|
||||
)
|
||||
)
|
||||
elif 'id' in terms:
|
||||
query = query.filter(Certificate.id == cast(terms[1], Integer))
|
||||
elif 'name' in terms:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Certificate.name.ilike(term),
|
||||
Certificate.domains.any(Domain.name.ilike(term)),
|
||||
Certificate.cn.ilike(term),
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = database.filter(query, Certificate, terms)
|
||||
|
||||
if show:
|
||||
sub_query = database.session_query(Role.name).filter(Role.user_id == g.user.id).subquery()
|
||||
sub_query = database.session_query(Role.name).filter(Role.user_id == args['user'].id).subquery()
|
||||
query = query.filter(
|
||||
or_(
|
||||
Certificate.user_id == g.user.id,
|
||||
Certificate.user_id == args['user'].id,
|
||||
Certificate.owner.in_(sub_query)
|
||||
)
|
||||
)
|
||||
@ -312,83 +372,47 @@ def create_csr(**csr_config):
|
||||
|
||||
:param csr_config:
|
||||
"""
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
backend=default_backend()
|
||||
)
|
||||
private_key = generate_private_key(csr_config.get('key_type'))
|
||||
|
||||
# TODO When we figure out a better way to validate these options they should be parsed as str
|
||||
builder = x509.CertificateSigningRequestBuilder()
|
||||
builder = builder.subject_name(x509.Name([
|
||||
x509.NameAttribute(x509.OID_COMMON_NAME, csr_config['common_name']),
|
||||
x509.NameAttribute(x509.OID_ORGANIZATION_NAME, csr_config['organization']),
|
||||
x509.NameAttribute(x509.OID_ORGANIZATIONAL_UNIT_NAME, csr_config['organizational_unit']),
|
||||
x509.NameAttribute(x509.OID_COUNTRY_NAME, csr_config['country']),
|
||||
x509.NameAttribute(x509.OID_STATE_OR_PROVINCE_NAME, csr_config['state']),
|
||||
x509.NameAttribute(x509.OID_LOCALITY_NAME, csr_config['location']),
|
||||
x509.NameAttribute(x509.OID_EMAIL_ADDRESS, csr_config['owner'])
|
||||
]))
|
||||
name_list = [x509.NameAttribute(x509.OID_COMMON_NAME, csr_config['common_name'])]
|
||||
if current_app.config.get('LEMUR_OWNER_EMAIL_IN_SUBJECT', True):
|
||||
name_list.append(x509.NameAttribute(x509.OID_EMAIL_ADDRESS, csr_config['owner']))
|
||||
if 'organization' in csr_config and csr_config['organization'].strip():
|
||||
name_list.append(x509.NameAttribute(x509.OID_ORGANIZATION_NAME, csr_config['organization']))
|
||||
if 'organizational_unit' in csr_config and csr_config['organizational_unit'].strip():
|
||||
name_list.append(x509.NameAttribute(x509.OID_ORGANIZATIONAL_UNIT_NAME, csr_config['organizational_unit']))
|
||||
if 'country' in csr_config and csr_config['country'].strip():
|
||||
name_list.append(x509.NameAttribute(x509.OID_COUNTRY_NAME, csr_config['country']))
|
||||
if 'state' in csr_config and csr_config['state'].strip():
|
||||
name_list.append(x509.NameAttribute(x509.OID_STATE_OR_PROVINCE_NAME, csr_config['state']))
|
||||
if 'location' in csr_config and csr_config['location'].strip():
|
||||
name_list.append(x509.NameAttribute(x509.OID_LOCALITY_NAME, csr_config['location']))
|
||||
builder = builder.subject_name(x509.Name(name_list))
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.BasicConstraints(ca=False, path_length=None), critical=True,
|
||||
)
|
||||
extensions = csr_config.get('extensions', {})
|
||||
critical_extensions = ['basic_constraints', 'sub_alt_names', 'key_usage']
|
||||
noncritical_extensions = ['extended_key_usage']
|
||||
for k, v in extensions.items():
|
||||
if v:
|
||||
if k in critical_extensions:
|
||||
current_app.logger.debug('Adding Critical Extension: {0} {1}'.format(k, v))
|
||||
if k == 'sub_alt_names':
|
||||
if v['names']:
|
||||
builder = builder.add_extension(v['names'], critical=True)
|
||||
else:
|
||||
builder = builder.add_extension(v, critical=True)
|
||||
|
||||
if csr_config.get('extensions'):
|
||||
for k, v in csr_config.get('extensions', {}).items():
|
||||
if k == 'sub_alt_names':
|
||||
# map types to their x509 objects
|
||||
general_names = []
|
||||
for name in v['names']:
|
||||
if name['name_type'] == 'DNSName':
|
||||
general_names.append(x509.DNSName(name['value']))
|
||||
if k in noncritical_extensions:
|
||||
current_app.logger.debug('Adding Extension: {0} {1}'.format(k, v))
|
||||
builder = builder.add_extension(v, critical=False)
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName(general_names), critical=True
|
||||
)
|
||||
|
||||
# TODO support more CSR options, none of the authority plugins currently support these options
|
||||
# builder.add_extension(
|
||||
# x509.KeyUsage(
|
||||
# digital_signature=digital_signature,
|
||||
# content_commitment=content_commitment,
|
||||
# key_encipherment=key_enipherment,
|
||||
# data_encipherment=data_encipherment,
|
||||
# key_agreement=key_agreement,
|
||||
# key_cert_sign=key_cert_sign,
|
||||
# crl_sign=crl_sign,
|
||||
# encipher_only=enchipher_only,
|
||||
# decipher_only=decipher_only
|
||||
# ), critical=True
|
||||
# )
|
||||
#
|
||||
# # we must maintain our own list of OIDs here
|
||||
# builder.add_extension(
|
||||
# x509.ExtendedKeyUsage(
|
||||
# server_authentication=server_authentication,
|
||||
# email=
|
||||
# )
|
||||
# )
|
||||
#
|
||||
# builder.add_extension(
|
||||
# x509.AuthorityInformationAccess()
|
||||
# )
|
||||
#
|
||||
# builder.add_extension(
|
||||
# x509.AuthorityKeyIdentifier()
|
||||
# )
|
||||
#
|
||||
# builder.add_extension(
|
||||
# x509.SubjectKeyIdentifier()
|
||||
# )
|
||||
#
|
||||
# builder.add_extension(
|
||||
# x509.CRLDistributionPoints()
|
||||
# )
|
||||
#
|
||||
# builder.add_extension(
|
||||
# x509.ObjectIdentifier(oid)
|
||||
# )
|
||||
ski = extensions.get('subject_key_identifier', {})
|
||||
if ski.get('include_ski', False):
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
|
||||
critical=False
|
||||
)
|
||||
|
||||
request = builder.sign(
|
||||
private_key, hashes.SHA256(), default_backend()
|
||||
@ -406,7 +430,7 @@ def create_csr(**csr_config):
|
||||
|
||||
csr = request.public_bytes(
|
||||
encoding=serialization.Encoding.PEM
|
||||
)
|
||||
).decode('utf-8')
|
||||
|
||||
return csr, private_key
|
||||
|
||||
@ -470,13 +494,12 @@ def calculate_reissue_range(start, end):
|
||||
"""
|
||||
span = end - start
|
||||
|
||||
new_start = arrow.utcnow().date()
|
||||
new_start = arrow.utcnow()
|
||||
new_end = new_start + span
|
||||
|
||||
return new_start, new_end
|
||||
return new_start, arrow.get(new_end)
|
||||
|
||||
|
||||
# TODO pull the OU, O, CN, etc + other extensions.
|
||||
def get_certificate_primitives(certificate):
|
||||
"""
|
||||
Retrieve key primitive from a certificate such that the certificate
|
||||
@ -486,22 +509,42 @@ def get_certificate_primitives(certificate):
|
||||
certificate via `create`.
|
||||
"""
|
||||
start, end = calculate_reissue_range(certificate.not_before, certificate.not_after)
|
||||
names = [{'name_type': 'DNSName', 'value': x.name} for x in certificate.domains]
|
||||
data = CertificateInputSchema().load(CertificateOutputSchema().dump(certificate).data).data
|
||||
|
||||
extensions = {
|
||||
'sub_alt_names': {
|
||||
'names': names
|
||||
}
|
||||
}
|
||||
# we can't quite tell if we are using a custom name, as this is an automated process (typically)
|
||||
# we will rely on the Lemur generated name
|
||||
data.pop('name', None)
|
||||
|
||||
return dict(
|
||||
authority=certificate.authority,
|
||||
common_name=certificate.cn,
|
||||
description=certificate.description,
|
||||
validity_start=start,
|
||||
validity_end=end,
|
||||
destinations=certificate.destinations,
|
||||
roles=certificate.roles,
|
||||
extensions=extensions,
|
||||
owner=certificate.owner
|
||||
)
|
||||
# TODO this can be removed once we migrate away from cn
|
||||
data['cn'] = data['common_name']
|
||||
|
||||
# needed until we move off not_*
|
||||
data['not_before'] = start
|
||||
data['not_after'] = end
|
||||
data['validity_start'] = start
|
||||
data['validity_end'] = end
|
||||
return data
|
||||
|
||||
|
||||
def reissue_certificate(certificate, replace=None, user=None):
|
||||
"""
|
||||
Reissue certificate with the same properties of the given certificate.
|
||||
:param certificate:
|
||||
:param replace:
|
||||
:param user:
|
||||
:return:
|
||||
"""
|
||||
primitives = get_certificate_primitives(certificate)
|
||||
|
||||
if not user:
|
||||
primitives['creator'] = certificate.user
|
||||
|
||||
else:
|
||||
primitives['creator'] = user
|
||||
|
||||
if replace:
|
||||
primitives['replaces'] = [certificate]
|
||||
|
||||
new_cert = create(**primitives)
|
||||
|
||||
return new_cert
|
||||
|
@ -1,25 +1,25 @@
|
||||
"""
|
||||
.. module: lemur.certificates.verify
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import requests
|
||||
import subprocess
|
||||
from OpenSSL import crypto
|
||||
from requests.exceptions import ConnectionError, InvalidSchema
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
from flask import current_app
|
||||
from lemur.utils import mktempfile
|
||||
from lemur.common.utils import parse_certificate
|
||||
|
||||
|
||||
def ocsp_verify(cert_path, issuer_chain_path):
|
||||
"""
|
||||
Attempts to verify a certificate via OCSP. OCSP is a more modern version
|
||||
of CRL in that it will query the OCSP URI in order to determine if the
|
||||
certificate as been revoked
|
||||
certificate has been revoked
|
||||
|
||||
:param cert_path:
|
||||
:param issuer_chain_path:
|
||||
@ -33,13 +33,16 @@ def ocsp_verify(cert_path, issuer_chain_path):
|
||||
'-cert', cert_path, "-url", url.strip()], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
message, err = p2.communicate()
|
||||
if 'error' in message or 'Error' in message:
|
||||
|
||||
p_message = message.decode('utf-8')
|
||||
|
||||
if 'error' in p_message or 'Error' in p_message:
|
||||
raise Exception("Got error when parsing OCSP url")
|
||||
|
||||
elif 'revoked' in message:
|
||||
elif 'revoked' in p_message:
|
||||
return
|
||||
|
||||
elif 'good' not in message:
|
||||
elif 'good' not in p_message:
|
||||
raise Exception("Did not receive a valid response")
|
||||
|
||||
return True
|
||||
@ -54,17 +57,39 @@ def crl_verify(cert_path):
|
||||
:raise Exception: If certificate does not have CRL
|
||||
"""
|
||||
with open(cert_path, 'rt') as c:
|
||||
cert = x509.load_pem_x509_certificate(c.read(), default_backend())
|
||||
cert = parse_certificate(c.read())
|
||||
|
||||
distribution_points = cert.extensions.get_extension_for_oid(x509.OID_CRL_DISTRIBUTION_POINTS).value
|
||||
|
||||
for p in distribution_points:
|
||||
point = p.full_name[0].value
|
||||
response = requests.get(point)
|
||||
crl = crypto.load_crl(crypto.FILETYPE_ASN1, response.content) # TODO this should be switched to cryptography when support exists
|
||||
revoked = crl.get_revoked()
|
||||
for r in revoked:
|
||||
if cert.serial == r.get_serial():
|
||||
|
||||
try:
|
||||
response = requests.get(point)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception("Unable to retrieve CRL: {0}".format(point))
|
||||
except InvalidSchema:
|
||||
# Unhandled URI scheme (like ldap://); skip this distribution point.
|
||||
continue
|
||||
except ConnectionError:
|
||||
raise Exception("Unable to retrieve CRL: {0}".format(point))
|
||||
|
||||
crl = x509.load_der_x509_crl(response.content, backend=default_backend())
|
||||
|
||||
for r in crl:
|
||||
if cert.serial == r.serial_number:
|
||||
try:
|
||||
reason = r.extensions.get_extension_for_class(x509.CRLReason).value
|
||||
# Handle "removeFromCRL" revoke reason as unrevoked; continue with the next distribution point.
|
||||
# Per RFC 5280 section 6.3.3 (k): https://tools.ietf.org/html/rfc5280#section-6.3.3
|
||||
if reason == x509.ReasonFlags.remove_from_crl:
|
||||
break
|
||||
except x509.ExtensionNotFound:
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@ -81,13 +106,10 @@ def verify(cert_path, issuer_chain_path):
|
||||
try:
|
||||
return ocsp_verify(cert_path, issuer_chain_path)
|
||||
except Exception as e:
|
||||
current_app.logger.debug("Could not use OCSP: {0}".format(e))
|
||||
try:
|
||||
return crl_verify(cert_path)
|
||||
except Exception as e:
|
||||
current_app.logger.debug("Could not use CRL: {0}".format(e))
|
||||
raise Exception("Failed to verify")
|
||||
raise Exception("Failed to verify")
|
||||
|
||||
|
||||
def verify_string(cert_string, issuer_string):
|
||||
|
@ -1,15 +1,15 @@
|
||||
"""
|
||||
.. module: lemur.certificates.views
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import base64
|
||||
from builtins import str
|
||||
|
||||
from flask import Blueprint, make_response, jsonify
|
||||
from flask.ext.restful import reqparse, Api
|
||||
from flask import Blueprint, make_response, jsonify, g
|
||||
from flask_restful import reqparse, Api
|
||||
|
||||
from lemur.common.schema import validate_schema
|
||||
from lemur.common.utils import paginated_parser
|
||||
@ -18,10 +18,19 @@ from lemur.auth.service import AuthenticatedResource
|
||||
from lemur.auth.permissions import AuthorityPermission, CertificatePermission
|
||||
|
||||
from lemur.certificates import service
|
||||
from lemur.certificates.schemas import certificate_input_schema, certificate_output_schema, \
|
||||
certificate_upload_input_schema, certificates_output_schema, certificate_export_input_schema, certificate_edit_input_schema
|
||||
from lemur.certificates.models import Certificate
|
||||
from lemur.plugins.base import plugins
|
||||
from lemur.certificates.schemas import (
|
||||
certificate_input_schema,
|
||||
certificate_output_schema,
|
||||
certificate_upload_input_schema,
|
||||
certificates_output_schema,
|
||||
certificate_export_input_schema,
|
||||
certificate_edit_input_schema
|
||||
)
|
||||
|
||||
from lemur.roles import service as role_service
|
||||
from lemur.logs import service as log_service
|
||||
|
||||
|
||||
mod = Blueprint('certificates', __name__)
|
||||
@ -83,7 +92,7 @@ class CertificatesList(AuthenticatedResource):
|
||||
"deleted": null,
|
||||
"notifications": [{
|
||||
"id": 1
|
||||
}]
|
||||
}],
|
||||
"signingAlgorithm": "sha256",
|
||||
"user": {
|
||||
"username": "jane",
|
||||
@ -98,6 +107,7 @@ class CertificatesList(AuthenticatedResource):
|
||||
"name": "*.test.example.net"
|
||||
}],
|
||||
"replaces": [],
|
||||
"replaced": [],
|
||||
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
|
||||
"roles": [{
|
||||
"id": 464,
|
||||
@ -110,7 +120,7 @@ class CertificatesList(AuthenticatedResource):
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: acs or desc
|
||||
:query sortDir: asc or desc
|
||||
:query page: int. default is 1
|
||||
:query filter: key value pair format is k;v
|
||||
:query count: count number. default is 10
|
||||
@ -129,6 +139,7 @@ class CertificatesList(AuthenticatedResource):
|
||||
parser.add_argument('show', type=str, location='args')
|
||||
|
||||
args = parser.parse_args()
|
||||
args['user'] = g.user
|
||||
return service.render(args)
|
||||
|
||||
@validate_schema(certificate_input_schema, certificate_output_schema)
|
||||
@ -166,7 +177,7 @@ class CertificatesList(AuthenticatedResource):
|
||||
},
|
||||
"replacements": [{
|
||||
"id": 1
|
||||
},
|
||||
}],
|
||||
"notify": true,
|
||||
"validityEnd": "2026-01-01T08:00:00.000Z",
|
||||
"authority": {
|
||||
@ -212,7 +223,7 @@ class CertificatesList(AuthenticatedResource):
|
||||
"deleted": null,
|
||||
"notifications": [{
|
||||
"id": 1
|
||||
}]
|
||||
}],
|
||||
"signingAlgorithm": "sha256",
|
||||
"user": {
|
||||
"username": "jane",
|
||||
@ -229,6 +240,8 @@ class CertificatesList(AuthenticatedResource):
|
||||
"replaces": [{
|
||||
"id": 1
|
||||
}],
|
||||
"rotation": true,
|
||||
"rotationPolicy": {"name": "default"},
|
||||
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
|
||||
"roles": [{
|
||||
"id": 464,
|
||||
@ -238,18 +251,6 @@ class CertificatesList(AuthenticatedResource):
|
||||
"san": null
|
||||
}
|
||||
|
||||
|
||||
:arg extensions: extensions to be used in the certificate
|
||||
:arg description: description for new certificate
|
||||
:arg owner: owner email
|
||||
:arg validityStart: when the certificate should start being valid
|
||||
:arg validityEnd: when the certificate should expire
|
||||
:arg authority: authority that should issue the certificate
|
||||
:arg country: country for the CSR
|
||||
:arg state: state for the CSR
|
||||
:arg location: location for the CSR
|
||||
:arg organization: organization for CSR
|
||||
:arg commonName: certificate common name
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
@ -265,9 +266,14 @@ class CertificatesList(AuthenticatedResource):
|
||||
authority_permission = AuthorityPermission(data['authority'].id, roles)
|
||||
|
||||
if authority_permission.can():
|
||||
return service.create(**data)
|
||||
data['creator'] = g.user
|
||||
cert = service.create(**data)
|
||||
if isinstance(cert, Certificate):
|
||||
# only log if created, not pending
|
||||
log_service.create(g.user, 'create_cert', certificate=cert)
|
||||
return cert
|
||||
|
||||
return dict(message="You are not authorized to use {0}".format(data['authority'].name)), 403
|
||||
return dict(message="You are not authorized to use the authority: {0}".format(data['authority'].name)), 403
|
||||
|
||||
|
||||
class CertificatesUpload(AuthenticatedResource):
|
||||
@ -293,13 +299,15 @@ class CertificatesUpload(AuthenticatedResource):
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"owner": "joe@exmaple.com",
|
||||
"publicCert": "---Begin Public...",
|
||||
"intermediateCert": "---Begin Public...",
|
||||
"privateKey": "---Begin Private..."
|
||||
"owner": "joe@example.com",
|
||||
"body": "-----BEGIN CERTIFICATE-----...",
|
||||
"chain": "-----BEGIN CERTIFICATE-----...",
|
||||
"privateKey": "-----BEGIN RSA PRIVATE KEY-----..."
|
||||
"destinations": [],
|
||||
"notifications": [],
|
||||
"replacements": [],
|
||||
"roles": [],
|
||||
"notify": true,
|
||||
"name": "cert1"
|
||||
}
|
||||
|
||||
@ -335,7 +343,7 @@ class CertificatesUpload(AuthenticatedResource):
|
||||
"deleted": null,
|
||||
"notifications": [{
|
||||
"id": 1
|
||||
}]
|
||||
}],
|
||||
"signingAlgorithm": "sha256",
|
||||
"user": {
|
||||
"username": "jane",
|
||||
@ -350,6 +358,8 @@ class CertificatesUpload(AuthenticatedResource):
|
||||
"name": "*.test.example.net"
|
||||
}],
|
||||
"replaces": [],
|
||||
"rotation": true,
|
||||
"rotationPolicy": {"name": "default"},
|
||||
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
|
||||
"roles": [{
|
||||
"id": 464,
|
||||
@ -359,16 +369,12 @@ class CertificatesUpload(AuthenticatedResource):
|
||||
"san": null
|
||||
}
|
||||
|
||||
:arg owner: owner email for certificate
|
||||
:arg publicCert: valid PEM public key for certificate
|
||||
:arg intermediateCert valid PEM intermediate key for certificate
|
||||
:arg privateKey: valid PEM private key for certificate
|
||||
:arg destinations: list of aws destinations to upload the certificate to
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 403: unauthenticated
|
||||
:statuscode 200: no error
|
||||
|
||||
"""
|
||||
data['creator'] = g.user
|
||||
if data.get('destinations'):
|
||||
if data.get('private_key'):
|
||||
return service.upload(**data)
|
||||
@ -423,7 +429,7 @@ class CertificatePrivateKey(AuthenticatedResource):
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"key": "----Begin ...",
|
||||
"key": "-----BEGIN ..."
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
@ -434,16 +440,19 @@ class CertificatePrivateKey(AuthenticatedResource):
|
||||
if not cert:
|
||||
return dict(message="Cannot find specified certificate"), 404
|
||||
|
||||
owner_role = role_service.get_by_name(cert.owner)
|
||||
permission = CertificatePermission(cert.id, owner_role, [x.name for x in cert.roles])
|
||||
# allow creators
|
||||
if g.current_user != cert.user:
|
||||
owner_role = role_service.get_by_name(cert.owner)
|
||||
permission = CertificatePermission(owner_role, [x.name for x in cert.roles])
|
||||
|
||||
if permission.can():
|
||||
response = make_response(jsonify(key=cert.private_key), 200)
|
||||
response.headers['cache-control'] = 'private, max-age=0, no-cache, no-store'
|
||||
response.headers['pragma'] = 'no-cache'
|
||||
return response
|
||||
if not permission.can():
|
||||
return dict(message='You are not authorized to view this key'), 403
|
||||
|
||||
return dict(message='You are not authorized to view this key'), 403
|
||||
log_service.create(g.current_user, 'key_view', certificate=cert)
|
||||
response = make_response(jsonify(key=cert.private_key), 200)
|
||||
response.headers['cache-control'] = 'private, max-age=0, no-cache, no-store'
|
||||
response.headers['pragma'] = 'no-cache'
|
||||
return response
|
||||
|
||||
|
||||
class Certificates(AuthenticatedResource):
|
||||
@ -498,7 +507,7 @@ class Certificates(AuthenticatedResource):
|
||||
"deleted": null,
|
||||
"notifications": [{
|
||||
"id": 1
|
||||
}]
|
||||
}],
|
||||
"signingAlgorithm": "sha256",
|
||||
"user": {
|
||||
"username": "jane",
|
||||
@ -512,7 +521,10 @@ class Certificates(AuthenticatedResource):
|
||||
"id": 1090,
|
||||
"name": "*.test.example.net"
|
||||
}],
|
||||
"rotation": true,
|
||||
"rotationPolicy": {"name": "default"},
|
||||
"replaces": [],
|
||||
"replaced": [],
|
||||
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
|
||||
"roles": [{
|
||||
"id": 464,
|
||||
@ -605,6 +617,8 @@ class Certificates(AuthenticatedResource):
|
||||
"description": "This is a google group based role created by Lemur",
|
||||
"name": "joe@example.com"
|
||||
}],
|
||||
"rotation": true,
|
||||
"rotationPolicy": {"name": "default"},
|
||||
"san": null
|
||||
}
|
||||
|
||||
@ -615,27 +629,29 @@ class Certificates(AuthenticatedResource):
|
||||
"""
|
||||
cert = service.get(certificate_id)
|
||||
|
||||
owner_role = role_service.get_by_name(cert.owner)
|
||||
permission = CertificatePermission(cert.id, owner_role, [x.name for x in cert.roles])
|
||||
if not cert:
|
||||
return dict(message="Cannot find specified certificate"), 404
|
||||
|
||||
if permission.can():
|
||||
for destination in data['destinations']:
|
||||
if destination.plugin.requires_key:
|
||||
if not cert.private_key:
|
||||
return dict('Unable to add destination: {0}. Certificate does not have required private key.'.format(destination.label))
|
||||
# allow creators
|
||||
if g.current_user != cert.user:
|
||||
owner_role = role_service.get_by_name(cert.owner)
|
||||
permission = CertificatePermission(owner_role, [x.name for x in cert.roles])
|
||||
|
||||
return service.update(
|
||||
certificate_id,
|
||||
data['owner'],
|
||||
data['description'],
|
||||
data['notify'],
|
||||
data['destinations'],
|
||||
data['notifications'],
|
||||
data['replacements'],
|
||||
data['roles']
|
||||
)
|
||||
if not permission.can():
|
||||
return dict(message='You are not authorized to update this certificate'), 403
|
||||
|
||||
return dict(message='You are not authorized to update this certificate'), 403
|
||||
for destination in data['destinations']:
|
||||
if destination.plugin.requires_key:
|
||||
if not cert.private_key:
|
||||
return dict(
|
||||
message='Unable to add destination: {0}. Certificate does not have required private key.'.format(
|
||||
destination.label
|
||||
)
|
||||
), 400
|
||||
|
||||
cert = service.update(certificate_id, **data)
|
||||
log_service.create(g.current_user, 'update_cert', certificate=cert)
|
||||
return cert
|
||||
|
||||
|
||||
class NotificationCertificatesList(AuthenticatedResource):
|
||||
@ -693,7 +709,7 @@ class NotificationCertificatesList(AuthenticatedResource):
|
||||
"deleted": null,
|
||||
"notifications": [{
|
||||
"id": 1
|
||||
}]
|
||||
}],
|
||||
"signingAlgorithm": "sha256",
|
||||
"user": {
|
||||
"username": "jane",
|
||||
@ -708,6 +724,9 @@ class NotificationCertificatesList(AuthenticatedResource):
|
||||
"name": "*.test.example.net"
|
||||
}],
|
||||
"replaces": [],
|
||||
"replaced": [],
|
||||
"rotation": true,
|
||||
"rotationPolicy": {"name": "default"},
|
||||
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
|
||||
"roles": [{
|
||||
"id": 464,
|
||||
@ -720,7 +739,7 @@ class NotificationCertificatesList(AuthenticatedResource):
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: acs or desc
|
||||
:query sortDir: asc or desc
|
||||
:query page: int default is 1
|
||||
:query filter: key value pair format is k;v
|
||||
:query count: count number default is 10
|
||||
@ -740,6 +759,7 @@ class NotificationCertificatesList(AuthenticatedResource):
|
||||
|
||||
args = parser.parse_args()
|
||||
args['notification_id'] = notification_id
|
||||
args['user'] = g.current_user
|
||||
return service.render(args)
|
||||
|
||||
|
||||
@ -811,6 +831,9 @@ class CertificatesReplacementsList(AuthenticatedResource):
|
||||
"name": "*.test.example.net"
|
||||
}],
|
||||
"replaces": [],
|
||||
"replaced": [],
|
||||
"rotation": true,
|
||||
"rotationPolicy": {"name": "default"},
|
||||
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
|
||||
"roles": [{
|
||||
"id": 464,
|
||||
@ -904,45 +927,104 @@ class CertificateExport(AuthenticatedResource):
|
||||
"""
|
||||
cert = service.get(certificate_id)
|
||||
|
||||
owner_role = role_service.get_by_name(cert.owner)
|
||||
permission = CertificatePermission(cert.id, owner_role, [x.name for x in cert.roles])
|
||||
if not cert:
|
||||
return dict(message="Cannot find specified certificate"), 404
|
||||
|
||||
options = data['plugin']['plugin_options']
|
||||
plugin = data['plugin']['plugin_object']
|
||||
|
||||
if plugin.requires_key:
|
||||
if cert.private_key:
|
||||
if permission.can():
|
||||
extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, options)
|
||||
else:
|
||||
return dict(message='You are not authorized to export this certificate.'), 403
|
||||
if not cert.private_key:
|
||||
return dict(
|
||||
message='Unable to export certificate, plugin: {0} requires a private key but no key was found.'.format(
|
||||
plugin.slug)), 400
|
||||
|
||||
else:
|
||||
return dict(message='Unable to export certificate, plugin: {0} requires a private key but no key was found.'.format(plugin.slug))
|
||||
else:
|
||||
extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, options)
|
||||
# allow creators
|
||||
if g.current_user != cert.user:
|
||||
owner_role = role_service.get_by_name(cert.owner)
|
||||
permission = CertificatePermission(owner_role, [x.name for x in cert.roles])
|
||||
|
||||
if not permission.can():
|
||||
return dict(message='You are not authorized to export this certificate.'), 403
|
||||
|
||||
options = data['plugin']['plugin_options']
|
||||
|
||||
log_service.create(g.current_user, 'key_view', certificate=cert)
|
||||
extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, options)
|
||||
|
||||
# we take a hit in message size when b64 encoding
|
||||
return dict(extension=extension, passphrase=passphrase, data=base64.b64encode(data).decode('utf-8'))
|
||||
|
||||
|
||||
class CertificateClone(AuthenticatedResource):
|
||||
class CertificateRevoke(AuthenticatedResource):
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(CertificateExport, self).__init__()
|
||||
super(CertificateRevoke, self).__init__()
|
||||
|
||||
@validate_schema(None, certificate_output_schema)
|
||||
def get(self, certificate_id):
|
||||
@validate_schema(None, None)
|
||||
def put(self, certificate_id, data=None):
|
||||
"""
|
||||
.. http:put:: /certificates/1/revoke
|
||||
|
||||
pass
|
||||
Revoke a certificate
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /certificates/1/revoke HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
'id': 1
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
|
||||
"""
|
||||
cert = service.get(certificate_id)
|
||||
|
||||
if not cert:
|
||||
return dict(message="Cannot find specified certificate"), 404
|
||||
|
||||
# allow creators
|
||||
if g.current_user != cert.user:
|
||||
owner_role = role_service.get_by_name(cert.owner)
|
||||
permission = CertificatePermission(owner_role, [x.name for x in cert.roles])
|
||||
|
||||
if not permission.can():
|
||||
return dict(message='You are not authorized to revoke this certificate.'), 403
|
||||
|
||||
if not cert.external_id:
|
||||
return dict(message='Cannot revoke certificate. No external id found.'), 400
|
||||
|
||||
if cert.endpoints:
|
||||
return dict(message='Cannot revoke certificate. Endpoints are deployed with the given certificate.'), 403
|
||||
|
||||
plugin = plugins.get(cert.authority.plugin_name)
|
||||
plugin.revoke_certificate(cert, data)
|
||||
log_service.create(g.current_user, 'revoke_cert', certificate=cert)
|
||||
return dict(id=cert.id)
|
||||
|
||||
|
||||
api.add_resource(CertificateRevoke, '/certificates/<int:certificate_id>/revoke', endpoint='revokeCertificate')
|
||||
api.add_resource(CertificatesList, '/certificates', endpoint='certificates')
|
||||
api.add_resource(Certificates, '/certificates/<int:certificate_id>', endpoint='certificate')
|
||||
api.add_resource(CertificatesStats, '/certificates/stats', endpoint='certificateStats')
|
||||
api.add_resource(CertificatesUpload, '/certificates/upload', endpoint='certificateUpload')
|
||||
api.add_resource(CertificatePrivateKey, '/certificates/<int:certificate_id>/key', endpoint='privateKeyCertificates')
|
||||
api.add_resource(CertificateExport, '/certificates/<int:certificate_id>/export', endpoint='exportCertificate')
|
||||
api.add_resource(CertificateClone, '/certificates/<int:certificate_id>/clone', endpoint='cloneCertificate')
|
||||
api.add_resource(NotificationCertificatesList, '/notifications/<int:notification_id>/certificates',
|
||||
endpoint='notificationCertificates')
|
||||
api.add_resource(CertificatesReplacementsList, '/certificates/<int:certificate_id>/replacements',
|
||||
|
@ -1,8 +1,26 @@
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from cryptography import x509
|
||||
from flask import current_app
|
||||
from lemur.extensions import sentry
|
||||
from lemur.constants import SAN_NAMING_TEMPLATE, DEFAULT_NAMING_TEMPLATE
|
||||
|
||||
|
||||
def text_to_slug(value):
|
||||
"""Normalize a string to a "slug" value, stripping character accents and removing non-alphanum characters."""
|
||||
|
||||
# Strip all character accents: decompose Unicode characters and then drop combining chars.
|
||||
value = ''.join(c for c in unicodedata.normalize('NFKD', value) if not unicodedata.combining(c))
|
||||
|
||||
# Replace all remaining non-alphanumeric characters with '-'. Multiple characters get collapsed into a single dash.
|
||||
# Except, keep 'xn--' used in IDNA domain names as is.
|
||||
value = re.sub(r'[^A-Za-z0-9.]+(?<!xn--)', '-', value)
|
||||
|
||||
# '-' in the beginning or end of string looks ugly.
|
||||
return value.strip('-')
|
||||
|
||||
|
||||
def certificate_name(common_name, issuer, not_before, not_after, san):
|
||||
"""
|
||||
Create a name for our certificate. A naming standard
|
||||
@ -25,21 +43,13 @@ def certificate_name(common_name, issuer, not_before, not_after, san):
|
||||
|
||||
temp = t.format(
|
||||
subject=common_name,
|
||||
issuer=issuer,
|
||||
issuer=issuer.replace(' ', ''),
|
||||
not_before=not_before.strftime('%Y%m%d'),
|
||||
not_after=not_after.strftime('%Y%m%d')
|
||||
)
|
||||
|
||||
disallowed_chars = ''.join(c for c in map(chr, range(256)) if not c.isalnum())
|
||||
disallowed_chars = disallowed_chars.replace("-", "")
|
||||
disallowed_chars = disallowed_chars.replace(".", "")
|
||||
temp = temp.replace('*', "WILDCARD")
|
||||
|
||||
for c in disallowed_chars:
|
||||
temp = temp.replace(c, "")
|
||||
|
||||
# white space is silly too
|
||||
return temp.replace(" ", "-")
|
||||
return text_to_slug(temp)
|
||||
|
||||
|
||||
def signing_algorithm(cert):
|
||||
@ -53,9 +63,88 @@ def common_name(cert):
|
||||
:param cert:
|
||||
:return: Common name or None
|
||||
"""
|
||||
return cert.subject.get_attributes_for_oid(
|
||||
x509.OID_COMMON_NAME
|
||||
)[0].value.strip()
|
||||
try:
|
||||
return cert.subject.get_attributes_for_oid(
|
||||
x509.OID_COMMON_NAME
|
||||
)[0].value.strip()
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.error("Unable to get common name! {0}".format(e))
|
||||
|
||||
|
||||
def organization(cert):
|
||||
"""
|
||||
Attempt to get the organization name from a given certificate.
|
||||
:param cert:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
return cert.subject.get_attributes_for_oid(
|
||||
x509.OID_ORGANIZATION_NAME
|
||||
)[0].value.strip()
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.error("Unable to get organization! {0}".format(e))
|
||||
|
||||
|
||||
def organizational_unit(cert):
|
||||
"""
|
||||
Attempt to get the organization unit from a given certificate.
|
||||
:param cert:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
return cert.subject.get_attributes_for_oid(
|
||||
x509.OID_ORGANIZATIONAL_UNIT_NAME
|
||||
)[0].value.strip()
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.error("Unable to get organizational unit! {0}".format(e))
|
||||
|
||||
|
||||
def country(cert):
|
||||
"""
|
||||
Attempt to get the country from a given certificate.
|
||||
:param cert:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
return cert.subject.get_attributes_for_oid(
|
||||
x509.OID_COUNTRY_NAME
|
||||
)[0].value.strip()
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.error("Unable to get country! {0}".format(e))
|
||||
|
||||
|
||||
def state(cert):
|
||||
"""
|
||||
Attempt to get the from a given certificate.
|
||||
:param cert:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
return cert.subject.get_attributes_for_oid(
|
||||
x509.OID_STATE_OR_PROVINCE_NAME
|
||||
)[0].value.strip()
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.error("Unable to get state! {0}".format(e))
|
||||
|
||||
|
||||
def location(cert):
|
||||
"""
|
||||
Attempt to get the location name from a given certificate.
|
||||
:param cert:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
return cert.subject.get_attributes_for_oid(
|
||||
x509.OID_LOCALITY_NAME
|
||||
)[0].value.strip()
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.error("Unable to get location! {0}".format(e))
|
||||
|
||||
|
||||
def domains(cert):
|
||||
@ -73,8 +162,11 @@ def domains(cert):
|
||||
entries = ext.value.get_values_for_type(x509.DNSName)
|
||||
for entry in entries:
|
||||
domains.append(entry)
|
||||
except x509.ExtensionNotFound:
|
||||
if current_app.config.get("LOG_SSL_SUBJ_ALT_NAME_ERRORS", True):
|
||||
sentry.captureException()
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Failed to get SubjectAltName: {0}".format(e))
|
||||
sentry.captureException()
|
||||
|
||||
return domains
|
||||
|
||||
@ -86,7 +178,7 @@ def serial(cert):
|
||||
:param cert:
|
||||
:return: serial number
|
||||
"""
|
||||
return cert.serial
|
||||
return cert.serial_number
|
||||
|
||||
|
||||
def san(cert):
|
||||
@ -126,24 +218,30 @@ def bitstrength(cert):
|
||||
try:
|
||||
return cert.public_key().key_size
|
||||
except AttributeError:
|
||||
sentry.captureException()
|
||||
current_app.logger.debug('Unable to get bitstrength.')
|
||||
|
||||
|
||||
def issuer(cert):
|
||||
"""
|
||||
Gets a sane issuer from a given certificate.
|
||||
Gets a sane issuer name from a given certificate.
|
||||
|
||||
:param cert:
|
||||
:return: Issuer
|
||||
"""
|
||||
delchars = ''.join(c for c in map(chr, range(256)) if not c.isalnum())
|
||||
try:
|
||||
issuer = str(cert.issuer.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)[0].value)
|
||||
# Try organization name or fall back to CN
|
||||
issuer = (cert.issuer.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)
|
||||
or cert.issuer.get_attributes_for_oid(x509.OID_COMMON_NAME))
|
||||
issuer = str(issuer[0].value)
|
||||
for c in delchars:
|
||||
issuer = issuer.replace(c, "")
|
||||
return issuer
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.error("Unable to get issuer! {0}".format(e))
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def not_before(cert):
|
||||
|
@ -1,8 +1,34 @@
|
||||
"""
|
||||
.. module: lemur.common.fields
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import arrow
|
||||
import warnings
|
||||
import ipaddress
|
||||
|
||||
from flask import current_app
|
||||
from datetime import datetime as dt
|
||||
from marshmallow.fields import Field
|
||||
|
||||
from cryptography import x509
|
||||
|
||||
from marshmallow import utils
|
||||
from marshmallow.fields import Field
|
||||
from marshmallow.exceptions import ValidationError
|
||||
|
||||
from lemur.common import validators
|
||||
|
||||
|
||||
class Hex(Field):
|
||||
"""
|
||||
A hex formatted string.
|
||||
"""
|
||||
def _serialize(self, value, attr, obj):
|
||||
if value:
|
||||
value = hex(int(value))[2:].upper()
|
||||
return value
|
||||
|
||||
|
||||
class ArrowDateTime(Field):
|
||||
@ -91,3 +117,298 @@ class ArrowDateTime(Field):
|
||||
warnings.warn('It is recommended that you install python-dateutil '
|
||||
'for improved datetime deserialization.')
|
||||
raise self.fail('invalid')
|
||||
|
||||
|
||||
class KeyUsageExtension(Field):
|
||||
"""An x509.KeyUsage ExtensionType object
|
||||
|
||||
Dict of KeyUsage names/values are deserialized into an x509.KeyUsage object
|
||||
and back.
|
||||
|
||||
:param kwargs: The same keyword arguments that :class:`Field` receives.
|
||||
|
||||
"""
|
||||
|
||||
def _serialize(self, value, attr, obj):
|
||||
return {
|
||||
'useDigitalSignature': value.digital_signature,
|
||||
'useNonRepudiation': value.content_commitment,
|
||||
'useKeyEncipherment': value.key_encipherment,
|
||||
'useDataEncipherment': value.data_encipherment,
|
||||
'useKeyAgreement': value.key_agreement,
|
||||
'useKeyCertSign': value.key_cert_sign,
|
||||
'useCRLSign': value.crl_sign,
|
||||
'useEncipherOnly': value._encipher_only,
|
||||
'useDecipherOnly': value._decipher_only
|
||||
}
|
||||
|
||||
def _deserialize(self, value, attr, data):
|
||||
keyusages = {
|
||||
'digital_signature': False,
|
||||
'content_commitment': False,
|
||||
'key_encipherment': False,
|
||||
'data_encipherment': False,
|
||||
'key_agreement': False,
|
||||
'key_cert_sign': False,
|
||||
'crl_sign': False,
|
||||
'encipher_only': False,
|
||||
'decipher_only': False
|
||||
}
|
||||
|
||||
for k, v in value.items():
|
||||
if k == 'useDigitalSignature':
|
||||
keyusages['digital_signature'] = v
|
||||
|
||||
elif k == 'useNonRepudiation':
|
||||
keyusages['content_commitment'] = v
|
||||
|
||||
elif k == 'useKeyEncipherment':
|
||||
keyusages['key_encipherment'] = v
|
||||
|
||||
elif k == 'useDataEncipherment':
|
||||
keyusages['data_encipherment'] = v
|
||||
|
||||
elif k == 'useKeyCertSign':
|
||||
keyusages['key_cert_sign'] = v
|
||||
|
||||
elif k == 'useCRLSign':
|
||||
keyusages['crl_sign'] = v
|
||||
|
||||
elif k == 'useKeyAgreement':
|
||||
keyusages['key_agreement'] = v
|
||||
|
||||
elif k == 'useEncipherOnly' and v:
|
||||
keyusages['encipher_only'] = True
|
||||
keyusages['key_agreement'] = True
|
||||
|
||||
elif k == 'useDecipherOnly' and v:
|
||||
keyusages['decipher_only'] = True
|
||||
keyusages['key_agreement'] = True
|
||||
|
||||
if keyusages['encipher_only'] and keyusages['decipher_only']:
|
||||
raise ValidationError('A certificate cannot have both Encipher Only and Decipher Only Extended Key Usages.')
|
||||
|
||||
return x509.KeyUsage(
|
||||
digital_signature=keyusages['digital_signature'],
|
||||
content_commitment=keyusages['content_commitment'],
|
||||
key_encipherment=keyusages['key_encipherment'],
|
||||
data_encipherment=keyusages['data_encipherment'],
|
||||
key_agreement=keyusages['key_agreement'],
|
||||
key_cert_sign=keyusages['key_cert_sign'],
|
||||
crl_sign=keyusages['crl_sign'],
|
||||
encipher_only=keyusages['encipher_only'],
|
||||
decipher_only=keyusages['decipher_only']
|
||||
)
|
||||
|
||||
|
||||
class ExtendedKeyUsageExtension(Field):
|
||||
"""An x509.ExtendedKeyUsage ExtensionType object
|
||||
|
||||
Dict of ExtendedKeyUsage names/values are deserialized into an x509.ExtendedKeyUsage object
|
||||
and back.
|
||||
|
||||
:param kwargs: The same keyword arguments that :class:`Field` receives.
|
||||
|
||||
"""
|
||||
|
||||
def _serialize(self, value, attr, obj):
|
||||
usages = value._usages
|
||||
usage_list = {}
|
||||
for usage in usages:
|
||||
if usage == x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH:
|
||||
usage_list['useClientAuthentication'] = True
|
||||
|
||||
elif usage == x509.oid.ExtendedKeyUsageOID.SERVER_AUTH:
|
||||
usage_list['useServerAuthentication'] = True
|
||||
|
||||
elif usage == x509.oid.ExtendedKeyUsageOID.CODE_SIGNING:
|
||||
usage_list['useCodeSigning'] = True
|
||||
|
||||
elif usage == x509.oid.ExtendedKeyUsageOID.EMAIL_PROTECTION:
|
||||
usage_list['useEmailProtection'] = True
|
||||
|
||||
elif usage == x509.oid.ExtendedKeyUsageOID.TIME_STAMPING:
|
||||
usage_list['useTimestamping'] = True
|
||||
|
||||
elif usage == x509.oid.ExtendedKeyUsageOID.OCSP_SIGNING:
|
||||
usage_list['useOCSPSigning'] = True
|
||||
|
||||
elif usage.dotted_string == '1.3.6.1.5.5.7.3.14':
|
||||
usage_list['useEapOverLAN'] = True
|
||||
|
||||
elif usage.dotted_string == '1.3.6.1.5.5.7.3.13':
|
||||
usage_list['useEapOverPPP'] = True
|
||||
|
||||
elif usage.dotted_string == '1.3.6.1.4.1.311.20.2.2':
|
||||
usage_list['useSmartCardLogon'] = True
|
||||
|
||||
else:
|
||||
current_app.logger.warning('Unable to serialize ExtendedKeyUsage with OID: {usage}'.format(usage=usage.dotted_string))
|
||||
|
||||
return usage_list
|
||||
|
||||
def _deserialize(self, value, attr, data):
|
||||
usage_oids = []
|
||||
for k, v in value.items():
|
||||
if k == 'useClientAuthentication' and v:
|
||||
usage_oids.append(x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH)
|
||||
|
||||
elif k == 'useServerAuthentication' and v:
|
||||
usage_oids.append(x509.oid.ExtendedKeyUsageOID.SERVER_AUTH)
|
||||
|
||||
elif k == 'useCodeSigning' and v:
|
||||
usage_oids.append(x509.oid.ExtendedKeyUsageOID.CODE_SIGNING)
|
||||
|
||||
elif k == 'useEmailProtection' and v:
|
||||
usage_oids.append(x509.oid.ExtendedKeyUsageOID.EMAIL_PROTECTION)
|
||||
|
||||
elif k == 'useTimestamping' and v:
|
||||
usage_oids.append(x509.oid.ExtendedKeyUsageOID.TIME_STAMPING)
|
||||
|
||||
elif k == 'useOCSPSigning' and v:
|
||||
usage_oids.append(x509.oid.ExtendedKeyUsageOID.OCSP_SIGNING)
|
||||
|
||||
elif k == 'useEapOverLAN' and v:
|
||||
usage_oids.append(x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.3.14"))
|
||||
|
||||
elif k == 'useEapOverPPP' and v:
|
||||
usage_oids.append(x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.3.13"))
|
||||
|
||||
elif k == 'useSmartCardLogon' and v:
|
||||
usage_oids.append(x509.oid.ObjectIdentifier("1.3.6.1.4.1.311.20.2.2"))
|
||||
|
||||
else:
|
||||
current_app.logger.warning('Unable to deserialize ExtendedKeyUsage with name: {key}'.format(key=k))
|
||||
|
||||
return x509.ExtendedKeyUsage(usage_oids)
|
||||
|
||||
|
||||
class BasicConstraintsExtension(Field):
|
||||
"""An x509.BasicConstraints ExtensionType object
|
||||
|
||||
Dict of CA boolean and a path_length integer names/values are deserialized into an x509.BasicConstraints object
|
||||
and back.
|
||||
|
||||
:param kwargs: The same keyword arguments that :class:`Field` receives.
|
||||
|
||||
"""
|
||||
|
||||
def _serialize(self, value, attr, obj):
|
||||
return {'ca': value.ca, 'path_length': value.path_length}
|
||||
|
||||
def _deserialize(self, value, attr, data):
|
||||
ca = value.get('ca', False)
|
||||
path_length = value.get('path_length', None)
|
||||
|
||||
if ca:
|
||||
if not isinstance(path_length, (type(None), int)):
|
||||
raise ValidationError('A CA certificate path_length (for BasicConstraints) must be None or an integer.')
|
||||
return x509.BasicConstraints(ca=True, path_length=path_length)
|
||||
else:
|
||||
return x509.BasicConstraints(ca=False, path_length=None)
|
||||
|
||||
|
||||
class SubjectAlternativeNameExtension(Field):
|
||||
"""An x509.SubjectAlternativeName ExtensionType object
|
||||
|
||||
Dict of CA boolean and a path_length integer names/values are deserialized into an x509.BasicConstraints object
|
||||
and back.
|
||||
|
||||
:param kwargs: The same keyword arguments that :class:`Field` receives.
|
||||
|
||||
"""
|
||||
def _serialize(self, value, attr, obj):
|
||||
general_names = []
|
||||
name_type = None
|
||||
|
||||
if value:
|
||||
for name in value._general_names:
|
||||
value = name.value
|
||||
|
||||
if isinstance(name, x509.DNSName):
|
||||
name_type = 'DNSName'
|
||||
|
||||
elif isinstance(name, x509.IPAddress):
|
||||
if isinstance(value, ipaddress.IPv4Network):
|
||||
name_type = 'IPNetwork'
|
||||
else:
|
||||
name_type = 'IPAddress'
|
||||
|
||||
value = str(value)
|
||||
|
||||
elif isinstance(name, x509.UniformResourceIdentifier):
|
||||
name_type = 'uniformResourceIdentifier'
|
||||
|
||||
elif isinstance(name, x509.DirectoryName):
|
||||
name_type = 'directoryName'
|
||||
|
||||
elif isinstance(name, x509.RFC822Name):
|
||||
name_type = 'rfc822Name'
|
||||
|
||||
elif isinstance(name, x509.RegisteredID):
|
||||
name_type = 'registeredID'
|
||||
value = value.dotted_string
|
||||
else:
|
||||
current_app.logger.warning('Unknown SubAltName type: {name}'.format(name=name))
|
||||
|
||||
general_names.append({'nameType': name_type, 'value': value})
|
||||
|
||||
return general_names
|
||||
|
||||
def _deserialize(self, value, attr, data):
|
||||
general_names = []
|
||||
for name in value:
|
||||
if name['nameType'] == 'DNSName':
|
||||
validators.sensitive_domain(name['value'])
|
||||
general_names.append(x509.DNSName(name['value']))
|
||||
|
||||
elif name['nameType'] == 'IPAddress':
|
||||
general_names.append(x509.IPAddress(ipaddress.ip_address(name['value'])))
|
||||
|
||||
elif name['nameType'] == 'IPNetwork':
|
||||
general_names.append(x509.IPAddress(ipaddress.ip_network(name['value'])))
|
||||
|
||||
elif name['nameType'] == 'uniformResourceIdentifier':
|
||||
general_names.append(x509.UniformResourceIdentifier(name['value']))
|
||||
|
||||
elif name['nameType'] == 'directoryName':
|
||||
# TODO: Need to parse a string in name['value'] like:
|
||||
# 'CN=Common Name, O=Org Name, OU=OrgUnit Name, C=US, ST=ST, L=City/emailAddress=person@example.com'
|
||||
# or
|
||||
# 'CN=Common Name/O=Org Name/OU=OrgUnit Name/C=US/ST=NH/L=City/emailAddress=person@example.com'
|
||||
# and turn it into something like:
|
||||
# x509.Name([
|
||||
# x509.NameAttribute(x509.OID_COMMON_NAME, "Common Name"),
|
||||
# x509.NameAttribute(x509.OID_ORGANIZATION_NAME, "Org Name"),
|
||||
# x509.NameAttribute(x509.OID_ORGANIZATIONAL_UNIT_NAME, "OrgUnit Name"),
|
||||
# x509.NameAttribute(x509.OID_COUNTRY_NAME, "US"),
|
||||
# x509.NameAttribute(x509.OID_STATE_OR_PROVINCE_NAME, "NH"),
|
||||
# x509.NameAttribute(x509.OID_LOCALITY_NAME, "City"),
|
||||
# x509.NameAttribute(x509.OID_EMAIL_ADDRESS, "person@example.com")
|
||||
# ]
|
||||
# general_names.append(x509.DirectoryName(x509.Name(BLAH))))
|
||||
pass
|
||||
|
||||
elif name['nameType'] == 'rfc822Name':
|
||||
general_names.append(x509.RFC822Name(name['value']))
|
||||
|
||||
elif name['nameType'] == 'registeredID':
|
||||
general_names.append(x509.RegisteredID(x509.ObjectIdentifier(name['value'])))
|
||||
|
||||
elif name['nameType'] == 'otherName':
|
||||
# This has two inputs (type and value), so it doesn't fit the mold of the rest of these GeneralName entities.
|
||||
# general_names.append(x509.OtherName(name['type'], bytes(name['value']), 'utf-8'))
|
||||
pass
|
||||
|
||||
elif name['nameType'] == 'x400Address':
|
||||
# The Python Cryptography library doesn't support x400Address types (yet?)
|
||||
pass
|
||||
|
||||
elif name['nameType'] == 'EDIPartyName':
|
||||
# The Python Cryptography library doesn't support EDIPartyName types (yet?)
|
||||
pass
|
||||
|
||||
else:
|
||||
current_app.logger.warning('Unable to deserialize SubAltName with type: {name_type}'.format(name_type=name['nameType']))
|
||||
|
||||
return x509.SubjectAlternativeName(general_names)
|
||||
|
@ -1,16 +1,29 @@
|
||||
"""
|
||||
.. module: lemur.common.health
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask import Blueprint
|
||||
from lemur.database import db
|
||||
from lemur.extensions import sentry
|
||||
|
||||
mod = Blueprint('healthCheck', __name__)
|
||||
|
||||
|
||||
@mod.route('/healthcheck')
|
||||
def health():
|
||||
return 'ok'
|
||||
try:
|
||||
if healthcheck(db):
|
||||
return 'ok'
|
||||
except Exception:
|
||||
sentry.captureException()
|
||||
return 'db check failed'
|
||||
|
||||
|
||||
def healthcheck(db):
|
||||
with db.engine.connect() as connection:
|
||||
connection.execute('SELECT 1;')
|
||||
return True
|
||||
|
@ -1,13 +1,15 @@
|
||||
"""
|
||||
.. module: lemur.common.managers
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
from lemur.exceptions import InvalidConfiguration
|
||||
|
||||
|
||||
# inspired by https://github.com/getsentry/sentry
|
||||
class InstanceManager(object):
|
||||
@ -58,9 +60,14 @@ class InstanceManager(object):
|
||||
results.append(cls())
|
||||
else:
|
||||
results.append(cls)
|
||||
|
||||
except InvalidConfiguration as e:
|
||||
current_app.logger.warning("Plugin '{0}' may not work correctly. {1}".format(class_name, e))
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.exception('Unable to import %s. Reason: %s', cls_path, e)
|
||||
current_app.logger.exception("Unable to import {0}. Reason: {1}".format(cls_path, e))
|
||||
continue
|
||||
|
||||
self.cache = results
|
||||
|
||||
return results
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""
|
||||
.. module: lemur.common.schema
|
||||
:platform: unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
@ -13,7 +13,9 @@ from flask import request, current_app
|
||||
from sqlalchemy.orm.collections import InstrumentedList
|
||||
|
||||
from inflection import camelize, underscore
|
||||
from marshmallow import Schema, post_dump, pre_load, pre_dump
|
||||
from marshmallow import Schema, post_dump, pre_load
|
||||
|
||||
from lemur.extensions import sentry
|
||||
|
||||
|
||||
class LemurSchema(Schema):
|
||||
@ -68,10 +70,9 @@ class LemurOutputSchema(LemurSchema):
|
||||
data = self.unwrap_envelope(data, many)
|
||||
return self.under(data, many=many)
|
||||
|
||||
@pre_dump(pass_many=True)
|
||||
def unwrap_envelope(self, data, many):
|
||||
if many:
|
||||
if data:
|
||||
if data['items']:
|
||||
if isinstance(data, InstrumentedList) or isinstance(data, list):
|
||||
self.context['total'] = len(data)
|
||||
return data
|
||||
@ -115,6 +116,28 @@ def wrap_errors(messages):
|
||||
return errors
|
||||
|
||||
|
||||
def unwrap_pagination(data, output_schema):
|
||||
if not output_schema:
|
||||
return data
|
||||
|
||||
if isinstance(data, dict):
|
||||
if 'total' in data.keys():
|
||||
if data.get('total') == 0:
|
||||
return data
|
||||
|
||||
marshaled_data = {'total': data['total']}
|
||||
marshaled_data['items'] = output_schema.dump(data['items'], many=True).data
|
||||
return marshaled_data
|
||||
|
||||
return output_schema.dump(data).data
|
||||
|
||||
elif isinstance(data, list):
|
||||
marshaled_data = {'total': len(data)}
|
||||
marshaled_data['items'] = output_schema.dump(data, many=True).data
|
||||
return marshaled_data
|
||||
return output_schema.dump(data).data
|
||||
|
||||
|
||||
def validate_schema(input_schema, output_schema):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
@ -135,6 +158,7 @@ def validate_schema(input_schema, output_schema):
|
||||
try:
|
||||
resp = f(*args, **kwargs)
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
||||
current_app.logger.exception(e)
|
||||
return dict(message=str(e)), 500
|
||||
|
||||
@ -144,10 +168,7 @@ def validate_schema(input_schema, output_schema):
|
||||
if not resp:
|
||||
return dict(message="No data found"), 404
|
||||
|
||||
if output_schema:
|
||||
data = output_schema.dump(resp)
|
||||
return data.data, 200
|
||||
return resp, 200
|
||||
return unwrap_pagination(resp, output_schema), 200
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
@ -1,20 +1,23 @@
|
||||
"""
|
||||
.. module: lemur.common.utils
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import six
|
||||
import sys
|
||||
import string
|
||||
import random
|
||||
import string
|
||||
|
||||
import sqlalchemy
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, ec
|
||||
from flask_restful.reqparse import RequestParser
|
||||
from sqlalchemy import and_, func
|
||||
|
||||
from flask.ext.restful.reqparse import RequestParser
|
||||
from lemur.constants import CERTIFICATE_KEY_TYPES
|
||||
from lemur.exceptions import InvalidConfiguration
|
||||
|
||||
paginated_parser = RequestParser()
|
||||
|
||||
@ -37,15 +40,96 @@ def get_psuedo_random_string():
|
||||
|
||||
|
||||
def parse_certificate(body):
|
||||
if sys.version_info[0] <= 2:
|
||||
return x509.load_pem_x509_certificate(bytes(body), default_backend())
|
||||
"""
|
||||
Helper function that parses a PEM certificate.
|
||||
|
||||
if isinstance(body, six.string_types):
|
||||
:param body:
|
||||
:return:
|
||||
"""
|
||||
if isinstance(body, str):
|
||||
body = body.encode('utf-8')
|
||||
|
||||
return x509.load_pem_x509_certificate(body, default_backend())
|
||||
|
||||
|
||||
def parse_csr(csr):
|
||||
"""
|
||||
Helper function that parses a CSR.
|
||||
|
||||
:param csr:
|
||||
:return:
|
||||
"""
|
||||
if isinstance(csr, str):
|
||||
csr = csr.encode('utf-8')
|
||||
|
||||
return x509.load_pem_x509_csr(csr, default_backend())
|
||||
|
||||
|
||||
def get_authority_key(body):
|
||||
"""Returns the authority key for a given certificate in hex format"""
|
||||
parsed_cert = parse_certificate(body)
|
||||
authority_key = parsed_cert.extensions.get_extension_for_class(
|
||||
x509.AuthorityKeyIdentifier).value.key_identifier
|
||||
return authority_key.hex()
|
||||
|
||||
|
||||
def generate_private_key(key_type):
|
||||
"""
|
||||
Generates a new private key based on key_type.
|
||||
|
||||
Valid key types: RSA2048, RSA4096', 'ECCPRIME192V1', 'ECCPRIME256V1', 'ECCSECP192R1',
|
||||
'ECCSECP224R1', 'ECCSECP256R1', 'ECCSECP384R1', 'ECCSECP521R1', 'ECCSECP256K1',
|
||||
'ECCSECT163K1', 'ECCSECT233K1', 'ECCSECT283K1', 'ECCSECT409K1', 'ECCSECT571K1',
|
||||
'ECCSECT163R2', 'ECCSECT233R1', 'ECCSECT283R1', 'ECCSECT409R1', 'ECCSECT571R2'
|
||||
|
||||
:param key_type:
|
||||
:return:
|
||||
"""
|
||||
|
||||
_CURVE_TYPES = {
|
||||
"ECCPRIME192V1": ec.SECP192R1(),
|
||||
"ECCPRIME256V1": ec.SECP256R1(),
|
||||
|
||||
"ECCSECP192R1": ec.SECP192R1(),
|
||||
"ECCSECP224R1": ec.SECP224R1(),
|
||||
"ECCSECP256R1": ec.SECP256R1(),
|
||||
"ECCSECP384R1": ec.SECP384R1(),
|
||||
"ECCSECP521R1": ec.SECP521R1(),
|
||||
"ECCSECP256K1": ec.SECP256K1(),
|
||||
|
||||
"ECCSECT163K1": ec.SECT163K1(),
|
||||
"ECCSECT233K1": ec.SECT233K1(),
|
||||
"ECCSECT283K1": ec.SECT283K1(),
|
||||
"ECCSECT409K1": ec.SECT409K1(),
|
||||
"ECCSECT571K1": ec.SECT571K1(),
|
||||
|
||||
"ECCSECT163R2": ec.SECT163R2(),
|
||||
"ECCSECT233R1": ec.SECT233R1(),
|
||||
"ECCSECT283R1": ec.SECT283R1(),
|
||||
"ECCSECT409R1": ec.SECT409R1(),
|
||||
"ECCSECT571R2": ec.SECT571R1(),
|
||||
}
|
||||
|
||||
if key_type not in CERTIFICATE_KEY_TYPES:
|
||||
raise Exception("Invalid key type: {key_type}. Supported key types: {choices}".format(
|
||||
key_type=key_type,
|
||||
choices=",".join(CERTIFICATE_KEY_TYPES)
|
||||
))
|
||||
|
||||
if 'RSA' in key_type:
|
||||
key_size = int(key_type[3:])
|
||||
return rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size,
|
||||
backend=default_backend()
|
||||
)
|
||||
elif 'ECC' in key_type:
|
||||
return ec.generate_private_key(
|
||||
curve=_CURVE_TYPES[key_type],
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
|
||||
def is_weekend(date):
|
||||
"""
|
||||
Determines if a given date is on a weekend.
|
||||
@ -55,3 +139,75 @@ def is_weekend(date):
|
||||
"""
|
||||
if date.weekday() > 5:
|
||||
return True
|
||||
|
||||
|
||||
def validate_conf(app, required_vars):
|
||||
"""
|
||||
Ensures that the given fields are set in the applications conf.
|
||||
|
||||
:param app:
|
||||
:param required_vars: list
|
||||
"""
|
||||
for var in required_vars:
|
||||
if not app.config.get(var):
|
||||
raise InvalidConfiguration("Required variable '{var}' is not set in Lemur's conf.".format(var=var))
|
||||
|
||||
|
||||
# https://bitbucket.org/zzzeek/sqlalchemy/wiki/UsageRecipes/WindowedRangeQuery
|
||||
def column_windows(session, column, windowsize):
|
||||
"""Return a series of WHERE clauses against
|
||||
a given column that break it into windows.
|
||||
|
||||
Result is an iterable of tuples, consisting of
|
||||
((start, end), whereclause), where (start, end) are the ids.
|
||||
|
||||
Requires a database that supports window functions,
|
||||
i.e. Postgresql, SQL Server, Oracle.
|
||||
|
||||
Enhance this yourself ! Add a "where" argument
|
||||
so that windows of just a subset of rows can
|
||||
be computed.
|
||||
|
||||
"""
|
||||
def int_for_range(start_id, end_id):
|
||||
if end_id:
|
||||
return and_(
|
||||
column >= start_id,
|
||||
column < end_id
|
||||
)
|
||||
else:
|
||||
return column >= start_id
|
||||
|
||||
q = session.query(
|
||||
column,
|
||||
func.row_number().over(order_by=column).label('rownum')
|
||||
).from_self(column)
|
||||
|
||||
if windowsize > 1:
|
||||
q = q.filter(sqlalchemy.text("rownum %% %d=1" % windowsize))
|
||||
|
||||
intervals = [id for id, in q]
|
||||
|
||||
while intervals:
|
||||
start = intervals.pop(0)
|
||||
if intervals:
|
||||
end = intervals[0]
|
||||
else:
|
||||
end = None
|
||||
yield int_for_range(start, end)
|
||||
|
||||
|
||||
def windowed_query(q, column, windowsize):
|
||||
""""Break a Query into windows on a given column."""
|
||||
|
||||
for whereclause in column_windows(
|
||||
q.session,
|
||||
column, windowsize):
|
||||
for row in q.filter(whereclause).order_by(column):
|
||||
yield row
|
||||
|
||||
|
||||
def truthiness(s):
|
||||
"""If input string resembles something truthy then return True, else False."""
|
||||
|
||||
return s.lower() in ('true', 'yes', 'on', 't', '1')
|
||||
|
@ -1,9 +1,10 @@
|
||||
import re
|
||||
|
||||
from flask import current_app
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.x509 import NameOID
|
||||
from flask import current_app
|
||||
from marshmallow.exceptions import ValidationError
|
||||
|
||||
from lemur.auth.permissions import SensitiveDomainPermission
|
||||
@ -20,7 +21,8 @@ def public_certificate(body):
|
||||
"""
|
||||
try:
|
||||
parse_certificate(body)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
current_app.logger.exception(e)
|
||||
raise ValidationError('Public certificate presented is not valid.')
|
||||
|
||||
|
||||
@ -40,22 +42,33 @@ def private_key(key):
|
||||
raise ValidationError('Private key presented is not valid.')
|
||||
|
||||
|
||||
def common_name(value):
|
||||
"""If the common name could be a domain name, apply domain validation rules."""
|
||||
# Common name could be a domain name, or a human-readable name of the subject (often used in CA names or client
|
||||
# certificates). As a simple heuristic, we assume that human-readable names always include a space.
|
||||
# However, to avoid confusion for humans, we also don't count spaces at the beginning or end of the string.
|
||||
if ' ' not in value.strip():
|
||||
return sensitive_domain(value)
|
||||
|
||||
|
||||
def sensitive_domain(domain):
|
||||
"""
|
||||
Determines if domain has been marked as sensitive.
|
||||
:param domain:
|
||||
Checks if user has the admin role, the domain does not match sensitive domains and whitelisted domain patterns.
|
||||
:param domain: domain name (str)
|
||||
:return:
|
||||
"""
|
||||
restricted_domains = current_app.config.get('LEMUR_RESTRICTED_DOMAINS', [])
|
||||
if restricted_domains:
|
||||
domains = domain_service.get_by_name(domain)
|
||||
for domain in domains:
|
||||
# we only care about non-admins
|
||||
if not SensitiveDomainPermission().can():
|
||||
if domain.sensitive or any([re.match(pattern, domain.name) for pattern in restricted_domains]):
|
||||
raise ValidationError(
|
||||
'Domain {0} has been marked as sensitive, contact and administrator \
|
||||
to issue the certificate.'.format(domain))
|
||||
if SensitiveDomainPermission().can():
|
||||
# User has permission, no need to check anything
|
||||
return
|
||||
|
||||
whitelist = current_app.config.get('LEMUR_WHITELISTED_DOMAINS', [])
|
||||
if whitelist and not any(re.match(pattern, domain) for pattern in whitelist):
|
||||
raise ValidationError('Domain {0} does not match whitelisted domain patterns. '
|
||||
'Contact an administrator to issue the certificate.'.format(domain))
|
||||
|
||||
if any(d.sensitive for d in domain_service.get_by_name(domain)):
|
||||
raise ValidationError('Domain {0} has been marked as sensitive. '
|
||||
'Contact an administrator to issue the certificate.'.format(domain))
|
||||
|
||||
|
||||
def encoding(oid_encoding):
|
||||
@ -83,15 +96,27 @@ def sub_alt_type(alt_type):
|
||||
|
||||
def csr(data):
|
||||
"""
|
||||
Determines if the CSR is valid.
|
||||
Determines if the CSR is valid and allowed.
|
||||
:param data:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
x509.load_pem_x509_csr(bytes(data), default_backend())
|
||||
request = x509.load_pem_x509_csr(data.encode('utf-8'), default_backend())
|
||||
except Exception:
|
||||
raise ValidationError('CSR presented is not valid.')
|
||||
|
||||
# Validate common name and SubjectAltNames
|
||||
for name in request.subject.get_attributes_for_oid(NameOID.COMMON_NAME):
|
||||
common_name(name.value)
|
||||
|
||||
try:
|
||||
alt_names = request.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
|
||||
for name in alt_names.value.get_values_for_type(x509.DNSName):
|
||||
sensitive_domain(name)
|
||||
except x509.ExtensionNotFound:
|
||||
pass
|
||||
|
||||
|
||||
def dates(data):
|
||||
if not data.get('validity_start') and data.get('validity_end'):
|
||||
|
@ -1,8 +1,34 @@
|
||||
"""
|
||||
.. module: lemur.constants
|
||||
:copyright: (c) 2015 by Netflix Inc.
|
||||
:copyright: (c) 2018 by Netflix Inc.
|
||||
:license: Apache, see LICENSE for more details.
|
||||
"""
|
||||
SAN_NAMING_TEMPLATE = "SAN-{subject}-{issuer}-{not_before}-{not_after}"
|
||||
DEFAULT_NAMING_TEMPLATE = "{subject}-{issuer}-{not_before}-{not_after}"
|
||||
NONSTANDARD_NAMING_TEMPLATE = "{issuer}-{not_before}-{not_after}"
|
||||
|
||||
SUCCESS_METRIC_STATUS = 'success'
|
||||
FAILURE_METRIC_STATUS = 'failure'
|
||||
|
||||
CERTIFICATE_KEY_TYPES = [
|
||||
'RSA2048',
|
||||
'RSA4096',
|
||||
'ECCPRIME192V1',
|
||||
'ECCPRIME256V1',
|
||||
'ECCSECP192R1',
|
||||
'ECCSECP224R1',
|
||||
'ECCSECP256R1',
|
||||
'ECCSECP384R1',
|
||||
'ECCSECP521R1',
|
||||
'ECCSECP256K1',
|
||||
'ECCSECT163K1',
|
||||
'ECCSECT233K1',
|
||||
'ECCSECT283K1',
|
||||
'ECCSECT409K1',
|
||||
'ECCSECT571K1',
|
||||
'ECCSECT163R2',
|
||||
'ECCSECT233R1',
|
||||
'ECCSECT283R1',
|
||||
'ECCSECT409R1',
|
||||
'ECCSECT571R2'
|
||||
]
|
||||
|
@ -4,12 +4,13 @@
|
||||
:synopsis: This module contains all of the database related methods
|
||||
needed for lemur to interact with a datastore
|
||||
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from sqlalchemy import exc
|
||||
from inflection import underscore
|
||||
from sqlalchemy import exc, func
|
||||
from sqlalchemy.sql import and_, or_
|
||||
from sqlalchemy.orm import make_transient
|
||||
|
||||
@ -75,6 +76,16 @@ def add(model):
|
||||
db.session.add(model)
|
||||
|
||||
|
||||
def get_model_column(model, field):
|
||||
if field in getattr(model, 'sensitive_fields', ()):
|
||||
raise AttrNotFound(field)
|
||||
column = model.__table__.columns._data.get(field, None)
|
||||
if column is None:
|
||||
raise AttrNotFound(field)
|
||||
|
||||
return column
|
||||
|
||||
|
||||
def find_all(query, model, kwargs):
|
||||
"""
|
||||
Returns a query object that ensures that all kwargs
|
||||
@ -91,7 +102,7 @@ def find_all(query, model, kwargs):
|
||||
if not isinstance(value, list):
|
||||
value = value.split(',')
|
||||
|
||||
conditions.append(getattr(model, attr).in_(value))
|
||||
conditions.append(get_model_column(model, attr).in_(value))
|
||||
|
||||
return query.filter(and_(*conditions))
|
||||
|
||||
@ -108,7 +119,7 @@ def find_any(query, model, kwargs):
|
||||
"""
|
||||
or_args = []
|
||||
for attr, value in kwargs.items():
|
||||
or_args.append(or_(getattr(model, attr) == value))
|
||||
or_args.append(or_(get_model_column(model, attr) == value))
|
||||
exprs = or_(*or_args)
|
||||
return query.filter(exprs)
|
||||
|
||||
@ -123,7 +134,7 @@ def get(model, value, field="id"):
|
||||
:return:
|
||||
"""
|
||||
query = session_query(model)
|
||||
return query.filter(getattr(model, field) == value).scalar()
|
||||
return query.filter(get_model_column(model, field) == value).scalar()
|
||||
|
||||
|
||||
def get_all(model, value, field="id"):
|
||||
@ -136,7 +147,7 @@ def get_all(model, value, field="id"):
|
||||
:return:
|
||||
"""
|
||||
query = session_query(model)
|
||||
return query.filter(getattr(model, field) == value)
|
||||
return query.filter(get_model_column(model, field) == value)
|
||||
|
||||
|
||||
def create(model):
|
||||
@ -188,7 +199,8 @@ def filter(query, model, terms):
|
||||
:param terms:
|
||||
:return:
|
||||
"""
|
||||
return query.filter(getattr(model, terms[0]).ilike('%{}%'.format(terms[1])))
|
||||
column = get_model_column(model, underscore(terms[0]))
|
||||
return query.filter(column.ilike('%{}%'.format(terms[1])))
|
||||
|
||||
|
||||
def sort(query, model, field, direction):
|
||||
@ -201,13 +213,8 @@ def sort(query, model, field, direction):
|
||||
:param field:
|
||||
:param direction:
|
||||
"""
|
||||
try:
|
||||
field = getattr(model, field)
|
||||
direction = getattr(field, direction)
|
||||
query = query.order_by(direction())
|
||||
return query
|
||||
except AttributeError:
|
||||
raise AttrNotFound(field)
|
||||
column = get_model_column(model, underscore(field))
|
||||
return query.order_by(column.desc() if direction == 'desc' else column.asc())
|
||||
|
||||
|
||||
def paginate(query, page, count):
|
||||
@ -260,6 +267,17 @@ def clone(model):
|
||||
return model
|
||||
|
||||
|
||||
def get_count(q):
|
||||
"""
|
||||
Count the number of rows in a table. More efficient than count(*)
|
||||
:param q:
|
||||
:return:
|
||||
"""
|
||||
count_q = q.statement.with_only_columns([func.count()]).order_by(None)
|
||||
count = q.session.execute(count_q).scalar()
|
||||
return count
|
||||
|
||||
|
||||
def sort_and_page(query, model, args):
|
||||
"""
|
||||
Helper that allows us to combine sorting and paging
|
||||
@ -274,12 +292,15 @@ def sort_and_page(query, model, args):
|
||||
page = args.pop('page')
|
||||
count = args.pop('count')
|
||||
|
||||
if args.get('user'):
|
||||
user = args.pop('user')
|
||||
|
||||
query = find_all(query, model, args)
|
||||
|
||||
if sort_by and sort_dir:
|
||||
query = sort(query, model, sort_by, sort_dir)
|
||||
|
||||
total = query.count()
|
||||
total = get_count(query)
|
||||
|
||||
# offset calculated at zero
|
||||
page -= 1
|
||||
|
@ -1,56 +0,0 @@
|
||||
"""
|
||||
.. module: lemur.decorators
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
"""
|
||||
from builtins import str
|
||||
|
||||
from datetime import timedelta
|
||||
from flask import make_response, request, current_app
|
||||
|
||||
from functools import update_wrapper
|
||||
|
||||
|
||||
# this is only used for dev
|
||||
def crossdomain(origin=None, methods=None, headers=None,
|
||||
max_age=21600, attach_to_all=True,
|
||||
automatic_options=True): # pragma: no cover
|
||||
if methods is not None:
|
||||
methods = ', '.join(sorted(x.upper() for x in methods))
|
||||
|
||||
if headers is not None and not isinstance(headers, str):
|
||||
headers = ', '.join(x.upper() for x in headers)
|
||||
|
||||
if not isinstance(origin, str):
|
||||
origin = ', '.join(origin)
|
||||
|
||||
if isinstance(max_age, timedelta):
|
||||
max_age = max_age.total_seconds()
|
||||
|
||||
def get_methods():
|
||||
if methods is not None:
|
||||
return methods
|
||||
|
||||
options_resp = current_app.make_default_options_response()
|
||||
return options_resp.headers['allow']
|
||||
|
||||
def decorator(f):
|
||||
def wrapped_function(*args, **kwargs):
|
||||
if automatic_options and request.method == 'OPTIONS':
|
||||
resp = current_app.make_default_options_response()
|
||||
else:
|
||||
resp = make_response(f(*args, **kwargs))
|
||||
if not attach_to_all and request.method != 'OPTIONS':
|
||||
return resp
|
||||
|
||||
h = resp.headers
|
||||
h['Access-Control-Allow-Origin'] = origin
|
||||
h['Access-Control-Allow-Methods'] = get_methods()
|
||||
h['Access-Control-Max-Age'] = str(max_age)
|
||||
h['Access-Control-Allow-Headers'] = "Origin, X-Requested-With, Content-Type, Accept, Authorization "
|
||||
h['Access-Control-Allow-Credentials'] = 'true'
|
||||
return resp
|
||||
|
||||
f.provide_automatic_options = False
|
||||
return update_wrapper(wrapped_function, f)
|
||||
return decorator
|
@ -9,7 +9,7 @@ THREADS_PER_PAGE = 8
|
||||
|
||||
# These will need to be set to `True` if you are developing locally
|
||||
CORS = False
|
||||
debug = False
|
||||
DEBUG = False
|
||||
|
||||
# Logging
|
||||
|
||||
|
23
lemur/defaults/schemas.py
Normal file
23
lemur/defaults/schemas.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""
|
||||
.. module: lemur.defaults.schemas
|
||||
:platform: unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from marshmallow import fields
|
||||
from lemur.common.schema import LemurOutputSchema
|
||||
from lemur.authorities.schemas import AuthorityNestedOutputSchema
|
||||
|
||||
|
||||
class DefaultOutputSchema(LemurOutputSchema):
|
||||
authority = fields.Nested(AuthorityNestedOutputSchema)
|
||||
country = fields.String()
|
||||
state = fields.String()
|
||||
location = fields.String()
|
||||
organization = fields.String()
|
||||
organizational_unit = fields.String()
|
||||
issuer_plugin = fields.String()
|
||||
|
||||
|
||||
default_output_schema = DefaultOutputSchema()
|
@ -1,13 +1,17 @@
|
||||
"""
|
||||
.. module: lemur.status.views
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
.. module: lemur.defaults.views
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
"""
|
||||
from flask import current_app, Blueprint
|
||||
from flask.ext.restful import Api
|
||||
from flask_restful import Api
|
||||
|
||||
from lemur.common.schema import validate_schema
|
||||
from lemur.authorities.service import get_by_name
|
||||
from lemur.auth.service import AuthenticatedResource
|
||||
|
||||
from lemur.defaults.schemas import default_output_schema
|
||||
|
||||
|
||||
mod = Blueprint('default', __name__)
|
||||
api = Api(mod)
|
||||
@ -18,6 +22,7 @@ class LemurDefaults(AuthenticatedResource):
|
||||
def __init__(self):
|
||||
super(LemurDefaults)
|
||||
|
||||
@validate_schema(None, default_output_schema)
|
||||
def get(self):
|
||||
"""
|
||||
.. http:get:: /defaults
|
||||
@ -45,20 +50,26 @@ class LemurDefaults(AuthenticatedResource):
|
||||
"state": "CA",
|
||||
"location": "Los Gatos",
|
||||
"organization": "Netflix",
|
||||
"organizationalUnit": "Operations"
|
||||
"organizationalUnit": "Operations",
|
||||
"dnsProviders": [{"name": "test", ...}, {...}],
|
||||
}
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
|
||||
default_authority = get_by_name(current_app.config.get('LEMUR_DEFAULT_AUTHORITY'))
|
||||
|
||||
return dict(
|
||||
country=current_app.config.get('LEMUR_DEFAULT_COUNTRY'),
|
||||
state=current_app.config.get('LEMUR_DEFAULT_STATE'),
|
||||
location=current_app.config.get('LEMUR_DEFAULT_LOCATION'),
|
||||
organization=current_app.config.get('LEMUR_DEFAULT_ORGANIZATION'),
|
||||
organizationalUnit=current_app.config.get('LEMUR_DEFAULT_ORGANIZATIONAL_UNIT'),
|
||||
issuerPlugin=current_app.config.get('LEMUR_DEFAULT_ISSUER_PLUGIN')
|
||||
organizational_unit=current_app.config.get('LEMUR_DEFAULT_ORGANIZATIONAL_UNIT'),
|
||||
issuer_plugin=current_app.config.get('LEMUR_DEFAULT_ISSUER_PLUGIN'),
|
||||
authority=default_authority,
|
||||
)
|
||||
|
||||
|
||||
api.add_resource(LemurDefaults, '/defaults', endpoint='default')
|
||||
|
0
lemur/deployment/__init__.py
Normal file
0
lemur/deployment/__init__.py
Normal file
15
lemur/deployment/service.py
Normal file
15
lemur/deployment/service.py
Normal file
@ -0,0 +1,15 @@
|
||||
from lemur import database
|
||||
|
||||
|
||||
def rotate_certificate(endpoint, new_cert):
|
||||
"""
|
||||
Rotates a certificate on a given endpoint.
|
||||
|
||||
:param endpoint:
|
||||
:param new_cert:
|
||||
:return:
|
||||
"""
|
||||
# ensure that certificate is available for rotation
|
||||
endpoint.source.plugin.update_endpoint(endpoint, new_cert)
|
||||
endpoint.certificate = new_cert
|
||||
database.update(endpoint)
|
@ -1,7 +1,7 @@
|
||||
"""
|
||||
.. module: lemur.destinations.models
|
||||
:platform: unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""
|
||||
.. module: lemur.destinations.schemas
|
||||
:platform: unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
@ -29,7 +29,8 @@ class DestinationOutputSchema(LemurOutputSchema):
|
||||
|
||||
@post_dump
|
||||
def fill_object(self, data):
|
||||
data['plugin']['pluginOptions'] = data['options']
|
||||
if data:
|
||||
data['plugin']['pluginOptions'] = data['options']
|
||||
return data
|
||||
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""
|
||||
.. module: lemur.destinations.service
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
@ -22,6 +22,11 @@ def create(label, plugin_name, options, description=None):
|
||||
:rtype : Destination
|
||||
:return: New destination
|
||||
"""
|
||||
# remove any sub-plugin objects before try to save the json options
|
||||
for option in options:
|
||||
if 'plugin' in option['type']:
|
||||
del option['value']['plugin_object']
|
||||
|
||||
destination = Destination(label=label, options=options, plugin_name=plugin_name, description=description)
|
||||
return database.create(destination)
|
||||
|
||||
@ -56,7 +61,7 @@ def delete(destination_id):
|
||||
|
||||
def get(destination_id):
|
||||
"""
|
||||
Retrieves an destination by it's lemur assigned ID.
|
||||
Retrieves an destination by its lemur assigned ID.
|
||||
|
||||
:param destination_id: Lemur assigned ID
|
||||
:rtype : Destination
|
||||
@ -67,7 +72,7 @@ def get(destination_id):
|
||||
|
||||
def get_by_label(label):
|
||||
"""
|
||||
Retrieves a destination by it's label
|
||||
Retrieves a destination by its label
|
||||
|
||||
:param label:
|
||||
:return:
|
||||
|
@ -2,12 +2,12 @@
|
||||
.. module: lemur.destinations.views
|
||||
:platform: Unix
|
||||
:synopsis: This module contains all of the accounts view code.
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask import Blueprint
|
||||
from flask.ext.restful import Api, reqparse
|
||||
from flask_restful import Api, reqparse
|
||||
from lemur.destinations import service
|
||||
|
||||
from lemur.auth.service import AuthenticatedResource
|
||||
@ -82,7 +82,7 @@ class DestinationsList(AuthenticatedResource):
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: acs or desc
|
||||
:query sortDir: asc or desc
|
||||
:query page: int. default is 1
|
||||
:query filter: key value pair format is k;v
|
||||
:query count: count number default is 10
|
||||
@ -392,7 +392,7 @@ class CertificateDestinations(AuthenticatedResource):
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: acs or desc
|
||||
:query sortDir: asc or desc
|
||||
:query page: int default is 1
|
||||
:query filter: key value pair format is k;v
|
||||
:query count: count number default is 10
|
||||
|
37
lemur/dns_providers/models.py
Normal file
37
lemur/dns_providers/models.py
Normal file
@ -0,0 +1,37 @@
|
||||
from sqlalchemy import Column, Integer, String, text, Text
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
from sqlalchemy_utils import ArrowType
|
||||
|
||||
from lemur.database import db
|
||||
from lemur.plugins.base import plugins
|
||||
from lemur.utils import Vault
|
||||
|
||||
|
||||
class DnsProvider(db.Model):
|
||||
__tablename__ = 'dns_providers'
|
||||
id = Column(
|
||||
Integer(),
|
||||
primary_key=True,
|
||||
)
|
||||
name = Column(String(length=256), unique=True, nullable=True)
|
||||
description = Column(Text(), nullable=True)
|
||||
provider_type = Column(String(length=256), nullable=True)
|
||||
credentials = Column(Vault, nullable=True)
|
||||
api_endpoint = Column(String(length=256), nullable=True)
|
||||
date_created = Column(ArrowType(), server_default=text('now()'), nullable=False)
|
||||
status = Column(String(length=128), nullable=True)
|
||||
options = Column(JSON, nullable=True)
|
||||
domains = Column(JSON, nullable=True)
|
||||
|
||||
def __init__(self, name, description, provider_type, credentials):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.provider_type = provider_type
|
||||
self.credentials = credentials
|
||||
|
||||
@property
|
||||
def plugin(self):
|
||||
return plugins.get(self.plugin_name)
|
||||
|
||||
def __repr__(self):
|
||||
return "DnsProvider(name={name})".format(name=self.name)
|
27
lemur/dns_providers/schemas.py
Normal file
27
lemur/dns_providers/schemas.py
Normal file
@ -0,0 +1,27 @@
|
||||
from lemur.common.fields import ArrowDateTime
|
||||
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
|
||||
|
||||
from marshmallow import fields
|
||||
|
||||
|
||||
class DnsProvidersNestedOutputSchema(LemurOutputSchema):
|
||||
__envelope__ = False
|
||||
id = fields.Integer()
|
||||
name = fields.String()
|
||||
providerType = fields.String()
|
||||
description = fields.String()
|
||||
credentials = fields.String()
|
||||
api_endpoint = fields.String()
|
||||
date_created = ArrowDateTime()
|
||||
|
||||
|
||||
class DnsProvidersNestedInputSchema(LemurInputSchema):
|
||||
__envelope__ = False
|
||||
name = fields.String()
|
||||
description = fields.String()
|
||||
provider_type = fields.Dict()
|
||||
|
||||
|
||||
dns_provider_output_schema = DnsProvidersNestedOutputSchema()
|
||||
|
||||
dns_provider_input_schema = DnsProvidersNestedInputSchema()
|
111
lemur/dns_providers/service.py
Normal file
111
lemur/dns_providers/service.py
Normal file
@ -0,0 +1,111 @@
|
||||
import json
|
||||
|
||||
from flask import current_app
|
||||
from lemur import database
|
||||
from lemur.dns_providers.models import DnsProvider
|
||||
|
||||
|
||||
def render(args):
|
||||
"""
|
||||
Helper that helps us render the REST Api responses.
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
query = database.session_query(DnsProvider)
|
||||
|
||||
return database.sort_and_page(query, DnsProvider, args)
|
||||
|
||||
|
||||
def get(dns_provider_id):
|
||||
provider = database.get(DnsProvider, dns_provider_id)
|
||||
return provider
|
||||
|
||||
|
||||
def get_friendly(dns_provider_id):
|
||||
"""
|
||||
Retrieves a dns provider by its lemur assigned ID.
|
||||
|
||||
:param dns_provider_id: Lemur assigned ID
|
||||
:rtype : DnsProvider
|
||||
:return:
|
||||
"""
|
||||
dns_provider = get(dns_provider_id)
|
||||
dns_provider_friendly = {
|
||||
"name": dns_provider.name,
|
||||
"description": dns_provider.description,
|
||||
"providerType": dns_provider.provider_type,
|
||||
"options": dns_provider.options,
|
||||
"credentials": dns_provider.credentials,
|
||||
}
|
||||
|
||||
if dns_provider.provider_type == "route53":
|
||||
dns_provider_friendly["account_id"] = json.loads(dns_provider.credentials).get("account_id")
|
||||
return dns_provider_friendly
|
||||
|
||||
|
||||
def delete(dns_provider_id):
|
||||
"""
|
||||
Deletes a DNS provider.
|
||||
|
||||
:param dns_provider_id: Lemur assigned ID
|
||||
"""
|
||||
database.delete(get(dns_provider_id))
|
||||
|
||||
|
||||
def get_types():
|
||||
provider_config = current_app.config.get(
|
||||
'ACME_DNS_PROVIDER_TYPES',
|
||||
{"items": [
|
||||
{
|
||||
'name': 'route53',
|
||||
'requirements': [
|
||||
{
|
||||
'name': 'account_id',
|
||||
'type': 'int',
|
||||
'required': True,
|
||||
'helpMessage': 'AWS Account number'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
'name': 'cloudflare',
|
||||
'requirements': [
|
||||
{
|
||||
'name': 'email',
|
||||
'type': 'str',
|
||||
'required': True,
|
||||
'helpMessage': 'Cloudflare Email'
|
||||
},
|
||||
{
|
||||
'name': 'key',
|
||||
'type': 'str',
|
||||
'required': True,
|
||||
'helpMessage': 'Cloudflare Key'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
'name': 'dyn',
|
||||
},
|
||||
]}
|
||||
)
|
||||
if not provider_config:
|
||||
raise Exception("No DNS Provider configuration specified.")
|
||||
provider_config["total"] = len(provider_config.get("items"))
|
||||
return provider_config
|
||||
|
||||
|
||||
def create(data):
|
||||
provider_name = data.get("name")
|
||||
|
||||
credentials = {}
|
||||
for item in data.get("provider_type", {}).get("requirements", []):
|
||||
credentials[item["name"]] = item["value"]
|
||||
dns_provider = DnsProvider(
|
||||
name=provider_name,
|
||||
description=data.get("description"),
|
||||
provider_type=data.get("provider_type").get("name"),
|
||||
credentials=json.dumps(credentials),
|
||||
)
|
||||
created = database.create(dns_provider)
|
||||
return created.id
|
170
lemur/dns_providers/views.py
Normal file
170
lemur/dns_providers/views.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""
|
||||
.. module: lemur.dns)providers.views
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Curtis Castrapel <ccastrapel@netflix.com>
|
||||
"""
|
||||
from flask import Blueprint, g
|
||||
from flask_restful import reqparse, Api
|
||||
|
||||
from lemur.auth.permissions import admin_permission
|
||||
from lemur.auth.service import AuthenticatedResource
|
||||
from lemur.common.schema import validate_schema
|
||||
from lemur.common.utils import paginated_parser
|
||||
from lemur.dns_providers import service
|
||||
from lemur.dns_providers.schemas import dns_provider_output_schema, dns_provider_input_schema
|
||||
|
||||
mod = Blueprint('dns_providers', __name__)
|
||||
api = Api(mod)
|
||||
|
||||
|
||||
class DnsProvidersList(AuthenticatedResource):
|
||||
""" Defines the 'dns_providers' endpoint """
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(DnsProvidersList, self).__init__()
|
||||
|
||||
@validate_schema(None, dns_provider_output_schema)
|
||||
def get(self):
|
||||
"""
|
||||
.. http:get:: /dns_providers
|
||||
|
||||
The current list of DNS Providers
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /dns_providers HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"items": [{
|
||||
"id": 1,
|
||||
"name": "test",
|
||||
"description": "test",
|
||||
"provider_type": "dyn",
|
||||
"status": "active",
|
||||
}],
|
||||
"total": 1
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: asc or desc
|
||||
:query page: int. default is 1
|
||||
:query filter: key value pair format is k;v
|
||||
:query count: count number. default is 10
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
|
||||
"""
|
||||
parser = paginated_parser.copy()
|
||||
parser.add_argument('dns_provider_id', type=int, location='args')
|
||||
parser.add_argument('name', type=str, location='args')
|
||||
parser.add_argument('type', type=str, location='args')
|
||||
|
||||
args = parser.parse_args()
|
||||
args['user'] = g.user
|
||||
return service.render(args)
|
||||
|
||||
@validate_schema(dns_provider_input_schema, None)
|
||||
@admin_permission.require(http_exception=403)
|
||||
def post(self, data=None):
|
||||
"""
|
||||
Creates a DNS Provider
|
||||
|
||||
**Example request**:
|
||||
{
|
||||
"providerType": {
|
||||
"name": "route53",
|
||||
"requirements": [
|
||||
{
|
||||
"name": "account_id",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"helpMessage": "AWS Account number",
|
||||
"value": 12345
|
||||
}
|
||||
],
|
||||
"route": "dns_provider_options",
|
||||
"reqParams": null,
|
||||
"restangularized": true,
|
||||
"fromServer": true,
|
||||
"parentResource": null,
|
||||
"restangularCollection": false
|
||||
},
|
||||
"name": "provider_name",
|
||||
"description": "provider_description"
|
||||
}
|
||||
|
||||
**Example request 2**
|
||||
{
|
||||
"providerType": {
|
||||
"name": "cloudflare",
|
||||
"requirements": [
|
||||
{
|
||||
"name": "email",
|
||||
"type": "str",
|
||||
"required": true,
|
||||
"helpMessage": "Cloudflare Email",
|
||||
"value": "test@example.com"
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"type": "str",
|
||||
"required": true,
|
||||
"helpMessage": "Cloudflare Key",
|
||||
"value": "secretkey"
|
||||
}
|
||||
],
|
||||
"route": "dns_provider_options",
|
||||
"reqParams": null,
|
||||
"restangularized": true,
|
||||
"fromServer": true,
|
||||
"parentResource": null,
|
||||
"restangularCollection": false
|
||||
},
|
||||
"name": "provider_name",
|
||||
"description": "provider_description"
|
||||
}
|
||||
:return:
|
||||
"""
|
||||
return service.create(data)
|
||||
|
||||
|
||||
class DnsProviders(AuthenticatedResource):
|
||||
@validate_schema(None, dns_provider_output_schema)
|
||||
def get(self, dns_provider_id):
|
||||
return service.get_friendly(dns_provider_id)
|
||||
|
||||
@admin_permission.require(http_exception=403)
|
||||
def delete(self, dns_provider_id):
|
||||
service.delete(dns_provider_id)
|
||||
return {'result': True}
|
||||
|
||||
|
||||
class DnsProviderOptions(AuthenticatedResource):
|
||||
""" Defines the 'dns_provider_types' endpoint """
|
||||
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(DnsProviderOptions, self).__init__()
|
||||
|
||||
def get(self):
|
||||
return service.get_types()
|
||||
|
||||
|
||||
api.add_resource(DnsProvidersList, '/dns_providers', endpoint='dns_providers')
|
||||
api.add_resource(DnsProviders, '/dns_providers/<int:dns_provider_id>', endpoint='dns_provider')
|
||||
api.add_resource(DnsProviderOptions, '/dns_provider_options', endpoint='dns_provider_options')
|
@ -1,7 +1,7 @@
|
||||
"""
|
||||
.. module: lemur.domains.models
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""
|
||||
.. module: lemur.domains.schemas
|
||||
:platform: unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
@ -15,7 +15,7 @@ from lemur.schemas import AssociatedCertificateSchema
|
||||
class DomainInputSchema(LemurInputSchema):
|
||||
id = fields.Integer()
|
||||
name = fields.String(required=True)
|
||||
sensitive = fields.Boolean()
|
||||
sensitive = fields.Boolean(missing=False)
|
||||
certificates = fields.Nested(AssociatedCertificateSchema, many=True, missing=[])
|
||||
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""
|
||||
.. module: lemur.domains.service
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
@ -34,7 +34,7 @@ def get_all():
|
||||
|
||||
def get_by_name(name):
|
||||
"""
|
||||
Fetches domain by it's name
|
||||
Fetches domain by its name
|
||||
|
||||
:param name:
|
||||
:return:
|
||||
@ -76,7 +76,7 @@ def render(args):
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
query = database.session_query(Domain).join(Certificate, Domain.certificate)
|
||||
query = database.session_query(Domain)
|
||||
filt = args.pop('filter')
|
||||
certificate_id = args.pop('certificate_id', None)
|
||||
|
||||
@ -85,6 +85,7 @@ def render(args):
|
||||
query = database.filter(query, Domain, terms)
|
||||
|
||||
if certificate_id:
|
||||
query = query.join(Certificate, Domain.certificates)
|
||||
query = query.filter(Certificate.id == certificate_id)
|
||||
|
||||
return database.sort_and_page(query, Domain, args)
|
||||
|
@ -1,14 +1,14 @@
|
||||
"""
|
||||
.. module: lemur.domains.views
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
||||
"""
|
||||
from flask import Blueprint
|
||||
from flask.ext.restful import reqparse, Api
|
||||
from flask_restful import reqparse, Api
|
||||
|
||||
from lemur.domains import service
|
||||
from lemur.auth.service import AuthenticatedResource
|
||||
@ -68,7 +68,7 @@ class DomainsList(AuthenticatedResource):
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: acs or desc
|
||||
:query sortDir: asc or desc
|
||||
:query page: int default is 1
|
||||
:query filter: key value pair format is k;v
|
||||
:query count: count number. default is 10
|
||||
@ -115,7 +115,7 @@ class DomainsList(AuthenticatedResource):
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: acs or desc
|
||||
:query sortDir: asc or desc
|
||||
:query page: int default is 1
|
||||
:query filter: key value pair format is k;v
|
||||
:query count: count number default is 10
|
||||
@ -255,7 +255,7 @@ class CertificateDomains(AuthenticatedResource):
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: acs or desc
|
||||
:query sortDir: asc or desc
|
||||
:query page: int default is 1
|
||||
:query filter: key value pair format is k;v
|
||||
:query count: count number default is 10
|
||||
|
43
lemur/endpoints/cli.py
Normal file
43
lemur/endpoints/cli.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""
|
||||
.. module: lemur.endpoints.cli
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask_script import Manager
|
||||
|
||||
import arrow
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import cast
|
||||
from sqlalchemy_utils import ArrowType
|
||||
|
||||
from lemur import database
|
||||
from lemur.extensions import metrics, sentry
|
||||
from lemur.endpoints.models import Endpoint
|
||||
|
||||
|
||||
manager = Manager(usage="Handles all endpoint related tasks.")
|
||||
|
||||
|
||||
@manager.option('-ttl', '--time-to-live', type=int, dest='ttl', default=2, help='Time in hours, which endpoint has not been refreshed to remove the endpoint.')
|
||||
def expire(ttl):
|
||||
"""
|
||||
Removed all endpoints that have not been recently updated.
|
||||
"""
|
||||
print("[+] Staring expiration of old endpoints.")
|
||||
|
||||
try:
|
||||
now = arrow.utcnow()
|
||||
expiration = now - timedelta(hours=ttl)
|
||||
endpoints = database.session_query(Endpoint).filter(cast(Endpoint.last_updated, ArrowType) <= expiration)
|
||||
|
||||
for endpoint in endpoints:
|
||||
print("[!] Expiring endpoint: {name} Last Updated: {last_updated}".format(name=endpoint.name, last_updated=endpoint.last_updated))
|
||||
database.delete(endpoint)
|
||||
metrics.send('endpoint_expired', 'counter', 1)
|
||||
|
||||
print("[+] Finished expiration.")
|
||||
except Exception as e:
|
||||
sentry.captureException()
|
@ -1,16 +1,20 @@
|
||||
"""
|
||||
.. module: lemur.endpoints.models
|
||||
:platform: unix
|
||||
:synopsis: This module contains all of the models need to create a authority within Lemur.
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:synopsis: This module contains all of the models need to create an authority within Lemur.
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import arrow
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy import Column, Integer, String, func, DateTime, PassiveDefault, Boolean, ForeignKey
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.sql.expression import case
|
||||
|
||||
from sqlalchemy_utils import ArrowType
|
||||
|
||||
from lemur.database import db
|
||||
|
||||
from lemur.models import policies_ciphers
|
||||
@ -58,13 +62,16 @@ class Endpoint(db.Model):
|
||||
type = Column(String(128))
|
||||
active = Column(Boolean, default=True)
|
||||
port = Column(Integer)
|
||||
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
|
||||
policy_id = Column(Integer, ForeignKey('policy.id'))
|
||||
policy = relationship('Policy', backref='endpoint')
|
||||
certificate_id = Column(Integer, ForeignKey('certificates.id'))
|
||||
source_id = Column(Integer, ForeignKey('sources.id'))
|
||||
sensitive = Column(Boolean, default=False)
|
||||
source = relationship('Source', back_populates='endpoints')
|
||||
last_updated = Column(ArrowType, default=arrow.utcnow, nullable=False)
|
||||
date_created = Column(ArrowType, default=arrow.utcnow, onupdate=arrow.utcnow, nullable=False)
|
||||
|
||||
replaced = association_proxy('certificate', 'replaced')
|
||||
|
||||
@property
|
||||
def issues(self):
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""
|
||||
.. module: lemur.endpoints.schemas
|
||||
:platform: unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
@ -39,5 +39,6 @@ class EndpointOutputSchema(LemurOutputSchema):
|
||||
|
||||
issues = fields.List(fields.Dict())
|
||||
|
||||
|
||||
endpoint_output_schema = EndpointOutputSchema()
|
||||
endpoints_output_schema = EndpointOutputSchema(many=True)
|
||||
|
@ -3,17 +3,20 @@
|
||||
:platform: Unix
|
||||
:synopsis: This module contains all of the services level functions used to
|
||||
administer endpoints in Lemur
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
||||
"""
|
||||
from lemur import database
|
||||
from lemur.extensions import metrics
|
||||
from lemur.endpoints.models import Endpoint, Policy, Cipher
|
||||
import arrow
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from lemur import database
|
||||
from lemur.common.utils import truthiness
|
||||
from lemur.endpoints.models import Endpoint, Policy, Cipher
|
||||
from lemur.extensions import metrics
|
||||
|
||||
|
||||
def get_all():
|
||||
"""
|
||||
@ -36,14 +39,34 @@ def get(endpoint_id):
|
||||
return database.get(Endpoint, endpoint_id)
|
||||
|
||||
|
||||
def get_by_dnsname(endpoint_dnsname):
|
||||
def get_by_name(name):
|
||||
"""
|
||||
Retrieves an endpoint given it's name.
|
||||
|
||||
:param endpoint_dnsname:
|
||||
:param name:
|
||||
:return:
|
||||
"""
|
||||
return database.get(Endpoint, endpoint_dnsname, field='dnsname')
|
||||
return database.get(Endpoint, name, field='name')
|
||||
|
||||
|
||||
def get_by_dnsname(dnsname):
|
||||
"""
|
||||
Retrieves an endpoint given it's name.
|
||||
|
||||
:param dnsname:
|
||||
:return:
|
||||
"""
|
||||
return database.get(Endpoint, dnsname, field='dnsname')
|
||||
|
||||
|
||||
def get_by_dnsname_and_port(dnsname, port):
|
||||
"""
|
||||
Retrieves and endpoint by it's dnsname and port.
|
||||
:param dnsname:
|
||||
:param port:
|
||||
:return:
|
||||
"""
|
||||
return Endpoint.query.filter(Endpoint.dnsname == dnsname).filter(Endpoint.port == port).scalar()
|
||||
|
||||
|
||||
def get_by_source(source_label):
|
||||
@ -55,6 +78,15 @@ def get_by_source(source_label):
|
||||
return Endpoint.query.filter(Endpoint.source.label == source_label).all() # noqa
|
||||
|
||||
|
||||
def get_all_pending_rotation():
|
||||
"""
|
||||
Retrieves all endpoints which have certificates deployed
|
||||
that have been replaced.
|
||||
:return:
|
||||
"""
|
||||
return Endpoint.query.filter(Endpoint.replaced.any()).all()
|
||||
|
||||
|
||||
def create(**kwargs):
|
||||
"""
|
||||
Creates a new endpoint.
|
||||
@ -63,7 +95,7 @@ def create(**kwargs):
|
||||
"""
|
||||
endpoint = Endpoint(**kwargs)
|
||||
database.create(endpoint)
|
||||
metrics.send('endpoint_added', 'counter', 1)
|
||||
metrics.send('endpoint_added', 'counter', 1, metric_tags={'source': endpoint.source.label})
|
||||
return endpoint
|
||||
|
||||
|
||||
@ -92,6 +124,9 @@ def update(endpoint_id, **kwargs):
|
||||
|
||||
endpoint.policy = kwargs['policy']
|
||||
endpoint.certificate = kwargs['certificate']
|
||||
endpoint.source = kwargs['source']
|
||||
endpoint.last_updated = arrow.utcnow()
|
||||
metrics.send('endpoint_updated', 'counter', 1, metric_tags={'source': endpoint.source.label})
|
||||
database.update(endpoint)
|
||||
return endpoint
|
||||
|
||||
@ -108,7 +143,7 @@ def render(args):
|
||||
if filt:
|
||||
terms = filt.split(';')
|
||||
if 'active' in filt: # this is really weird but strcmp seems to not work here??
|
||||
query = query.filter(Endpoint.active == terms[1])
|
||||
query = query.filter(Endpoint.active == truthiness(terms[1]))
|
||||
elif 'port' in filt:
|
||||
if terms[1] != 'null': # ng-table adds 'null' if a number is removed
|
||||
query = query.filter(Endpoint.port == terms[1])
|
||||
|
@ -1,12 +1,12 @@
|
||||
"""
|
||||
.. module: lemur.endpoints.views
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask import Blueprint
|
||||
from flask.ext.restful import reqparse, Api
|
||||
from flask import Blueprint, g
|
||||
from flask_restful import reqparse, Api
|
||||
|
||||
from lemur.common.utils import paginated_parser
|
||||
from lemur.common.schema import validate_schema
|
||||
@ -51,7 +51,7 @@ class EndpointsList(AuthenticatedResource):
|
||||
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: acs or desc
|
||||
:query sortDir: asc or desc
|
||||
:query page: int default is 1
|
||||
:query filter: key value pair. format is k;v
|
||||
:query limit: limit number default is 10
|
||||
@ -63,6 +63,7 @@ class EndpointsList(AuthenticatedResource):
|
||||
"""
|
||||
parser = paginated_parser.copy()
|
||||
args = parser.parse_args()
|
||||
args['user'] = g.current_user
|
||||
return service.render(args)
|
||||
|
||||
|
||||
|
@ -1,14 +1,14 @@
|
||||
"""
|
||||
.. module: lemur.exceptions
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
|
||||
class LemurException(Exception):
|
||||
def __init__(self):
|
||||
current_app.logger.error(self)
|
||||
def __init__(self, *args, **kwargs):
|
||||
current_app.logger.exception(self)
|
||||
|
||||
|
||||
class DuplicateError(LemurException):
|
||||
@ -19,59 +19,26 @@ class DuplicateError(LemurException):
|
||||
return repr("Duplicate found! Could not create: {0}".format(self.key))
|
||||
|
||||
|
||||
class AuthenticationFailedException(LemurException):
|
||||
def __init__(self, remote_ip, user_agent):
|
||||
self.remote_ip = remote_ip
|
||||
self.user_agent = user_agent
|
||||
|
||||
def __str__(self):
|
||||
return repr("Failed login from: {} {}".format(self.remote_ip, self.user_agent))
|
||||
|
||||
|
||||
class IntegrityError(LemurException):
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.message)
|
||||
|
||||
|
||||
class AssociatedObjectNotFound(LemurException):
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.message)
|
||||
|
||||
|
||||
class InvalidListener(LemurException):
|
||||
def __str__(self):
|
||||
return repr("Invalid listener, ensure you select a certificate if you are using a secure protocol")
|
||||
|
||||
|
||||
class CertificateUnavailable(LemurException):
|
||||
def __str__(self):
|
||||
return repr("The certificate requested is not available")
|
||||
|
||||
|
||||
class AttrNotFound(LemurException):
|
||||
def __init__(self, field):
|
||||
self.field = field
|
||||
|
||||
def __str__(self):
|
||||
return repr("The field '{0}' is not sortable".format(self.field))
|
||||
return repr("The field '{0}' is not sortable or filterable".format(self.field))
|
||||
|
||||
|
||||
class NoPersistanceFound(Exception):
|
||||
def __str__(self):
|
||||
return repr("No peristence method found, Lemur cannot persist sensitive information")
|
||||
class InvalidConfiguration(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoEncryptionKeyFound(Exception):
|
||||
def __str__(self):
|
||||
return repr("Aborting... Lemur cannot locate db encryption key, is ENCRYPTION_KEY set?")
|
||||
class InvalidAuthority(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidToken(Exception):
|
||||
def __str__(self):
|
||||
return repr("Invalid token")
|
||||
class UnknownProvider(Exception):
|
||||
pass
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""
|
||||
.. module: lemur.extensions
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
"""
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
@ -13,10 +13,19 @@ from flask_bcrypt import Bcrypt
|
||||
bcrypt = Bcrypt()
|
||||
|
||||
from flask_principal import Principal
|
||||
principal = Principal()
|
||||
principal = Principal(use_sessions=False)
|
||||
|
||||
from flask_mail import Mail
|
||||
smtp_mail = Mail()
|
||||
|
||||
from lemur.metrics import Metrics
|
||||
metrics = Metrics()
|
||||
|
||||
from raven.contrib.flask import Sentry
|
||||
sentry = Sentry()
|
||||
|
||||
from blinker import Namespace
|
||||
signals = Namespace()
|
||||
|
||||
from flask_cors import CORS
|
||||
cors = CORS()
|
||||
|
@ -4,7 +4,7 @@
|
||||
:synopsis: This module contains all the needed functions to allow
|
||||
the factory app creation.
|
||||
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
||||
@ -18,8 +18,10 @@ from logging import Formatter, StreamHandler
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from lemur.certificates.hooks import activate_debug_dump
|
||||
from lemur.common.health import mod as health
|
||||
from lemur.extensions import db, migrate, principal, smtp_mail, metrics
|
||||
from lemur.extensions import db, migrate, principal, smtp_mail, metrics, sentry, cors
|
||||
|
||||
|
||||
DEFAULT_BLUEPRINTS = (
|
||||
@ -73,7 +75,8 @@ def from_file(file_path, silent=False):
|
||||
d.__file__ = file_path
|
||||
try:
|
||||
with open(file_path) as config_file:
|
||||
exec(compile(config_file.read(), file_path, 'exec'), d.__dict__)
|
||||
exec(compile(config_file.read(), # nosec: config file safe
|
||||
file_path, 'exec'), d.__dict__)
|
||||
except IOError as e:
|
||||
if silent and e.errno in (errno.ENOENT, errno.EISDIR):
|
||||
return False
|
||||
@ -92,16 +95,20 @@ def configure_app(app, config=None):
|
||||
"""
|
||||
# respect the config first
|
||||
if config and config != 'None':
|
||||
app.config['CONFIG_PATH'] = config
|
||||
app.config.from_object(from_file(config))
|
||||
else:
|
||||
try:
|
||||
app.config.from_envvar("LEMUR_CONF")
|
||||
except RuntimeError:
|
||||
# look in default paths
|
||||
if os.path.isfile(os.path.expanduser("~/.lemur/lemur.conf.py")):
|
||||
app.config.from_object(from_file(os.path.expanduser("~/.lemur/lemur.conf.py")))
|
||||
else:
|
||||
app.config.from_object(from_file(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'default.conf.py')))
|
||||
|
||||
try:
|
||||
app.config.from_envvar("LEMUR_CONF")
|
||||
except RuntimeError:
|
||||
# look in default paths
|
||||
if os.path.isfile(os.path.expanduser("~/.lemur/lemur.conf.py")):
|
||||
app.config.from_object(from_file(os.path.expanduser("~/.lemur/lemur.conf.py")))
|
||||
else:
|
||||
app.config.from_object(from_file(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'default.conf.py')))
|
||||
# we don't use this
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
|
||||
def configure_extensions(app):
|
||||
@ -116,6 +123,11 @@ def configure_extensions(app):
|
||||
principal.init_app(app)
|
||||
smtp_mail.init_app(app)
|
||||
metrics.init_app(app)
|
||||
sentry.init_app(app)
|
||||
|
||||
if app.config['CORS']:
|
||||
app.config['CORS_HEADERS'] = 'Content-Type'
|
||||
cors.init_app(app, resources=r'/api/*', headers='Content-Type', origin='*', supports_credentials=True)
|
||||
|
||||
|
||||
def configure_blueprints(app, blueprints):
|
||||
@ -148,9 +160,12 @@ def configure_logging(app):
|
||||
app.logger.addHandler(handler)
|
||||
|
||||
stream_handler = StreamHandler()
|
||||
stream_handler.setLevel(app.config.get('LOG_LEVEL'))
|
||||
stream_handler.setLevel(app.config.get('LOG_LEVEL', 'DEBUG'))
|
||||
app.logger.addHandler(stream_handler)
|
||||
|
||||
if app.config.get('DEBUG_DUMP', False):
|
||||
activate_debug_dump()
|
||||
|
||||
|
||||
def install_plugins(app):
|
||||
"""
|
||||
@ -177,8 +192,10 @@ def install_plugins(app):
|
||||
|
||||
# ensure that we have some way to notify
|
||||
with app.app_context():
|
||||
slug = app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification")
|
||||
try:
|
||||
slug = app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification")
|
||||
plugins.get(slug)
|
||||
except KeyError:
|
||||
raise Exception("Unable to location notification plugin: {slug}. Ensure that LEMUR_DEFAULT_NOTIFICATION_PLUGIN is set to a valid and installed notification plugin.".format(slug=slug))
|
||||
raise Exception("Unable to location notification plugin: {slug}. Ensure that "
|
||||
"LEMUR_DEFAULT_NOTIFICATION_PLUGIN is set to a valid and installed notification plugin."
|
||||
.format(slug=slug))
|
||||
|
0
lemur/logs/__init__.py
Normal file
0
lemur/logs/__init__.py
Normal file
23
lemur/logs/models.py
Normal file
23
lemur/logs/models.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""
|
||||
.. module: lemur.logs.models
|
||||
:platform: unix
|
||||
:synopsis: This module contains all of the models related private key audit log.
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, ForeignKey, PassiveDefault, func, Enum
|
||||
|
||||
from sqlalchemy_utils.types.arrow import ArrowType
|
||||
|
||||
from lemur.database import db
|
||||
|
||||
|
||||
class Log(db.Model):
|
||||
__tablename__ = 'logs'
|
||||
id = Column(Integer, primary_key=True)
|
||||
certificate_id = Column(Integer, ForeignKey('certificates.id'))
|
||||
log_type = Column(Enum('key_view', 'create_cert', 'update_cert', 'revoke_cert', name='log_type'), nullable=False)
|
||||
logged_at = Column(ArrowType(), PassiveDefault(func.now()), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
23
lemur/logs/schemas.py
Normal file
23
lemur/logs/schemas.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""
|
||||
.. module: lemur.logs.schemas
|
||||
:platform: unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from marshmallow import fields
|
||||
|
||||
from lemur.common.schema import LemurOutputSchema
|
||||
from lemur.certificates.schemas import CertificateNestedOutputSchema
|
||||
from lemur.users.schemas import UserNestedOutputSchema
|
||||
|
||||
|
||||
class LogOutputSchema(LemurOutputSchema):
|
||||
id = fields.Integer()
|
||||
certificate = fields.Nested(CertificateNestedOutputSchema)
|
||||
user = fields.Nested(UserNestedOutputSchema)
|
||||
logged_at = fields.DateTime()
|
||||
log_type = fields.String()
|
||||
|
||||
|
||||
logs_output_schema = LogOutputSchema(many=True)
|
73
lemur/logs/service.py
Normal file
73
lemur/logs/service.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""
|
||||
.. module: lemur.logs.service
|
||||
:platform: Unix
|
||||
:synopsis: This module contains all of the services level functions used to
|
||||
administer logs in Lemur
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
from lemur import database
|
||||
from lemur.logs.models import Log
|
||||
from lemur.users.models import User
|
||||
from lemur.certificates.models import Certificate
|
||||
|
||||
|
||||
def create(user, type, certificate=None):
|
||||
"""
|
||||
Creates logs a given action.
|
||||
|
||||
:param user:
|
||||
:param type:
|
||||
:param certificate:
|
||||
:return:
|
||||
"""
|
||||
current_app.logger.info("[lemur-audit] action: {0}, user: {1}, certificate: {2}.".format(type, user.email, certificate.name))
|
||||
view = Log(user_id=user.id, log_type=type, certificate_id=certificate.id)
|
||||
database.add(view)
|
||||
database.commit()
|
||||
|
||||
|
||||
def get_all():
|
||||
"""
|
||||
Retrieve all logs from the database.
|
||||
|
||||
:return:
|
||||
"""
|
||||
query = database.session_query(Log)
|
||||
return database.find_all(query, Log, {}).all()
|
||||
|
||||
|
||||
def render(args):
|
||||
"""
|
||||
Helper that paginates and filters data when requested
|
||||
through the REST Api
|
||||
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
query = database.session_query(Log)
|
||||
|
||||
filt = args.pop('filter')
|
||||
|
||||
if filt:
|
||||
terms = filt.split(';')
|
||||
|
||||
if 'certificate.name' in terms:
|
||||
sub_query = database.session_query(Certificate.id)\
|
||||
.filter(Certificate.name.ilike('%{0}%'.format(terms[1])))
|
||||
|
||||
query = query.filter(Log.certificate_id.in_(sub_query))
|
||||
|
||||
elif 'user.email' in terms:
|
||||
sub_query = database.session_query(User.id)\
|
||||
.filter(User.email.ilike('%{0}%'.format(terms[1])))
|
||||
|
||||
query = query.filter(Log.user_id.in_(sub_query))
|
||||
|
||||
else:
|
||||
query = database.filter(query, Log, terms)
|
||||
|
||||
return database.sort_and_page(query, Log, args)
|
74
lemur/logs/views.py
Normal file
74
lemur/logs/views.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""
|
||||
.. module: lemur.logs.views
|
||||
:platform: Unix
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask import Blueprint
|
||||
from flask_restful import reqparse, Api
|
||||
|
||||
from lemur.common.schema import validate_schema
|
||||
from lemur.common.utils import paginated_parser
|
||||
|
||||
from lemur.auth.service import AuthenticatedResource
|
||||
from lemur.logs.schemas import logs_output_schema
|
||||
|
||||
from lemur.logs import service
|
||||
|
||||
|
||||
mod = Blueprint('logs', __name__)
|
||||
api = Api(mod)
|
||||
|
||||
|
||||
class LogsList(AuthenticatedResource):
|
||||
""" Defines the 'logs' endpoint """
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(LogsList, self).__init__()
|
||||
|
||||
@validate_schema(None, logs_output_schema)
|
||||
def get(self):
|
||||
"""
|
||||
.. http:get:: /logs
|
||||
|
||||
The current log list
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /logs HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"items": [
|
||||
]
|
||||
"total": 2
|
||||
}
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: asc or desc
|
||||
:query page: int default is 1
|
||||
:query filter: key value pair format is k;v
|
||||
:query count: count number default is 10
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
"""
|
||||
parser = paginated_parser.copy()
|
||||
parser.add_argument('owner', type=str, location='args')
|
||||
parser.add_argument('id', type=str, location='args')
|
||||
args = parser.parse_args()
|
||||
return service.render(args)
|
||||
|
||||
|
||||
api.add_resource(LogsList, '/logs', endpoint='logs')
|
602
lemur/manage.py
602
lemur/manage.py
@ -1,39 +1,35 @@
|
||||
from __future__ import unicode_literals # at top of module
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from collections import Counter
|
||||
|
||||
import os
|
||||
import sys
|
||||
import base64
|
||||
import time
|
||||
import requests
|
||||
import json
|
||||
|
||||
from tabulate import tabulate
|
||||
from gunicorn.config import make_settings
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from flask import current_app
|
||||
from flask.ext.script import Manager, Command, Option, prompt_pass
|
||||
from flask.ext.migrate import Migrate, MigrateCommand, stamp
|
||||
from flask_script import Manager, Command, Option, prompt_pass
|
||||
from flask_migrate import Migrate, MigrateCommand, stamp
|
||||
from flask_script.commands import ShowUrls, Clean, Server
|
||||
|
||||
from lemur.sources.cli import manager as source_manager
|
||||
from lemur.policies.cli import manager as policy_manager
|
||||
from lemur.reporting.cli import manager as report_manager
|
||||
from lemur.endpoints.cli import manager as endpoint_manager
|
||||
from lemur.certificates.cli import manager as certificate_manager
|
||||
from lemur.notifications.cli import manager as notification_manager
|
||||
from lemur.pending_certificates.cli import manager as pending_certificate_manager
|
||||
|
||||
from lemur import database
|
||||
from lemur.extensions import metrics
|
||||
from lemur.users import service as user_service
|
||||
from lemur.roles import service as role_service
|
||||
from lemur.certificates import service as cert_service
|
||||
from lemur.authorities import service as authority_service
|
||||
from lemur.policies import service as policy_service
|
||||
from lemur.notifications import service as notification_service
|
||||
|
||||
from lemur.certificates.service import get_name_from_arn
|
||||
from lemur.certificates.verify import verify_string
|
||||
|
||||
from lemur.plugins.lemur_aws import elb
|
||||
|
||||
from lemur.sources import service as source_service
|
||||
from lemur.common.utils import validate_conf
|
||||
|
||||
from lemur import create_app
|
||||
|
||||
@ -46,6 +42,10 @@ from lemur.destinations.models import Destination # noqa
|
||||
from lemur.domains.models import Domain # noqa
|
||||
from lemur.notifications.models import Notification # noqa
|
||||
from lemur.sources.models import Source # noqa
|
||||
from lemur.logs.models import Log # noqa
|
||||
from lemur.endpoints.models import Endpoint # noqa
|
||||
from lemur.policies.models import RotationPolicy # noqa
|
||||
from lemur.pending_certificates.models import PendingCertificate # noqa
|
||||
|
||||
|
||||
manager = Manager(create_app)
|
||||
@ -53,12 +53,21 @@ manager.add_option('-c', '--config', dest='config')
|
||||
|
||||
migrate = Migrate(create_app)
|
||||
|
||||
REQUIRED_VARIABLES = [
|
||||
'LEMUR_SECURITY_TEAM_EMAIL',
|
||||
'LEMUR_DEFAULT_ORGANIZATIONAL_UNIT',
|
||||
'LEMUR_DEFAULT_ORGANIZATION',
|
||||
'LEMUR_DEFAULT_LOCATION',
|
||||
'LEMUR_DEFAULT_COUNTRY',
|
||||
'LEMUR_DEFAULT_STATE',
|
||||
'SQLALCHEMY_DATABASE_URI'
|
||||
]
|
||||
|
||||
KEY_LENGTH = 40
|
||||
DEFAULT_CONFIG_PATH = '~/.lemur/lemur.conf.py'
|
||||
DEFAULT_SETTINGS = 'lemur.conf.server'
|
||||
SETTINGS_ENVVAR = 'LEMUR_CONF'
|
||||
|
||||
|
||||
CONFIG_TEMPLATE = """
|
||||
# This is just Python which means you can inherit and tweak settings
|
||||
|
||||
@ -80,8 +89,8 @@ SECRET_KEY = '{flask_secret_key}'
|
||||
LEMUR_TOKEN_SECRET = '{secret_token}'
|
||||
LEMUR_ENCRYPTION_KEYS = '{encryption_key}'
|
||||
|
||||
# this is a list of domains as regexes that only admins can issue
|
||||
LEMUR_RESTRICTED_DOMAINS = []
|
||||
# List of domain regular expressions that non-admin users can issue
|
||||
LEMUR_WHITELISTED_DOMAINS = []
|
||||
|
||||
# Mail Server
|
||||
|
||||
@ -99,6 +108,9 @@ LEMUR_DEFAULT_ORGANIZATIONAL_UNIT = ''
|
||||
# Authentication Providers
|
||||
ACTIVE_PROVIDERS = []
|
||||
|
||||
# Metrics Providers
|
||||
METRIC_PROVIDERS = []
|
||||
|
||||
# Logging
|
||||
|
||||
LOG_LEVEL = "DEBUG"
|
||||
@ -110,7 +122,6 @@ LOG_FILE = "lemur.log"
|
||||
# modify this if you are not using a local database
|
||||
SQLALCHEMY_DATABASE_URI = 'postgresql://lemur:lemur@localhost:5432/lemur'
|
||||
|
||||
|
||||
# AWS
|
||||
|
||||
#LEMUR_INSTANCE_PROFILE = 'Lemur'
|
||||
@ -139,28 +150,6 @@ def drop_all():
|
||||
database.db.drop_all()
|
||||
|
||||
|
||||
@manager.command
|
||||
def check_revoked():
|
||||
"""
|
||||
Function attempts to update Lemur's internal cache with revoked
|
||||
certificates. This is called periodically by Lemur. It checks both
|
||||
CRLs and OCSP to see if a certificate is revoked. If Lemur is unable
|
||||
encounters an issue with verification it marks the certificate status
|
||||
as `unknown`.
|
||||
"""
|
||||
for cert in cert_service.get_all_certs():
|
||||
try:
|
||||
if cert.chain:
|
||||
status = verify_string(cert.body, cert.chain)
|
||||
else:
|
||||
status = verify_string(cert.body, "")
|
||||
|
||||
cert.status = 'valid' if status else 'invalid'
|
||||
except Exception as e:
|
||||
cert.status = 'unknown'
|
||||
database.update(cert)
|
||||
|
||||
|
||||
@manager.shell
|
||||
def make_shell_context():
|
||||
"""
|
||||
@ -181,31 +170,14 @@ def generate_settings():
|
||||
output = CONFIG_TEMPLATE.format(
|
||||
# we use Fernet.generate_key to make sure that the key length is
|
||||
# compatible with Fernet
|
||||
encryption_key=Fernet.generate_key(),
|
||||
secret_token=base64.b64encode(os.urandom(KEY_LENGTH)),
|
||||
flask_secret_key=base64.b64encode(os.urandom(KEY_LENGTH)),
|
||||
encryption_key=Fernet.generate_key().decode('utf-8'),
|
||||
secret_token=base64.b64encode(os.urandom(KEY_LENGTH)).decode('utf-8'),
|
||||
flask_secret_key=base64.b64encode(os.urandom(KEY_LENGTH)).decode('utf-8'),
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@manager.command
|
||||
def notify():
|
||||
"""
|
||||
Runs Lemur's notification engine, that looks for expired certificates and sends
|
||||
notifications out to those that bave subscribed to them.
|
||||
|
||||
:return:
|
||||
"""
|
||||
sys.stdout.write("Starting to notify subscribers about expiring certificates!\n")
|
||||
count = notification_service.send_expiration_notifications()
|
||||
sys.stdout.write(
|
||||
"Finished notifying subscribers about expiring certificates! Sent {count} notifications!\n".format(
|
||||
count=count
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class InitializeApp(Command):
|
||||
"""
|
||||
This command will bootstrap our database with any destinations as
|
||||
@ -222,6 +194,33 @@ class InitializeApp(Command):
|
||||
create()
|
||||
user = user_service.get_by_username("lemur")
|
||||
|
||||
admin_role = role_service.get_by_name('admin')
|
||||
|
||||
if admin_role:
|
||||
sys.stdout.write("[-] Admin role already created, skipping...!\n")
|
||||
else:
|
||||
# we create an admin role
|
||||
admin_role = role_service.create('admin', description='This is the Lemur administrator role.')
|
||||
sys.stdout.write("[+] Created 'admin' role\n")
|
||||
|
||||
operator_role = role_service.get_by_name('operator')
|
||||
|
||||
if operator_role:
|
||||
sys.stdout.write("[-] Operator role already created, skipping...!\n")
|
||||
else:
|
||||
# we create an operator role
|
||||
operator_role = role_service.create('operator', description='This is the Lemur operator role.')
|
||||
sys.stdout.write("[+] Created 'operator' role\n")
|
||||
|
||||
read_only_role = role_service.get_by_name('read-only')
|
||||
|
||||
if read_only_role:
|
||||
sys.stdout.write("[-] Read only role already created, skipping...!\n")
|
||||
else:
|
||||
# we create an read only role
|
||||
read_only_role = role_service.create('read-only', description='This is the Lemur read only role.')
|
||||
sys.stdout.write("[+] Created 'read-only' role\n")
|
||||
|
||||
if not user:
|
||||
if not password:
|
||||
sys.stdout.write("We need to set Lemur's password to continue!\n")
|
||||
@ -232,24 +231,12 @@ class InitializeApp(Command):
|
||||
sys.stderr.write("[!] Passwords do not match!\n")
|
||||
sys.exit(1)
|
||||
|
||||
role = role_service.get_by_name('admin')
|
||||
|
||||
if role:
|
||||
sys.stdout.write("[-] Admin role already created, skipping...!\n")
|
||||
else:
|
||||
# we create an admin role
|
||||
role = role_service.create('admin', description='this is the lemur administrator role')
|
||||
sys.stdout.write("[+] Created 'admin' role\n")
|
||||
|
||||
user_service.create("lemur", password, 'lemur@nobody', True, None, [role])
|
||||
sys.stdout.write("[+] Added a 'lemur' user and added it to the 'admin' role!\n")
|
||||
user_service.create("lemur", password, 'lemur@nobody.com', True, None, [admin_role])
|
||||
sys.stdout.write("[+] Created the user 'lemur' and granted it the 'admin' role!\n")
|
||||
|
||||
else:
|
||||
sys.stdout.write("[-] Default user has already been created, skipping...!\n")
|
||||
|
||||
sys.stdout.write("[+] Creating expiration email notifications!\n")
|
||||
sys.stdout.write("[!] Using {0} as specified by LEMUR_SECURITY_TEAM_EMAIL for notifications\n".format("LEMUR_SECURITY_TEAM_EMAIL"))
|
||||
|
||||
intervals = current_app.config.get("LEMUR_DEFAULT_EXPIRATION_NOTIFICATION_INTERVALS", [])
|
||||
sys.stdout.write(
|
||||
"[!] Creating {num} notifications for {intervals} days as specified by LEMUR_DEFAULT_EXPIRATION_NOTIFICATION_INTERVALS\n".format(
|
||||
@ -259,8 +246,21 @@ class InitializeApp(Command):
|
||||
)
|
||||
|
||||
recipients = current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL')
|
||||
sys.stdout.write("[+] Creating expiration email notifications!\n")
|
||||
sys.stdout.write("[!] Using {0} as specified by LEMUR_SECURITY_TEAM_EMAIL for notifications\n".format(recipients))
|
||||
notification_service.create_default_expiration_notifications("DEFAULT_SECURITY", recipients=recipients)
|
||||
|
||||
_DEFAULT_ROTATION_INTERVAL = 'default'
|
||||
default_rotation_interval = policy_service.get_by_name(_DEFAULT_ROTATION_INTERVAL)
|
||||
|
||||
if default_rotation_interval:
|
||||
sys.stdout.write("[-] Default rotation interval policy already created, skipping...!\n")
|
||||
else:
|
||||
days = current_app.config.get("LEMUR_DEFAULT_ROTATION_INTERVAL", 30)
|
||||
sys.stdout.write("[+] Creating default certificate rotation policy of {days} days before issuance.\n".format(
|
||||
days=days))
|
||||
policy_service.create(days=days, name=_DEFAULT_ROTATION_INTERVAL)
|
||||
|
||||
sys.stdout.write("[/] Done!\n")
|
||||
|
||||
|
||||
@ -366,10 +366,16 @@ class LemurServer(Command):
|
||||
|
||||
def get_options(self):
|
||||
settings = make_settings()
|
||||
options = (
|
||||
Option(*klass.cli, action=klass.action)
|
||||
for setting, klass in settings.items() if klass.cli
|
||||
)
|
||||
options = []
|
||||
for setting, klass in settings.items():
|
||||
if klass.cli:
|
||||
if klass.action:
|
||||
if klass.action == 'store_const':
|
||||
options.append(Option(*klass.cli, const=klass.const, action=klass.action))
|
||||
else:
|
||||
options.append(Option(*klass.cli, action=klass.action))
|
||||
else:
|
||||
options.append(Option(*klass.cli))
|
||||
|
||||
return options
|
||||
|
||||
@ -377,7 +383,11 @@ class LemurServer(Command):
|
||||
from gunicorn.app.wsgiapp import WSGIApplication
|
||||
|
||||
app = WSGIApplication()
|
||||
app.app_uri = 'lemur:create_app(config="{0}")'.format(kwargs.get('config'))
|
||||
|
||||
# run startup tasks on an app like object
|
||||
validate_conf(current_app, REQUIRED_VARIABLES)
|
||||
|
||||
app.app_uri = 'lemur:create_app(config="{0}")'.format(current_app.config.get('CONFIG_PATH'))
|
||||
|
||||
return app.run()
|
||||
|
||||
@ -392,6 +402,7 @@ def create_config(config_path=None):
|
||||
|
||||
config_path = os.path.expanduser(config_path)
|
||||
dir = os.path.dirname(config_path)
|
||||
|
||||
if not os.path.exists(dir):
|
||||
os.makedirs(dir)
|
||||
|
||||
@ -479,263 +490,6 @@ def unlock(path=None):
|
||||
sys.stdout.write("[+] Keys have been unencrypted!\n")
|
||||
|
||||
|
||||
def unicode_(data):
|
||||
import sys
|
||||
|
||||
if sys.version_info.major < 3:
|
||||
return data.decode('UTF-8')
|
||||
return data
|
||||
|
||||
|
||||
class RotateELBs(Command):
|
||||
"""
|
||||
Rotates existing certificates to a new one on an ELB
|
||||
"""
|
||||
option_list = (
|
||||
Option('-e', '--elb-list', dest='elb_list', required=True),
|
||||
Option('-p', '--chain-path', dest='chain_path'),
|
||||
Option('-c', '--cert-name', dest='cert_name'),
|
||||
Option('-a', '--cert-prefix', dest='cert_prefix'),
|
||||
Option('-d', '--description', dest='description')
|
||||
)
|
||||
|
||||
def run(self, elb_list, chain_path, cert_name, cert_prefix, description):
|
||||
|
||||
for e in open(elb_list, 'r').readlines():
|
||||
elb_name, account_id, region, from_port, to_port, protocol = e.strip().split(',')
|
||||
|
||||
if cert_name:
|
||||
arn = "arn:aws:iam::{0}:server-certificate/{1}".format(account_id, cert_name)
|
||||
|
||||
else:
|
||||
# if no cert name is provided we need to discover it
|
||||
listeners = elb.get_listeners(account_id, region, elb_name)
|
||||
|
||||
# get the listener we care about
|
||||
for listener in listeners:
|
||||
if listener[0] == int(from_port) and listener[1] == int(to_port):
|
||||
arn = listener[4]
|
||||
name = get_name_from_arn(arn)
|
||||
certificate = cert_service.get_by_name(name)
|
||||
break
|
||||
else:
|
||||
sys.stdout.write("[-] Could not find ELB {0}".format(elb_name))
|
||||
continue
|
||||
|
||||
if not certificate:
|
||||
sys.stdout.write("[-] Could not find certificate {0} in Lemur".format(name))
|
||||
continue
|
||||
|
||||
dests = []
|
||||
for d in certificate.destinations:
|
||||
dests.append({'id': d.id})
|
||||
|
||||
nots = []
|
||||
for n in certificate.notifications:
|
||||
nots.append({'id': n.id})
|
||||
|
||||
new_certificate = database.clone(certificate)
|
||||
|
||||
if cert_prefix:
|
||||
new_certificate.name = "{0}-{1}".format(cert_prefix, new_certificate.name)
|
||||
|
||||
new_certificate.chain = open(chain_path, 'r').read()
|
||||
new_certificate.description = "{0} - {1}".format(new_certificate.description, description)
|
||||
|
||||
new_certificate = database.create(new_certificate)
|
||||
database.update_list(new_certificate, 'destinations', Destination, dests)
|
||||
database.update_list(new_certificate, 'notifications', Notification, nots)
|
||||
database.update(new_certificate)
|
||||
|
||||
arn = new_certificate.get_arn(account_id)
|
||||
|
||||
elb.update_listeners(account_id, region, elb_name, [(from_port, to_port, protocol, arn)], [from_port])
|
||||
|
||||
sys.stdout.write("[+] Updated {0} to use {1}\n".format(elb_name, new_certificate.name))
|
||||
|
||||
|
||||
class ProvisionELB(Command):
|
||||
"""
|
||||
Creates and provisions a certificate on an ELB based on command line arguments
|
||||
"""
|
||||
option_list = (
|
||||
Option('-d', '--dns', dest='dns', action='append', required=True, type=unicode_),
|
||||
Option('-e', '--elb', dest='elb_name', required=True, type=unicode_),
|
||||
Option('-o', '--owner', dest='owner', type=unicode_),
|
||||
Option('-a', '--authority', dest='authority', required=True, type=unicode_),
|
||||
Option('-s', '--description', dest='description', default=u'Command line provisioned keypair', type=unicode_),
|
||||
Option('-t', '--destination', dest='destinations', action='append', type=unicode_, required=True),
|
||||
Option('-n', '--notification', dest='notifications', action='append', type=unicode_, default=[]),
|
||||
Option('-r', '--region', dest='region', default=u'us-east-1', type=unicode_),
|
||||
Option('-p', '--dport', '--port', dest='dport', default=7002),
|
||||
Option('--src-port', '--source-port', '--sport', dest='sport', default=443),
|
||||
Option('--dry-run', dest='dryrun', action='store_true')
|
||||
)
|
||||
|
||||
def configure_user(self, owner):
|
||||
from flask import g
|
||||
import lemur.users.service
|
||||
|
||||
# grab the user
|
||||
g.user = lemur.users.service.get_by_username(owner)
|
||||
# get the first user by default
|
||||
if not g.user:
|
||||
g.user = lemur.users.service.get_all()[0]
|
||||
|
||||
return g.user.username
|
||||
|
||||
def build_cert_options(self, destinations, notifications, description, owner, dns, authority):
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from lemur.certificates.views import valid_authority
|
||||
import sys
|
||||
|
||||
# convert argument lists to arrays, or empty sets
|
||||
destinations = self.get_destinations(destinations)
|
||||
if not destinations:
|
||||
sys.stderr.write("Valid destinations provided\n")
|
||||
sys.exit(1)
|
||||
|
||||
# get the primary CN
|
||||
common_name = dns[0]
|
||||
|
||||
# If there are more than one fqdn, add them as alternate names
|
||||
extensions = {}
|
||||
if len(dns) > 1:
|
||||
extensions['subAltNames'] = {'names': map(lambda x: {'nameType': 'DNSName', 'value': x}, dns)}
|
||||
|
||||
try:
|
||||
authority = valid_authority({"name": authority})
|
||||
except NoResultFound:
|
||||
sys.stderr.write("Invalid authority specified: '{}'\naborting\n".format(authority))
|
||||
sys.exit(1)
|
||||
|
||||
options = {
|
||||
# Convert from the Destination model to the JSON input expected further in the code
|
||||
'destinations': map(lambda x: {'id': x.id, 'label': x.label}, destinations),
|
||||
'description': description,
|
||||
'notifications': notifications,
|
||||
'commonName': common_name,
|
||||
'extensions': extensions,
|
||||
'authority': authority,
|
||||
'owner': owner,
|
||||
# defaults:
|
||||
'organization': current_app.config.get('LEMUR_DEFAULT_ORGANIZATION'),
|
||||
'organizationalUnit': current_app.config.get('LEMUR_DEFAULT_ORGANIZATIONAL_UNIT'),
|
||||
'country': current_app.config.get('LEMUR_DEFAULT_COUNTRY'),
|
||||
'state': current_app.config.get('LEMUR_DEFAULT_STATE'),
|
||||
'location': current_app.config.get('LEMUR_DEFAULT_LOCATION')
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
def get_destinations(self, destination_names):
|
||||
from lemur.destinations import service
|
||||
|
||||
destinations = []
|
||||
|
||||
for destination_name in destination_names:
|
||||
destination = service.get_by_label(destination_name)
|
||||
|
||||
if not destination:
|
||||
sys.stderr.write("Invalid destination specified: '{}'\nAborting...\n".format(destination_name))
|
||||
sys.exit(1)
|
||||
|
||||
destinations.append(service.get_by_label(destination_name))
|
||||
|
||||
return destinations
|
||||
|
||||
def check_duplicate_listener(self, elb_name, region, account, sport, dport):
|
||||
from lemur.plugins.lemur_aws import elb
|
||||
|
||||
listeners = elb.get_listeners(account, region, elb_name)
|
||||
for listener in listeners:
|
||||
if listener[0] == sport and listener[1] == dport:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_destination_account(self, destinations):
|
||||
for destination in self.get_destinations(destinations):
|
||||
if destination.plugin_name == 'aws-destination':
|
||||
|
||||
account_number = destination.plugin.get_option('accountNumber', destination.options)
|
||||
return account_number
|
||||
|
||||
sys.stderr.write("No destination AWS account provided, failing\n")
|
||||
sys.exit(1)
|
||||
|
||||
def run(self, dns, elb_name, owner, authority, description, notifications, destinations, region, dport, sport,
|
||||
dryrun):
|
||||
from lemur.certificates import service
|
||||
from lemur.plugins.lemur_aws import elb
|
||||
from boto.exception import BotoServerError
|
||||
|
||||
# configure the owner if we can find it, or go for default, and put it in the global
|
||||
owner = self.configure_user(owner)
|
||||
|
||||
# make a config blob from the command line arguments
|
||||
cert_options = self.build_cert_options(
|
||||
destinations=destinations,
|
||||
notifications=notifications,
|
||||
description=description,
|
||||
owner=owner,
|
||||
dns=dns,
|
||||
authority=authority)
|
||||
|
||||
aws_account = self.get_destination_account(destinations)
|
||||
|
||||
if dryrun:
|
||||
import json
|
||||
|
||||
cert_options['authority'] = cert_options['authority'].name
|
||||
sys.stdout.write('Will create certificate using options: {}\n'
|
||||
.format(json.dumps(cert_options, sort_keys=True, indent=2)))
|
||||
sys.stdout.write('Will create listener {}->{} HTTPS using the new certificate to elb {}\n'
|
||||
.format(sport, dport, elb_name))
|
||||
sys.exit(0)
|
||||
|
||||
if self.check_duplicate_listener(elb_name, region, aws_account, sport, dport):
|
||||
sys.stderr.write("ELB {} already has a listener {}->{}\nAborting...\n".format(elb_name, sport, dport))
|
||||
sys.exit(1)
|
||||
|
||||
# create the certificate
|
||||
try:
|
||||
sys.stdout.write('Creating certificate for {}\n'.format(cert_options['commonName']))
|
||||
cert = service.create(**cert_options)
|
||||
except Exception as e:
|
||||
if e.message == 'Duplicate certificate: a certificate with the same common name exists already':
|
||||
sys.stderr.write("Certificate already exists named: {}\n".format(dns[0]))
|
||||
sys.exit(1)
|
||||
raise e
|
||||
|
||||
cert_arn = cert.get_arn(aws_account)
|
||||
sys.stderr.write('cert arn: {}\n'.format(cert_arn))
|
||||
|
||||
sys.stderr.write('Configuring elb {} from port {} to port {} in region {} with cert {}\n'
|
||||
.format(elb_name, sport, dport, region, cert_arn))
|
||||
|
||||
delay = 1
|
||||
done = False
|
||||
retries = 5
|
||||
while not done and retries > 0:
|
||||
try:
|
||||
elb.create_new_listeners(aws_account, region, elb_name, [(sport, dport, 'HTTPS', cert_arn)])
|
||||
except BotoServerError as bse:
|
||||
# if the server returns ad error, the certificate
|
||||
if bse.error_code == 'CertificateNotFound':
|
||||
sys.stderr.write('Certificate not available yet in the AWS account, waiting {}, {} retries left\n'
|
||||
.format(delay, retries))
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
retries -= 1
|
||||
elif bse.error_code == 'DuplicateListener':
|
||||
sys.stderr.write('ELB {} already has a listener {}->{}'.format(elb_name, sport, dport))
|
||||
sys.exit(1)
|
||||
else:
|
||||
raise bse
|
||||
else:
|
||||
done = True
|
||||
|
||||
|
||||
@manager.command
|
||||
def publish_verisign_units():
|
||||
"""
|
||||
@ -768,166 +522,6 @@ def publish_verisign_units():
|
||||
requests.post('http://localhost:8078/metrics', data=json.dumps(metric))
|
||||
|
||||
|
||||
@manager.command
|
||||
def publish_unapproved_verisign_certificates():
|
||||
"""
|
||||
Query the Verisign for any certificates that need to be approved.
|
||||
:return:
|
||||
"""
|
||||
from lemur.plugins import plugins
|
||||
from lemur.extensions import metrics
|
||||
v = plugins.get('verisign-issuer')
|
||||
certs = v.get_pending_certificates()
|
||||
metrics.send('pending_certificates', 'gauge', certs)
|
||||
|
||||
|
||||
class Report(Command):
|
||||
"""
|
||||
Defines a set of reports to be run periodically against Lemur.
|
||||
"""
|
||||
option_list = (
|
||||
Option('-n', '--name', dest='name', default=None, help='Name of the report to run.'),
|
||||
Option('-d', '--duration', dest='duration', default=356, help='Number of days to run the report'),
|
||||
)
|
||||
|
||||
def run(self, name, duration):
|
||||
|
||||
end = datetime.utcnow()
|
||||
start = end - timedelta(days=duration)
|
||||
self.certificates_issued(name, start, end)
|
||||
|
||||
@staticmethod
|
||||
def certificates_issued(name=None, start=None, end=None):
|
||||
"""
|
||||
Generates simple report of number of certificates issued by the authority, if no authority
|
||||
is specified report on total number of certificates.
|
||||
|
||||
:param name:
|
||||
:param start:
|
||||
:param end:
|
||||
:return:
|
||||
"""
|
||||
|
||||
def _calculate_row(authority):
|
||||
day_cnt = Counter()
|
||||
month_cnt = Counter()
|
||||
year_cnt = Counter()
|
||||
|
||||
for cert in authority.certificates:
|
||||
date = cert.date_created.date()
|
||||
day_cnt[date.day] += 1
|
||||
month_cnt[date.month] += 1
|
||||
year_cnt[date.year] += 1
|
||||
|
||||
try:
|
||||
day_avg = int(sum(day_cnt.values()) / len(day_cnt.keys()))
|
||||
except ZeroDivisionError:
|
||||
day_avg = 0
|
||||
|
||||
try:
|
||||
month_avg = int(sum(month_cnt.values()) / len(month_cnt.keys()))
|
||||
except ZeroDivisionError:
|
||||
month_avg = 0
|
||||
|
||||
try:
|
||||
year_avg = int(sum(year_cnt.values()) / len(year_cnt.keys()))
|
||||
except ZeroDivisionError:
|
||||
year_avg = 0
|
||||
|
||||
return [authority.name, authority.description, day_avg, month_avg, year_avg]
|
||||
|
||||
rows = []
|
||||
if not name:
|
||||
for authority in authority_service.get_all():
|
||||
rows.append(_calculate_row(authority))
|
||||
|
||||
else:
|
||||
authority = authority_service.get_by_name(name)
|
||||
|
||||
if not authority:
|
||||
sys.stderr.write('[!] Authority {0} was not found.'.format(name))
|
||||
sys.exit(1)
|
||||
|
||||
rows.append(_calculate_row(authority))
|
||||
|
||||
sys.stdout.write(tabulate(rows, headers=["Authority Name", "Description", "Daily Average", "Monthy Average", "Yearly Average"]) + "\n")
|
||||
|
||||
|
||||
class Sources(Command):
|
||||
"""
|
||||
Defines a set of actions to take against Lemur's sources.
|
||||
"""
|
||||
option_list = (
|
||||
Option('-s', '--sources', dest='source_strings', action='append', help='Sources to operate on.'),
|
||||
Option('-a', '--action', choices=['sync', 'clean'], dest='action', help='Action to take on source.')
|
||||
)
|
||||
|
||||
def run(self, source_strings, action):
|
||||
sources = []
|
||||
if not source_strings:
|
||||
table = []
|
||||
for source in source_service.get_all():
|
||||
table.append([source.label, source.active, source.description])
|
||||
|
||||
sys.stdout.write(tabulate(table, headers=['Label', 'Active', 'Description']))
|
||||
sys.exit(1)
|
||||
|
||||
elif 'all' in source_strings:
|
||||
sources = source_service.get_all()
|
||||
|
||||
else:
|
||||
for source_str in source_strings:
|
||||
source = source_service.get_by_label(source_str)
|
||||
|
||||
if not source:
|
||||
sys.stderr.write("Unable to find specified source with label: {0}".format(source_str))
|
||||
|
||||
sources.append(source)
|
||||
|
||||
for source in sources:
|
||||
if action == 'sync':
|
||||
self.sync(source)
|
||||
|
||||
if action == 'clean':
|
||||
self.clean(source)
|
||||
|
||||
@staticmethod
|
||||
def sync(source):
|
||||
start_time = time.time()
|
||||
sys.stdout.write("[+] Staring to sync source: {label}!\n".format(label=source.label))
|
||||
|
||||
user = user_service.get_by_username('lemur')
|
||||
|
||||
try:
|
||||
source_service.sync(source, user)
|
||||
sys.stdout.write(
|
||||
"[+] Finished syncing source: {label}. Run Time: {time}\n".format(
|
||||
label=source.label,
|
||||
time=(time.time() - start_time)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.exception(e)
|
||||
|
||||
sys.stdout.write(
|
||||
"[X] Failed syncing source {label}!\n".format(label=source.label)
|
||||
)
|
||||
|
||||
metrics.send('sync_failed', 'counter', 1, metric_tags={'source': source.label})
|
||||
|
||||
@staticmethod
|
||||
def clean(source):
|
||||
start_time = time.time()
|
||||
sys.stdout.write("[+] Staring to clean source: {label}!\n".format(label=source.label))
|
||||
source_service.clean(source)
|
||||
sys.stdout.write(
|
||||
"[+] Finished cleaning source: {label}. Run Time: {time}\n".format(
|
||||
label=source.label,
|
||||
time=(time.time() - start_time)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
manager.add_command("start", LemurServer())
|
||||
manager.add_command("runserver", Server(host='127.0.0.1', threaded=True))
|
||||
@ -938,11 +532,15 @@ def main():
|
||||
manager.add_command("create_user", CreateUser())
|
||||
manager.add_command("reset_password", ResetPassword())
|
||||
manager.add_command("create_role", CreateRole())
|
||||
manager.add_command("provision_elb", ProvisionELB())
|
||||
manager.add_command("rotate_elbs", RotateELBs())
|
||||
manager.add_command("sources", Sources())
|
||||
manager.add_command("report", Report())
|
||||
manager.add_command("source", source_manager)
|
||||
manager.add_command("certificate", certificate_manager)
|
||||
manager.add_command("notify", notification_manager)
|
||||
manager.add_command("endpoint", endpoint_manager)
|
||||
manager.add_command("report", report_manager)
|
||||
manager.add_command("policy", policy_manager)
|
||||
manager.add_command("pending_certs", pending_certificate_manager)
|
||||
manager.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""
|
||||
.. module: lemur.metrics
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
"""
|
||||
from flask import current_app
|
||||
|
@ -3,6 +3,9 @@ from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from logging.config import fileConfig
|
||||
|
||||
import alembic_autogenerate_enums
|
||||
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
28
lemur/migrations/versions/131ec6accff5_.py
Normal file
28
lemur/migrations/versions/131ec6accff5_.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Ensuring we have endpoint updated times and certificate rotation availability.
|
||||
|
||||
Revision ID: 131ec6accff5
|
||||
Revises: e3691fc396e9
|
||||
Create Date: 2016-12-07 17:29:42.049986
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '131ec6accff5'
|
||||
down_revision = 'e3691fc396e9'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('certificates', sa.Column('rotation', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
op.add_column('endpoints', sa.Column('last_updated', sa.DateTime(), server_default=sa.text('now()'), nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('endpoints', 'last_updated')
|
||||
op.drop_column('certificates', 'rotation')
|
||||
# ### end Alembic commands ###
|
21
lemur/migrations/versions/1ae8e3104db8_.py
Normal file
21
lemur/migrations/versions/1ae8e3104db8_.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Adds additional ENUM for creating and updating certificates.
|
||||
|
||||
Revision ID: 1ae8e3104db8
|
||||
Revises: a02a678ddc25
|
||||
Create Date: 2017-07-13 12:32:09.162800
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1ae8e3104db8'
|
||||
down_revision = 'a02a678ddc25'
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.sync_enum_values('public', 'log_type', ['key_view'], ['create_cert', 'key_view', 'update_cert'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.sync_enum_values('public', 'log_type', ['create_cert', 'key_view', 'update_cert'], ['key_view'])
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user