Compare commits

...

200 Commits
0.5.0 ... 0.6.0

Author SHA1 Message Date
64ac32f683 6.0 release. (#1033) 2018-01-02 14:03:38 -08:00
1287c3dc4a CRL verify: handle "Remove from CRL" status as not revoked (#1028)
Per RFC 5280 section 6.3.3 (k):
https://tools.ietf.org/html/rfc5280#section-6.3.3
2018-01-02 13:39:02 -08:00
9d7fc9db8c [Doppins] Upgrade dependency boto3 to ==1.5.7 (#1024) 2018-01-02 13:11:44 -08:00
99b10c436a CRL verify: skip unknown URI schemes like ldap:// and add unit tests (#1027) 2018-01-02 13:11:17 -08:00
bb54085c20 Downgrading library again. (#1032) 2018-01-02 12:36:45 -08:00
9a0ada75fa Upgrading satellizer library. (#1031) 2018-01-02 09:12:06 -08:00
848ce8c978 Refactoring authentincation to support GET and POST requests. Closes #990. (#1030) 2018-01-01 19:11:29 -08:00
7b8df16c9e Fix typo in default SSH key path. (#1026) 2017-12-20 09:09:56 -08:00
7a84f38db9 Don't write files from the test suite (#1020)
The lemur_email.tests.test_render test would fail when running unittests
from a read-only source tree.
2017-12-12 10:14:39 -08:00
ba4de07ad8 Improve certificate details view, make information more concise (#1021)
The "Description" field can now display multi-line text content.

The "Authority" field now displays the authority name in Lemur (if
known) as well as issuer's name. For imported certs, "Imported" is
displayed.
2017-12-12 09:49:30 -08:00
b2d87940d6 Allow sorting and filtering by camelCase field names (#1019)
The API exposes camelCase field names everywhere, but only accepted
underscore_field_names in 'filter' or 'sort' GET attributes. Now both
are allowed.
2017-12-12 09:44:53 -08:00
6edc5180c7 fix roles assigned in the ui for sso (#1017)
This commit fixes the ability to assign roles to people in the ui
when the user is SSO. The idea is if a role is ever assigned via
SSO it becomes a "SSO Role" or a "Third Party" Role. by setting
third_party to true on the role object.

Once a role is marked as third party it can no longer be controlled
through the ui for SSO Users. (for ui users this poses no functional
change). It must be controlled via SSO.
2017-12-11 13:51:45 -08:00
f0c895a008 Upgrade dependency acme to ==0.20.0 (#1016) 2017-12-07 09:12:21 -08:00
6d6716b8a2 Upgrade dependency requests-mock to ==1.4.0 (#1013) 2017-12-07 09:12:08 -08:00
d64a010c39 Upgrade dependency pytest to ==3.3.1 (#1014) 2017-12-07 09:11:51 -08:00
e1f241bd55 Don't send notifications that are marked inactive (#1015)
Apparently previously Lemur ignored the "active" flag of notifications.
2017-12-06 08:32:24 -08:00
ad88637f22 Adding some niceties around the way users are associated with tokens. (#1012)
* Adding some niceties around the way users are associated with tokens.

- Includes user typeahead
- Tooltips
- User information displayed in table
- Default to current user when no user is passed
2017-12-05 10:57:17 -08:00
a756a74b49 Ensures we can get multiple endpoints with the same name but different ports. (#1011) 2017-12-04 13:13:02 -08:00
c311c0a221 Pinning JS versions (#1010) 2017-12-04 11:29:02 -08:00
ecc0934657 Adding cli command to clear out pending symantec certificates. (#1009) 2017-12-04 10:04:12 -08:00
c402f1ff87 add per user api keys to the backend (#995)
Adds in per user api keys to the backend of lemur.
the basics are:
  - API Keys are really just JWTs with custom second length TTLs.
  - API Keys are provided in the exact same ways JWTs are now.
  - API Keys can be revoked/unrevoked at any time by their creator
    as well as have their TTL Change at anytime.
  - Users can create/view/list their own API Keys at will, and
    an admin role has permission to modify all api keys in the
    instance.

Adds in support for lemur api keys to the frontend of lemur.
doing this required a few changes to the backend as well, but it is
now all working (maybe not the best way though, review will determine
that).

  - fixes inconsistency in moduleauthor name I inputted during the
    first commit.
  - Allows the revoke schema to optionally allow a full api_key object.
  - Adds `/users/:user_id/api_keys/:api_key` and `/users/:user_id/api_keys`
    endpoints.
  - normalizes use of `userId` vs `userId`
  - makes `put` call respond with a JWT so the frontend can show
    the token on updating.
  - adds in the API Key views for clicking "API Keys" on the main nav.
  - adds in the API Key views for clicking into a users edit page.
  - adds tests for the API Key backend views I added.
2017-12-04 08:50:31 -08:00
eb810f1bf0 Upgrade dependency jinja2 to ==2.10 (#992) 2017-12-03 10:08:13 -08:00
c067573193 Upgrade dependency paramiko to ==2.4.0 (#994) 2017-12-03 10:07:30 -08:00
553c119356 Upgrade dependency SQLAlchemy-Utils to ==0.32.21 (#991) 2017-12-03 10:06:32 -08:00
e62cb1b6b8 Upgrade dependency boto3 to ==1.4.8 (#1002) 2017-12-03 10:06:10 -08:00
4da243a59e Upgrade dependency moto to ==1.1.25 (#997) 2017-12-03 10:05:44 -08:00
622192e75e [Doppins] Upgrade dependency pytest to ==3.3.0 (#993)
* Upgrade dependency pytest to ==3.3.0
2017-12-03 10:05:31 -08:00
81a6ec644a Upgrade dependency tabulate to ==0.8.2 (#1006) 2017-12-03 10:05:05 -08:00
58100cda8b Upgrade dependency arrow to ==0.12.0 (#1005) 2017-12-03 10:04:51 -08:00
734ab5f3cd Upgrade dependency pyldap to ==2.4.45 (#998) 2017-12-03 10:02:28 -08:00
d855f752c8 Upgrade dependency marshmallow to ==2.15.0 (#1008) 2017-12-03 09:33:18 -08:00
5ac3ecb85e Added revoke support to cfssl plugin (#1007)
* Added revoke support to cfssl plugin
2017-11-29 14:33:22 -08:00
dfb9e3a0c8 Add nodejs-legacy to provide the 'node' command (#1004)
Affecting Ubuntu 16.04.3 LTS:

Following the directions of http://lemur.readthedocs.io/en/latest/quickstart/index.html, the make release command fails as the command 'node' cannot be found.

Adding nodejs-legacy solves the issue and allows the build to complete.

(lemur) lemur@lemur1:/www/lemur$ make release
--> Installing dependencies
npm install
npm WARN deprecated gulp-minify-css@1.2.4: Please use gulp-clean-css
npm WARN deprecated bower@1.8.2: ...psst! Your project can stop working at any moment because its dependencies can change. Prevent this by migrating to Yarn                                                                                 : https://bower.io/blog/2017/how-to-migrate-away-from-bower/
npm WARN deprecated gulp-foreach@0.1.0: Either use gulp-tap or gulp-flatmap, depending on your needs
npm WARN deprecated express@2.5.11: express 2.x series is deprecated
npm WARN deprecated connect@1.9.2: connect 1.x series is deprecated
npm WARN deprecated minimatch@2.0.10: Please update to minimatch 3.0.2 or higher to avoid a RegExp DoS issue
npm WARN deprecated minimatch@0.2.14: Please update to minimatch 3.0.2 or higher to avoid a RegExp DoS issue
npm WARN deprecated graceful-fs@1.2.3: graceful-fs v3.0.0 and before will fail on node releases >= v7.0. Please update to graceful-fs@^4.0.0 as soon as poss                                                                                 ible. Use 'npm ls graceful-fs' to find it in the tree.
npm WARN deprecated node-uuid@1.4.8: Use uuid module instead
npm WARN deprecated minimatch@0.3.0: Please update to minimatch 3.0.2 or higher to avoid a RegExp DoS issue
npm WARN prefer global marked@0.3.6 should be installed with -g

> optipng-bin@3.1.4 postinstall /www/lemur/node_modules/optipng-bin
> node lib/install.js

sh: 1: node: not found
npm WARN install:optipng-bin@3.1.4 optipng-bin@3.1.4 postinstall: `node lib/install.js`
npm WARN install:optipng-bin@3.1.4 spawn ENOENT

> jpegtran-bin@3.2.0 postinstall /www/lemur/node_modules/jpegtran-bin
> node lib/install.js

sh: 1: node: not found
npm WARN install:jpegtran-bin@3.2.0 jpegtran-bin@3.2.0 postinstall: `node lib/install.js`
npm WARN install:jpegtran-bin@3.2.0 spawn ENOENT

> gifsicle@3.0.4 postinstall /www/lemur/node_modules/gifsicle
> node lib/install.js

sh: 1: node: not found
npm WARN install:gifsicle@3.0.4 gifsicle@3.0.4 postinstall: `node lib/install.js`
npm WARN install:gifsicle@3.0.4 spawn ENOENT

> Lemur@ postinstall /www/lemur
> bower install --allow-root --config.interactive=false

/usr/bin/env: ‘node’: No such file or directory


Makefile:24: recipe for target 'release' failed
make: *** [release] Error 1
(lemur) lemur@lemur1:/www/lemur$ which node
(lemur) lemur@lemur1:/www/lemur$

Installing the package to solve the issue.

vsnine@lemur1:~$ sudo apt-get install nodejs-legacy
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following NEW packages will be installed:
  nodejs-legacy
0 upgraded, 1 newly installed, 0 to remove and 79 not upgraded.
Need to get 27.7 kB of archives.
After this operation, 81.9 kB of additional disk space will be used.
Get:1 http://ca.archive.ubuntu.com/ubuntu xenial-updates/universe amd64 nodejs-legacy all 4.2.6~dfsg-1ubuntu4.1 [27.7 kB]
Fetched 27.7 kB in 0s (52.4 kB/s)
Selecting previously unselected package nodejs-legacy.
(Reading database ... 73230 files and directories currently installed.)
Preparing to unpack .../nodejs-legacy_4.2.6~dfsg-1ubuntu4.1_all.deb ...
Unpacking nodejs-legacy (4.2.6~dfsg-1ubuntu4.1) ...
Processing triggers for man-db (2.7.5-1) ...
Setting up nodejs-legacy (4.2.6~dfsg-1ubuntu4.1) ...
vsnine@lemur1:~$ which node
/usr/bin/node
vsnine@lemur1:~$
2017-11-27 09:37:14 -08:00
c2b2ce1f11 Allowing the export of CAs that don't have a chain. (#1000) 2017-11-21 11:42:23 -08:00
cecfe47540 Adding the ability to revoke enmasse (#999) 2017-11-21 09:36:10 -08:00
4b544ae207 CSR Export Plugin (#988)
This plugin allows a certificate to be exported as a CSR via OpenSSL
x509.  The workflow will be:
* Create self-signed cert via Cryptography authority
* Export CSR via this plugin
* Sign your own cert outside of Lemur
* Import new cert with private key

Change-Id: Id3f7db2506bd959236cd3a6df622841058abda5a
2017-11-14 10:11:06 -08:00
e30e17038b Removing unused import. (#989) 2017-11-14 09:24:26 -08:00
7e2c16ee38 Fixes for using ACME with Route53 (#986)
* Changes required for functional Route53 operations

* Changes required for functional ACME operations with Route53

* Changes required for functional ACME operations with Route53, need external ID
2017-11-13 10:19:54 -08:00
041f3a22fa Added ability to set custom roles for users logging in via oauth provider (#985) 2017-11-10 08:38:33 -08:00
f990ef27cf Adding sentry tracking to issued with certificate deployment. (#978) 2017-10-26 15:21:13 -07:00
bef762e0d6 Upgrade dependency acme to ==0.19.0 (#957) 2017-10-25 13:44:01 -07:00
0d001b358e [Doppins] Upgrade dependency tabulate to ==0.8.1 (#953)
* Upgrade dependency tabulate to ==0.8.0

* Upgrade dependency tabulate to ==0.8.1
2017-10-25 11:19:25 -07:00
c1cd5c71e0 [Doppins] Upgrade dependency moto to ==1.1.19 (#946)
* Upgrade dependency moto to ==1.1.18

* Upgrade dependency moto to ==1.1.19
2017-10-25 11:06:36 -07:00
d4209510c2 Adding some additional exception capturing during certificate parsing. (#976) 2017-10-25 08:19:07 -07:00
620e279453 Caa (#975)
* Adding verisign error code for a CAA failure.

* Tweaking error msg.
2017-10-24 14:46:33 -07:00
bbf73c48a3 Adding health exception tracking. (#977) 2017-10-24 14:04:51 -07:00
9319dda0ec Added ability to ignore cert for oauth2 provider (#971)
* Added ability to ignore cert for oauth2 provider

This is useful for development environments where the OAuth provider
doesn't have a valid cert!

* Setting default for OAUTH2_VERIFY_CERT to true
2017-10-20 16:36:14 -07:00
14f5340802 During higher loads, retrying the connection attempt is often required for the CIS api. (#972) 2017-10-12 10:37:58 -07:00
0152985e64 Adding serial numbers when certificates with the same name are encoun… (#970)
* Adding serial numbers when certificates with the same name are encountered.
2017-10-11 13:20:19 -07:00
e43268f585 Source plugin (#965)
* Ensure that None values aren't passed.
2017-10-09 10:37:44 -07:00
7ef788752e Source plugin (#964)
* Another minor fix.
2017-10-06 17:39:31 -07:00
b66d7ce1fd Source plugin (#963)
* Ensuring that we have default options for source plugins.

* Handle duplicate serials. Serials are not unique across issuers.

* Minor fix.
2017-10-06 13:22:03 -07:00
dc34652efd Source plugin (#962)
* Ensuring that we have default options for source plugins.

* Handle duplicate serials. Serials are not unique across issuers.
2017-10-06 08:49:05 -07:00
e0d2fb0de1 Ensuring that we have default options for source plugins. (#961) 2017-10-05 17:27:45 -07:00
e0d9443141 Ensuring existing users are also given the default role. (#960) 2017-10-05 16:47:52 -07:00
a6305a5cae Adding Digicert CIS Sourceplugin (#959)
* Adding necessary features to complete backfill

* Fixing pagination logic.
2017-10-04 16:56:01 -07:00
9e2578be1e Adding necessary features to complete backfill (#958) 2017-10-04 14:57:57 -07:00
09b8f532a7 Adding cli to mass revoke certificates. (#955) 2017-10-03 10:51:53 -07:00
e0939a2856 Adding some default data to put. (#950) 2017-09-29 14:49:07 -07:00
90f4b458e3 Adding the lemur identity to be able to re-issue certificates. (#949) 2017-09-29 14:07:40 -07:00
f5213deb67 Removing revocation comments for now. (#947) 2017-09-29 10:53:15 -07:00
bb08b1e637 Initial work allowing certificates to be revoked. (#941)
* Initial work allowing for certificates to be revoked.
2017-09-28 18:27:56 -07:00
ea6f5c920b Update index.rst (#942)
Fixed typo for libsasl3-dev (was libsas13-dev).
2017-09-27 09:44:03 -07:00
54ff4cddbf Disallow issuing certificates from inactive authority (#936) 2017-09-25 15:34:49 -07:00
645641f4bd Avoid redundant key_view log entries (#937)
Don't re-request private key when it's already loaded in frontend.
2017-09-25 15:34:07 -07:00
97d83890e0 Various minor cleanups and fixes (#938)
* Documentation fixes

* Various docstring and help string fixes

* Minor code cleanups

* Removed redundant .gitignore entry, ignored package-lock.json.
* 'return' statement in certificates.service.render was redundant
* Split up too long line
* Non-matching tags in templates
2017-09-25 15:33:42 -07:00
ec5dec4a16 Add option to disable owner email address in CSR subject (#939) 2017-09-25 15:32:08 -07:00
4cfb621423 Upgrade dependency moto to ==1.1.14 (#940) 2017-09-25 15:31:39 -07:00
c381331c10 Upgrade dependency pyjwt to ==1.5.3 (#901) 2017-09-25 09:19:54 -07:00
a7923f2a06 Upgrade dependency six to ==1.11.0 (#926) 2017-09-25 09:19:40 -07:00
e5f7172c97 [Doppins] Upgrade dependency paramiko to ==2.3.1 (#927)
* Upgrade dependency paramiko to ==2.3.0

* Upgrade dependency paramiko to ==2.3.1
2017-09-25 09:19:24 -07:00
43fff0450b Upgrade dependency acme to ==0.18.2 (#928) 2017-09-25 09:19:08 -07:00
107fd3fce1 [Doppins] Upgrade dependency raven to ==6.2.1 (#933)
* Upgrade dependency raven to ==6.2.0

* Upgrade dependency raven to ==6.2.1
2017-09-25 09:18:57 -07:00
1a9b6dec26 [Doppins] Upgrade dependency moto to ==1.1.13 (#931)
* Upgrade dependency moto to ==1.1.12

* Upgrade dependency moto to ==1.1.13
2017-09-25 09:18:40 -07:00
444be5bb7f Updated Quikstart (#934)
Got some failures doing a clean install on Ubuntu 17.04 Zesty Zapus (Final) from virtualboxes.org
2017-09-22 12:35:25 -07:00
5ebfa018ee [Doppins] Upgrade dependency moto to ==1.1.11 (#922)
* Upgrade dependency moto to ==1.1.7

* Upgrade dependency moto to ==1.1.8

* Upgrade dependency moto to ==1.1.9

* Upgrade dependency moto to ==1.1.10

* Upgrade dependency moto to ==1.1.11
2017-09-21 10:31:45 -07:00
a6dab5e1ee a bit more ldap documentaion (#930) 2017-09-21 06:00:26 -07:00
f766871824 Create default rotation policy with name (#924) 2017-09-18 09:09:59 -07:00
ba29bbe3be Upgrade dependency pyOpenSSL to ==17.2.0 (#918) 2017-09-13 20:54:54 -07:00
d711031ce9 Upgrade dependency moto to ==1.1.6 (#919) 2017-09-13 20:54:43 -07:00
af5c19cc52 Solving conflicts 2017-09-13 09:41:19 -07:00
359fbd2d73 Pinning version of PyOpenSSL #873 2017-09-13 09:39:52 -07:00
e8b9853367 Fixes 873 by explicitly declaring pyopenssl version. (#917) 2017-09-13 09:30:20 -07:00
376b2b8051 Upgrade dependency moto to ==1.1.5 (#916) 2017-09-12 16:01:24 -07:00
e8d0af87e4 Upgrade dependency SQLAlchemy-Utils to ==0.32.16 (#895) 2017-09-12 09:59:49 -07:00
a4267320b0 Upgrade dependency Flask-Script to ==2.0.6 (#900) 2017-09-12 09:59:23 -07:00
52dd42701a Upgrade dependency moto to ==1.1.4 (#915) 2017-09-12 09:58:38 -07:00
fc9b1e5b12 server_default from "False" to sa.false() (#913) 2017-09-11 09:19:19 -07:00
2ecfaa41cf Add pyldap mock for readthedocs (#912) 2017-09-11 09:18:03 -07:00
7106c4fdcf Sync docs requirements.txt (#910) 2017-09-10 10:41:46 -07:00
9420ca9949 Upgrade dependency acme to ==0.18.1 (#908) 2017-09-08 16:59:49 -07:00
956a1851a2 Upgrade dependency moto to ==1.1.3 (#909) 2017-09-08 16:59:39 -07:00
dafed86179 Improve certificate name normalization: remove Unicode characters, etc. (#906)
* Accented characters are replaced with non-accented version (ä -> a)
* Spaces are replaced with '-' (previously they were removed)
* Multiple non-alphanumeric characters are collapsed into one '-'
2017-09-08 10:52:22 -07:00
e72efce071 Upgrade dependency acme to ==0.18.0 (#902) 2017-09-07 18:09:52 -07:00
77b9658dba Upgrade dependency pyldap to ==2.4.37 (#903) 2017-09-07 18:09:37 -07:00
090c984ca3 Upgrade dependency pytest to ==3.2.2 (#904) 2017-09-07 18:09:15 -07:00
2ff25b656f Upgrade dependency moto to ==1.1.2 (#905) 2017-09-07 18:09:07 -07:00
ff4d1edd63 remove duplicated ldap_bind_uri description (#898) 2017-09-04 10:12:40 -07:00
79d12578c7 basic ldap support (#842) 2017-09-03 20:41:43 -07:00
c0784b40e0 Upgrade dependency Flask-Migrate to ==2.1.1 (#892) 2017-08-29 20:20:39 -07:00
ff87c487c8 It's too expensive to attempt to load all certificates associated with a given notification. Some queries such as default are associated with a large number of certificates. We have little control over when these objects are loaded, but when marshalled they are lazyloaded via SQLAlachemy. If a user needs to get all the certificates associated with a certificate they should use the /notifications/<id>/certificates endpoints that support pagination. (#891) 2017-08-28 17:57:39 -07:00
82b43b5a9d Create signal hooks and handler for dumping CSR and certificate details (#882) 2017-08-28 17:35:56 -07:00
4b4e159a8e [Doppins] Upgrade dependency moto to ==1.1.1 (#888)
* Upgrade dependency moto to ==1.1.0

* Upgrade dependency moto to ==1.1.1
2017-08-28 17:35:12 -07:00
bb1c339655 Fix ability to remove all roles from authority (#880) 2017-08-28 17:35:01 -07:00
aca6d6346f Removing legacy requirement for nodejs. Closes #866 (#887) 2017-08-25 10:12:56 -07:00
e7efaf4365 Prevent creation of empty SubjAltNames extension in CSR (#883) 2017-08-18 09:10:56 -07:00
c6d76f580e Disable unused Flask Principal sessions (#881)
Lemur uses its own auth token for authentication; logging out doesn't
properly dispose of the Flask Principal session.
2017-08-17 09:24:35 -07:00
941df0366d Fix roles display on user screen and fix removing user roles (#879) 2017-08-17 09:24:10 -07:00
7762d6ed52 Reworked sensitive domain name and restriction logic (#878)
* This is a fix for a potential security issue; the old code had edge
  cases with unexpected behavior.
* LEMUR_RESTRICTED_DOMAINS is no more, instead LEMUR_WHITELISTED_DOMAINS
  is a list of *allowed* domain name patterns. Per discussion in PR #600
* Domain restrictions are now checked everywhere: in domain name-like
  CN (common name) values and SAN DNSNames, including raw CSR requests.
* Common name values that contain a space are exempt, since they cannot
  be valid domain names.
2017-08-16 19:24:49 -07:00
466df367e6 Upgrade dependency boto3 to ==1.4.6 (#874) 2017-08-16 09:56:22 -07:00
b0c8787cfa Upgrade dependency marshmallow to ==2.13.6 (#877) 2017-08-16 09:56:08 -07:00
cf805f530f Prevent unintended access to sensitive fields (passwords, private keys) (#876)
Make sure that fields specified in filter, sortBy, etc. are model fields
and may be accessed. This is fixes a potential security issue.

The filter() function allowed guessing the content of password hashes
one character at a time.

The sort() function allowed the user to call an arbitrary method of an
arbitrary model attribute, for example sortBy=id&sortDir=distinct would
produce an unexpected error.
2017-08-16 09:38:42 -07:00
b40c6a1c67 Upgrade dependency pem to ==17.1.0 (#872) 2017-08-10 15:08:11 -07:00
3a62010445 Upgrade dependency pytest to ==3.2.1 (#871) 2017-08-09 15:00:15 -07:00
3b4e7d9169 Fixed typo (#870) 2017-08-09 08:40:22 -07:00
4245ba0d15 Upgrade dependency acme to ==0.17.0 (#866) 2017-08-06 11:19:10 -07:00
95e4c23db1 Upgrade dependency factory-boy to ==2.9.2 (#868) 2017-08-06 11:19:00 -07:00
f5e120ad2e Update readme.txt (#869) 2017-08-04 12:42:27 -07:00
fab146b328 [Doppins] Upgrade dependency factory-boy to ==2.9.1 (#863)
* Upgrade dependency factory-boy to ==2.9.0

* Upgrade dependency factory-boy to ==2.9.1
2017-08-02 09:17:25 -07:00
5aeadf8f98 [Doppins] Upgrade dependency psycopg2 to ==2.7.3 (#858)
* Upgrade dependency psycopg2 to ==2.7.2

* Upgrade dependency psycopg2 to ==2.7.3
2017-08-02 09:16:38 -07:00
5f9c655594 Upgrade dependency Flask-Migrate to ==2.1.0 (#861) 2017-08-02 09:16:21 -07:00
dd18cac702 Upgrade dependency boto3 to ==1.4.5 (#862) 2017-08-02 09:16:01 -07:00
b76ab902e5 Upgrade dependency pytest to ==3.2.0 (#865) 2017-08-02 09:15:42 -07:00
f5082e2d3a Starting transition away from not_before and not_after. (#854) 2017-07-14 09:24:59 -07:00
61c493fc91 Adding additional failure conditions to sentry tracking. (#853)
* Adding additional failure conditions to sentry tracking.

* Removing sentry extension as a circular import.
2017-07-13 14:49:04 -07:00
6779e19ac9 Adding enum migration. (#852) 2017-07-13 13:12:53 -07:00
443eb43d1f Adding the ability to specify a per-certificate rotation policy. (#851) 2017-07-12 16:46:11 -07:00
560bd5a872 Upgrade dependency acme to ==0.16.0 (#850) 2017-07-12 15:53:32 -07:00
8f35a64faf Upgrade dependency pyjwt to ==1.5.2 (#846) 2017-07-12 15:52:50 -07:00
7507f6be50 Updating documentation (#849) 2017-07-05 20:17:19 -07:00
ac3b441456 Upgrade dependency pytest to ==3.1.3 (#847) 2017-07-05 19:02:59 -07:00
53113e5eeb Add auditing for creating or updating a cert. (#845) 2017-07-04 06:39:16 -07:00
9d5db3ec12 This should not have been upgraded as it breaks mTLS (#844) 2017-06-29 16:29:26 -07:00
169dcb86e2 supporting the ability to push exceptions to sentry (#843) 2017-06-29 14:12:38 -07:00
e4f5224f42 set ses email content type to utf-8 instead of string (#841) 2017-06-28 09:44:19 -07:00
98907e66e9 Minor fixes to S3.put signature (#840) 2017-06-27 16:18:34 -07:00
c05343d58e Adds the ability for destination plugins to be sub-classed from Expor… (#839)
* Adds the ability for destination plugins to be sub-classed from ExportDestination. These plugins have the extra option of specifying an export plugin before the destination receives the data. Closes #807.

* fixing tests
2017-06-26 12:03:24 -07:00
541fbc9a6d Use named kwargs rather than args when calling s3 put (#830) 2017-06-20 11:28:19 -07:00
ef08e02333 [Doppins] Upgrade dependency paramiko to ==2.2.1 (#833)
* Upgrade dependency paramiko to ==2.1.3

* Upgrade dependency paramiko to ==2.2.0

* Upgrade dependency paramiko to ==2.2.1
2017-06-14 09:20:35 -07:00
35cc7ef8d7 Adding support for private DigiCert certificates (#835) 2017-06-14 09:20:24 -07:00
e77382864b Fixing KeyError on error handling (#834) 2017-06-14 09:07:27 -07:00
b5fd802005 Upgrade dependency acme to ==0.15.0 (#831) 2017-06-09 09:03:07 -07:00
98897f3c98 Upgrade dependency pytest to ==3.1.2 (#832) 2017-06-09 09:02:55 -07:00
d49bb8a6ca Upgrade dependency Flask-RESTful to ==0.3.6 (#828) 2017-06-03 20:25:11 -07:00
05f2d3b2d9 Upgrade dependency moto to ==1.0.1 (#829) 2017-06-03 20:24:51 -07:00
d4d6d832b1 Fixing audit filtering and sorting. (#827) 2017-06-02 09:07:22 -07:00
9c92138f2d Fixing autorotation failures. (#825)
* Fixing issue with auto rotation failing due to a change in the way certificate data is serialized.
2017-06-02 08:59:42 -07:00
5a4806bc43 Allowing description to be optional. (#826) 2017-06-01 17:09:04 -07:00
54105e221e Upgrade dependency Flask-Migrate to ==2.0.4 (#822) 2017-05-31 08:58:54 -07:00
adfc76aa79 Upgrade dependency pytest to ==3.1.1 (#823) 2017-05-31 08:58:38 -07:00
3e3f7af796 Upgrade dependency cryptography to ==1.9 (#821) 2017-05-30 09:03:46 -07:00
07969f7e10 Ensuring IPAddresses and IPNetworks are correctly serialized. (#818) 2017-05-26 10:48:26 -07:00
249ab23df4 Upgrade dependency acme to ==0.14.2 (#817) 2017-05-25 17:40:55 -07:00
3141b47fba Catch OAuth providers that want the params sent as data (#800) 2017-05-25 10:21:29 -07:00
31f4cf0253 adding url context path to html templates (#814) 2017-05-25 10:20:32 -07:00
21d48b32c9 Fixing an issue with uploading to cloudfront. (#815) 2017-05-25 10:10:12 -07:00
11bd42af82 Correct status code for basic-auth (#813)
* ensuring those using basic auth recieve a correct status code when their password is incorrect

* Fixing oauth status codes
2017-05-23 09:48:31 -07:00
feac9cb3a3 Upgrade dependency pytest to ==3.1.0 (#811) 2017-05-23 09:31:18 -07:00
f6b5012f56 Add Check of DB connections on healthcheck URL (#812) 2017-05-22 17:15:41 -07:00
f9b388c658 Modifying the was s3 uploading works. (#810)
* Modiying the was s3 uploading works.

* Fixing pep8
2017-05-20 12:07:44 -07:00
4093f4669a Switching remaining uses of boto to boto3. (#809) 2017-05-20 11:09:55 -07:00
9594f2cd8d Upgrading moto and fixing test that break due to deprecation. (#808)
* Upgrading moto and fixing test that break due to deprecation.

* Adding region.
2017-05-20 10:40:22 -07:00
380203eb53 Adding the ability to upload to cloudfront via the 'path' parameter. Cloudfront destinations must be created separately. (#805)
Closes #277
2017-05-18 13:49:17 -07:00
307a73c752 Fixing some confusion between 401 vs 403 error code. 401 indicates that the user should attempt to authenticate again. Where as 403 indicates the user is authenticated but not allowed to complete an action. (#804)
Closes #767
2017-05-18 13:20:17 -07:00
7ad471a810 Upgrade dependency acme to ==0.14.1 (#801) 2017-05-16 13:33:21 -07:00
1184f9d070 Upgrade dependency freezegun to ==0.3.9 (#803) 2017-05-16 13:32:20 -07:00
3050aca3e6 Minor fixes to the domains UI. (#798)
* Fixes checkbox input.

* Fixes notification message.
2017-05-15 19:14:12 -07:00
8c41c6785d Fixes issue where domains without any associated certificates are not searchable. (#797) 2017-05-15 19:07:32 -07:00
092ce0f9d8 Closes #792. (#796) 2017-05-15 19:07:16 -07:00
97dceb5623 fixed typo in supervisord example config (#790) 2017-05-12 09:18:32 -07:00
23b6df536f Fix Minor Typo in index.rst (#793)
Changed LEMUR_DEFAUTL_ORGANIZATION to LEMUR_DEFAULT_ORGANIZATION
2017-05-12 09:17:52 -07:00
95b4206986 Removing tests folder from coverage report. (#788) 2017-05-11 19:42:53 -07:00
914de78576 Adds migration to fix keys on unique index. Closes #743. (#785) 2017-05-10 12:13:42 -07:00
ecf00fe9d6 Splitting out the default date issuance logic for CIS and CC. CIS assumes years is converted to validity_end while CC prefers validity_years over validity_end. (#784) 2017-05-10 12:05:03 -07:00
7257e791ff Upgrade dependency acme to ==0.14.0 (#777) 2017-05-08 19:27:33 -07:00
c71b3a319d Log the audit logs (#781) 2017-05-08 09:43:26 -07:00
767147aef1 Check for unknown as status is no longer represented as a boolean (#780) 2017-05-08 09:43:19 -07:00
ce5a45037a Fix for status representation in the view (#778) 2017-05-05 11:04:40 -07:00
9c9ca37586 Enabling hex serial numbers without breaking backward compatibility. (#779)
* Enabling hex serial numbers without breaking backward compatibility.

* Fixing tests.
2017-05-05 11:04:09 -07:00
381cd2e1ff Updated apache config (#776)
You guys asked for one that worked... It took me a little while to tweak, esp. since I'm not a guru with python.  The comment about needing mod_wsgi isn't true, unless you want to run lemur as a cgi program... I suspect that's from an older version that ran as cgi and not as a standalone webserver.
2017-05-04 08:45:55 -07:00
2a2d5a5583 Adding an example digicert url. Closes #700. (#775) 2017-05-01 10:59:49 -07:00
5c41dafc97 fix unit and interval transposition in schemas.py (#752) (#774) 2017-04-30 12:23:34 -07:00
6367a98134 Creating a user named 'lemur' in postgres (#773)
Creating a user named 'lemur' in postgres
2017-04-28 15:31:08 -07:00
0bbe2b0331 config LEMUR_MAIL to LEMUR_EMAIL (#772)
I referenced https://github.com/Netflix/lemur/blob/master/lemur/plugins/lemur_email/plugin.py and it appears this configuration option should be "LEMUR_EMAIL"
2017-04-28 15:01:21 -07:00
6a77d511e8 Upgrade dependency xmltodict to ==0.11.0 (#769) 2017-04-28 15:00:41 -07:00
989e3733a2 Add docker setup for running tests on a docker enabled dev environment. (#771) 2017-04-28 09:28:06 -07:00
fbc24ea400 There is an issue when iterating over extensions where certificates might not have been issued in adherence with basic constraints. Here we log these errors instead of failing out right. (#770) 2017-04-27 17:45:34 -07:00
2b8c2f612e Upgrade dependency pyjwt to ==1.5.0 (#768) 2017-04-27 12:16:36 -07:00
4905020e77 ensuring stdout has a default log level (#766) 2017-04-27 10:11:47 -07:00
75787d20bc ensuring that lemur's default user has a valid email (#765) 2017-04-27 09:53:35 -07:00
ca9f120988 fixing some pep8 issues (#764) 2017-04-27 09:44:39 -07:00
5fb6753445 Upgrade dependency marshmallow to ==2.13.5 (#753) 2017-04-27 09:20:03 -07:00
e86954e8ea Destination Plugin/Lemur_linuxdst (#736)
* Added lemur_linuxdst

* Revert "Added lemur_linuxdst"

This reverts commit 010c19bd1937320189ee5a0660f9e356221121f3.

* added plugin\lemur_linuxdst

Destination plugin for a target linux host

* Update remote_host.py

* Update plugin.py

* Update remote_host.py

* Update plugin.py

* Update plugin.py

* chaning var and funct names

* Write data with local temp

* .

* .

* typo

* tested plugin successfully

* Update plugin.py

* Update remote_host.py

* removed whitespace

* set permissions on exported keys to 600

sftp.chmod(dst_dir_cn + '/' + dst_file, (stat.S_IRUSR))

* Update plugin.py

* Update remote_host.py

* Update plugin.py

* added 'paramiko==2.1.2'

required for lemur_linuxdst plugin

* data stored in clear text at rest

* Update plugin.py

* Update plugin.py

* Update remote_host.py
2017-04-27 09:19:49 -07:00
604cd60dbe Return correct intermediate certificate on digicert creation. (#762)
This commit also removes the unused DIGICERT_INTERMEDIATE env
var as it is not used.
2017-04-27 09:14:20 -07:00
05f4ae8e58 Hexify cert serial (#763)
* Hexify serial at the serialization layer

* Fix for flakey test. Change test to test for uppercased string
2017-04-27 09:13:04 -07:00
88ac783fd2 PEP8 Fixes (#760) 2017-04-25 09:23:18 -07:00
bc66ede9aa Fixing Bandit findings and adding travis Bandit job (#759)
* Fixes for Bandit

This commit fixes a couple of issues so that Bandit can run
cleanly using medium+ severity and confidence filtering.

* Adding Lemur Bandit job to TravisCI
2017-04-24 18:37:03 -07:00
1c295896e6 Add test for when there are no notifications on a certificate (#757) 2017-04-24 09:04:49 -07:00
f90076abe9 Update index.rst (#754)
Seems the api for these actions have changed. Thought I would update the documentation around this. Let me know if I've misunderstood something.
2017-04-19 16:06:32 -07:00
01aa372e59 Version bump. (#751) 2017-04-08 13:23:48 -07:00
169 changed files with 5073 additions and 979 deletions

View File

@ -1,4 +1,5 @@
[report] [report]
include = lemur/*.py include = lemur/*.py
omit = lemur/migrations/* omit = lemur/migrations/*
lemur/tests/*

7
.gitignore vendored
View File

@ -13,6 +13,7 @@
MANIFEST MANIFEST
test.conf test.conf
pip-log.txt pip-log.txt
package-lock.json
/htmlcov /htmlcov
/cover /cover
/build /build
@ -27,5 +28,7 @@ pip-log.txt
docs/_build docs/_build
.editorconfig .editorconfig
.idea .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

View File

@ -1,10 +1,10 @@
- repo: git://github.com/pre-commit/pre-commit-hooks - repo: git://github.com/pre-commit/pre-commit-hooks
sha: 18d7035de5388cc7775be57f529c154bf541aab9 sha: v0.9.1
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: flake8 - id: flake8
- id: check-merge-conflict - id: check-merge-conflict
- repo: git://github.com/pre-commit/mirrors-jshint - repo: git://github.com/pre-commit/mirrors-jshint
sha: e72140112bdd29b18b0c8257956c896c4c3cebcb sha: v2.9.5
hooks: hooks:
- id: jshint - id: jshint

View File

@ -34,9 +34,11 @@ before_script:
install: install:
- pip install coveralls - pip install coveralls
- pip install bandit
script: script:
- make test - make test
- bandit -r . -ll -ii -x lemur/tests/,docs
after_success: after_success:
- coveralls - coveralls

View File

@ -1,6 +1,50 @@
Changelog Changelog
========= =========
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` 0.5 - `2016-04-08`
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~
@ -13,19 +57,15 @@ Other Highlights:
* Closed `#501 <https://github.com/Netflix/lemur/issues/501>`_ - Endpoint resource as now kept in sync via an * 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 expiration mechanism. Such that non-existant endpoints gracefully fall out of Lemur. Certificates are never
removed from Lemur. removed from Lemur.
* Closed `#551 <https://github.com/Netflix/lemur/pull/551>`_ - Added the ability to create a 4096 bit key during certificate * 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. 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 * Closed `#566 <https://github.com/Netflix/lemur/issues/566>`_ - Fixed an issue changing the notification status for certificates
without private keys. without private keys.
* Closed `#594 <https://github.com/Netflix/lemur/issues/594>`_ - Added `replaced` field indicating if a certificate has been superseded. * 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. * 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 with this release, notably: Special thanks to all who helped with this release, notably:
- RcRonco - RcRonco
- harmw - harmw
@ -87,7 +127,7 @@ Issuer Plugin Owners
-------------------- --------------------
This release may break your plugins, the keys in `issuer_options` have been changed from `camelCase` to `under_score`. 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. 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. .. note:: This change only affects issuer plugins and does not affect any other types of plugins.
@ -97,10 +137,10 @@ these keys should be fairly trivial, additionally pull requests have been submit
stricter input validation and better error messages when validation fails. 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 `#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 * 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 * Closed `#199 <https://github.com/Netflix/lemur/issues/199>`_ - Ensures that the dates submitted to Lemur during authority and
certificate creation are actually dates. 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. 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 * 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. (generated or otherwise) is found to be a duplicate we increment by appending a counter.
@ -112,7 +152,7 @@ these keys should be fairly trivial, additionally pull requests have been submit
via the UI. via the UI.
* Closed `#289 <https://github.com/Netflix/lemur/issues/289>`_ - Fixed and issue where intermediates were not being properly exported. * 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 * 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.
@ -162,6 +202,6 @@ these keys should be fairly trivial, additionally pull requests have been submit
0.1.5 - 2015-10-26 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. 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
View 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]"

View File

@ -5,8 +5,8 @@ Lemur
:alt: Join the chat at https://gitter.im/Netflix/lemur :alt: Join the chat at https://gitter.im/Netflix/lemur
:target: https://gitter.im/Netflix/lemur?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge :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 .. image:: https://readthedocs.io/projects/lemur/badge/?version=latest
:target: https://lemur.readthedocs.org :target: https://lemur.readthedocs.io
:alt: Latest Docs :alt: Latest Docs
.. image:: https://img.shields.io/badge/NetflixOSS-active-brightgreen.svg .. image:: https://img.shields.io/badge/NetflixOSS-active-brightgreen.svg
@ -25,7 +25,7 @@ Project resources
================= =================
- `Lemur Blog Post <http://techblog.netflix.com/2015/09/introducing-lemur.html>`_ - `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>`_ - `Source code <https://github.com/netflix/lemur>`_
- `Issue tracker <https://github.com/netflix/lemur/issues>`_ - `Issue tracker <https://github.com/netflix/lemur/issues>`_
- `Docker <https://github.com/Netflix/lemur-docker>`_ - `Docker <https://github.com/Netflix/lemur-docker>`_

View File

@ -31,7 +31,7 @@
"font-awesome": "~4.5.0", "font-awesome": "~4.5.0",
"lodash": "~4.0.1", "lodash": "~4.0.1",
"underscore": "~1.8.3", "underscore": "~1.8.3",
"angular-smart-table": "~2.1.6", "angular-smart-table": "2.1.8",
"angular-strap": ">= 2.2.2", "angular-strap": ">= 2.2.2",
"angular-underscore": "^0.5.0", "angular-underscore": "^0.5.0",
"angular-translate": "^2.9.0", "angular-translate": "^2.9.0",

18
docker-compose.yml Normal file
View File

@ -0,0 +1,18 @@
---
version: '2.0'
services:
test:
build: .
volumes:
- ".:/app"
links:
- postgres
command: make test
environment:
SQLALCHEMY_DATABASE_URI: postgresql://lemur:lemur@postgres:5432/lemur
postgres:
image: postgres:9.4
environment:
POSTGRES_USER: lemur
POSTGRES_PASSWORD: lemur

View File

@ -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>` 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. for more information.
.. note::
All configuration values are python strings unless otherwise noted.
Basic Configuration Basic Configuration
------------------- -------------------
@ -24,14 +28,14 @@ Basic Configuration
LOG_FILE = "/logs/lemur/lemur-test.log" LOG_FILE = "/logs/lemur/lemur-test.log"
.. data:: debug .. data:: DEBUG
:noindex: :noindex:
Sets the flask debug flag to true (if supported by the webserver) Sets the flask debug flag to true (if supported by the webserver)
:: ::
debug = False DEBUG = False
.. warning:: .. warning::
This should never be used in a production environment as it exposes Lemur to This should never be used in a production environment as it exposes Lemur to
@ -66,11 +70,24 @@ Basic Configuration
Specifies whether to allow certificates created by Lemur to expire on weekends. Default is True. Specifies whether to allow certificates created by Lemur to expire on weekends. Default is True.
.. data:: LEMUR_RESTRICTED_DOMAINS .. data:: LEMUR_WHITELISTED_DOMAINS
:noindex: :noindex:
This allows the administrator to mark a subset of domains or domains matching a particular regex as List of regular expressions for domain restrictions; if the list is not empty, normal users can only issue
*restricted*. This means that only an administrator is allows to issue the domains in question. 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 .. data:: LEMUR_TOKEN_SECRET
:noindex: :noindex:
@ -109,6 +126,12 @@ Basic Configuration
LEMUR_ENCRYPTION_KEYS = ['1YeftooSbxCiX2zo8m1lXtpvQjy27smZcUUaGmffhMY=', 'LAfQt6yrkLqOK5lwpvQcT4jf2zdeTQJV1uYeh9coT5s='] 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 Certificate Default Options
--------------------------- ---------------------------
@ -202,14 +225,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>`_ 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: :noindex:
Lemur sender's email Lemur sender's email
:: ::
LEMUR_MAIL = 'lemur.example.com' LEMUR_EMAIL = 'lemur.example.com'
.. data:: LEMUR_SECURITY_TEAM_EMAIL .. data:: LEMUR_SECURITY_TEAM_EMAIL
@ -234,7 +257,120 @@ Lemur supports sending certification expiration notifications through SES and SM
Authentication Options 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. 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>`_ For more information about how to use social logins, see: `Satellizer <https://github.com/sahat/satellizer>`_
@ -360,6 +496,13 @@ For more information about how to use social logins, see: `Satellizer <https://g
OAUTH2_AUTH_ENDPOINT = "https://<youroauthserver>/oauth2/v1/authorize" OAUTH2_AUTH_ENDPOINT = "https://<youroauthserver>/oauth2/v1/authorize"
.. data:: OAUTH2_VERIFY_CERT
:noindex:
::
OAUTH2_VERIFY_CERT = True
.. data:: GOOGLE_CLIENT_ID .. data:: GOOGLE_CLIENT_ID
:noindex: :noindex:
@ -435,7 +578,7 @@ The following configuration properties are required to use the Digicert issuer p
.. data:: DIGICERT_URL .. data:: DIGICERT_URL
:noindex: :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_API_KEY .. data:: DIGICERT_API_KEY
@ -450,12 +593,6 @@ The following configuration properties are required to use the Digicert issuer p
This is the Digicert organization ID tied to your API key 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 .. data:: DIGICERT_ROOT
:noindex: :noindex:
@ -468,6 +605,11 @@ 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) 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 CFSSL Issuer Plugin
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
@ -824,7 +966,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. This will ensure that any needed tables or columns are created or destroyed.
.. note:: .. 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:: .. note::
By default Alembic looks for the `migrations` folder in the current working directory.The migrations folder is By default Alembic looks for the `migrations` folder in the current working directory.The migrations folder is

View File

@ -13,12 +13,24 @@
# serve to show the default. # serve to show the default.
import sys import sys
import os import os
from unittest.mock import MagicMock
# If extensions (or modules to document with autodoc) are in another directory, # 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 # 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. # documentation root, use os.path.abspath to make it absolute, like shown here.
sys.path.insert(0, os.path.abspath('..')) 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 ------------------------------------------------ # -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here. # If your documentation needs a minimal Sphinx version, state it here.

View File

@ -89,6 +89,12 @@ You'll likely want to make some changes to the default configuration (we recomme
.. note:: The ``upgrade`` shortcut is simply a shortcut 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 Coding Standards
---------------- ----------------
@ -277,6 +283,31 @@ Domains
:undoc-members: :undoc-members:
:show-inheritance: :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 Internals
========= =========

View File

@ -1,15 +1,6 @@
certificates Package certificates Package
==================== ====================
:mod:`exceptions` Module
------------------------
.. automodule:: lemur.certificates.exceptions
:noindex:
:members:
:undoc-members:
:show-inheritance:
:mod:`models` Module :mod:`models` Module
-------------------- --------------------

View File

@ -10,15 +10,6 @@ lemur_verisign Package
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
:mod:`constants` Module
-----------------------
.. automodule:: lemur.plugins.lemur_verisign.constants
:noindex:
:members:
:undoc-members:
:show-inheritance:
:mod:`plugin` Module :mod:`plugin` Module
-------------------- --------------------

View File

@ -97,3 +97,19 @@ Subpackages
lemur.plugins lemur.plugins
lemur.roles lemur.roles
lemur.users 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_linuxdst
lemur.plugins.lemur_openssl
lemur.plugins.lemur_slack

View File

@ -6,7 +6,7 @@ Common Problems
In my startup logs I see *'Aborting... Lemur cannot locate db encryption key, is LEMUR_ENCRYPTION_KEYS set?'* 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 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. I am seeing Lemur's javascript load in my browser but not the CSS.

View File

@ -217,6 +217,23 @@ An example apache config::
# HSTS (mod_headers is required) (15768000 seconds = 6 months) # HSTS (mod_headers is required) (15768000 seconds = 6 months)
Header always set Strict-Transport-Security "max-age=15768000" 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> </VirtualHost>
Also included in the configurations above are several best practices when it comes to deploying TLS. Things like enabling Also included in the configurations above are several best practices when it comes to deploying TLS. Things like enabling
@ -314,6 +331,6 @@ How often you run these commands is largely up to the user. `notify` and `check_
Example cron entries:: Example cron entries::
0 22 * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur notify 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 sync -s all */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 check_revoked 0 22 * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur certificate check_revoked

View File

@ -27,10 +27,13 @@ If installing Lemur on a bare Ubuntu OS you will need to grab the following pack
.. code-block:: bash .. code-block:: bash
$ sudo apt-get update $ sudo apt-get update
$ sudo apt-get install nodejs-legacy python-pip python-dev python3-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:: 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: Now, install Python ``virtualenv`` package:
.. code-block:: bash .. code-block:: bash
@ -89,7 +92,7 @@ And then run:
.. note:: This command will install npm dependencies as well as compile static assets. .. 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. 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: Example:
@ -138,7 +141,7 @@ Before Lemur will run you need to fill in a few required variables in the config
LEMUR_DEFAULT_COUNTRY LEMUR_DEFAULT_COUNTRY
LEMUR_DEFAULT_STATE LEMUR_DEFAULT_STATE
LEMUR_DEFAULT_LOCATION LEMUR_DEFAULT_LOCATION
LEMUR_DEFAUTL_ORGANIZATION LEMUR_DEFAULT_ORGANIZATION
LEMUR_DEFAULT_ORGANIZATIONAL_UNIT LEMUR_DEFAULT_ORGANIZATIONAL_UNIT
Setup Postgres Setup Postgres
@ -151,9 +154,8 @@ First, set a password for the postgres user. For this guide, we will use ``lemu
.. code-block:: bash .. code-block:: bash
$ sudo -u postgres -i $ sudo -u postgres -i
# \password postgres $ psql
Enter new password: lemur postgres=# CREATE USER lemur WITH PASSWORD 'lemur';
Enter it again: lemur
Once successful, type CTRL-D to exit the Postgres shell. Once successful, type CTRL-D to exit the Postgres shell.
@ -269,8 +271,8 @@ Configuring Supervisor couldn't be more simple. Just point it to the ``lemur`` e
autostart=true autostart=true
autorestart=true autorestart=true
redirect_stderr=true redirect_stderr=true
stdout_logfile syslog stdout_logfile=syslog
stderr_logfile syslog stderr_logfile=syslog
See :ref:`Using Supervisor <UsingSupervisor>` for more details on using Supervisor. See :ref:`Using Supervisor <UsingSupervisor>` for more details on using Supervisor.

View File

@ -1,54 +1,35 @@
alabaster==0.7.8 Flask==0.12
alembic==0.8.6 Flask-RESTful==0.3.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 Flask-SQLAlchemy==2.1
future==0.15.2 Flask-Script==2.0.5
gunicorn==19.4.1 Flask-Migrate==2.1.1
idna==2.1 Flask-Bcrypt==0.7.1
imagesize==0.7.1 Flask-Principal==0.4.0
inflection==0.3.1 Flask-Mail==0.9.1
ipaddress==1.0.16 SQLAlchemy-Utils==0.32.14
itsdangerous==0.24 requests==2.11.1
Jinja2==2.8 ndg-httpsclient==0.4.2
lockfile==0.12.2 psycopg2==2.7.3
Mako==1.0.4 arrow==0.10.0
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 six==1.10.0
snowballstemmer==1.2.1 marshmallow-sqlalchemy==0.13.1
Sphinx==1.4.4 gunicorn==19.7.1
sphinx-rtd-theme==0.1.9 marshmallow==2.13.6
sphinxcontrib-httpdomain==1.5.0 cryptography==1.9
SQLAlchemy==1.0.13 xmltodict==0.11.0
SQLAlchemy-Utils==0.31.4 pyjwt==1.5.2
Werkzeug==0.11.10 lockfile==0.12.2
xmltodict==0.9.2 inflection==0.3.1
future==0.16.0
boto3==1.4.6
acme==0.18.1
retrying==1.3.3
tabulate==0.7.7
pem==17.1.0
raven[flask]==6.1.0
jinja2==2.9.6
# pyldap==2.4.37 # cannot be installed on rtd - required by ldap auth provider
paramiko==2.2.1 # required for lemur_linuxdst plugin
sphinx
sphinxcontrib-httpdomain
sphinx-rtd-theme

View File

@ -232,10 +232,16 @@ gulp.task('package:strip', function () {
gulp.task('addUrlContextPath',['addUrlContextPath:revreplace'], function(){ gulp.task('addUrlContextPath',['addUrlContextPath:revreplace'], function(){
var urlContextPathExists = argv.urlContextPath ? true : false; var urlContextPathExists = argv.urlContextPath ? true : false;
return gulp.src('lemur/static/dist/scripts/main*.js') ['lemur/static/dist/scripts/main*.js',
.pipe(gulpif(urlContextPathExists, replace('api/', argv.urlContextPath + '/api/'))) 'lemur/static/dist/angular/**/*.html']
.pipe(gulpif(urlContextPathExists, replace('angular/', argv.urlContextPath + '/angular/'))) .forEach(function(file){
.pipe(gulp.dest('lemur/static/dist/scripts')) 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(){ gulp.task('addUrlContextPath:revision', function(){

View File

@ -9,10 +9,10 @@ __title__ = "lemur"
__summary__ = ("Certificate management and orchestration service") __summary__ = ("Certificate management and orchestration service")
__uri__ = "https://github.com/Netflix/lemur" __uri__ = "https://github.com/Netflix/lemur"
__version__ = "0.5.0" __version__ = "0.6.0"
__author__ = "The Lemur developers" __author__ = "The Lemur developers"
__email__ = "security@netflix.com" __email__ = "security@netflix.com"
__license__ = "Apache License, Version 2.0" __license__ = "Apache License, Version 2.0"
__copyright__ = "Copyright 2016 {0}".format(__author__) __copyright__ = "Copyright 2017 {0}".format(__author__)

View File

@ -26,6 +26,7 @@ from lemur.notifications.views import mod as notifications_bp
from lemur.sources.views import mod as sources_bp from lemur.sources.views import mod as sources_bp
from lemur.endpoints.views import mod as endpoints_bp from lemur.endpoints.views import mod as endpoints_bp
from lemur.logs.views import mod as logs_bp from lemur.logs.views import mod as logs_bp
from lemur.api_keys.views import mod as api_key_bp
from lemur.__about__ import ( from lemur.__about__ import (
__author__, __copyright__, __email__, __license__, __summary__, __title__, __author__, __copyright__, __email__, __license__, __summary__, __title__,
@ -51,7 +52,8 @@ LEMUR_BLUEPRINTS = (
notifications_bp, notifications_bp,
sources_bp, sources_bp,
endpoints_bp, endpoints_bp,
logs_bp logs_bp,
api_key_bp
) )

View File

41
lemur/api_keys/cli.py Normal file
View File

@ -0,0 +1,41 @@
"""
.. module: lemur.api_keys.cli
:platform: Unix
:copyright: (c) 2017 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
View 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) 2017 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
View File

@ -0,0 +1,57 @@
"""
.. module: lemur.api_keys.schemas
:platform: Unix
:copyright: (c) 2017 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
View File

@ -0,0 +1,97 @@
"""
.. module: lemur.api_keys.service
:platform: Unix
:copyright: (c) 2017 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
View File

@ -0,0 +1,579 @@
"""
.. module: lemur.api_keys.views
:platform: Unix
:copyright: (c) 2017 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
View File

@ -0,0 +1,187 @@
"""
.. module: lemur.auth.ldap
:platform: Unix
:copyright: (c) 2015 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)

View File

@ -33,6 +33,11 @@ class CertificatePermission(Permission):
super(CertificatePermission, self).__init__(*needs) super(CertificatePermission, self).__init__(*needs)
class ApiKeyCreatorPermission(Permission):
def __init__(self):
super(ApiKeyCreatorPermission, self).__init__(RoleNeed('admin'))
RoleMember = namedtuple('role', ['method', 'value']) RoleMember = namedtuple('role', ['method', 'value'])
RoleMemberNeed = partial(RoleMember, 'member') RoleMemberNeed = partial(RoleMember, 'member')

View File

@ -27,6 +27,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from lemur.users import service as user_service from lemur.users import service as user_service
from lemur.api_keys import service as api_key_service
from lemur.auth.permissions import AuthorityCreatorNeed, RoleMemberNeed from lemur.auth.permissions import AuthorityCreatorNeed, RoleMemberNeed
@ -48,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. sessions until the token expires.
:param user: :param user:
@ -58,10 +59,24 @@ def create_token(user):
""" """
expiration_delta = timedelta(days=int(current_app.config.get('LEMUR_TOKEN_EXPIRATION', 1))) expiration_delta = timedelta(days=int(current_app.config.get('LEMUR_TOKEN_EXPIRATION', 1)))
payload = { payload = {
'sub': user.id,
'iat': datetime.utcnow(), 'iat': datetime.utcnow(),
'exp': datetime.utcnow() + expiration_delta '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']) token = jwt.encode(payload, current_app.config['LEMUR_TOKEN_SECRET'])
return token.decode('unicode_escape') return token.decode('unicode_escape')
@ -94,6 +109,16 @@ def login_required(f):
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
return dict(message='Token is invalid'), 403 return dict(message='Token is invalid'), 403
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']) user = user_service.get(payload['sub'])
if not user.active: if not user.active:

View File

@ -7,7 +7,6 @@
""" """
import jwt import jwt
import base64 import base64
import sys
import requests import requests
from flask import Blueprint, current_app from flask import Blueprint, current_app
@ -21,12 +20,180 @@ from lemur.common.utils import get_psuedo_random_string
from lemur.users import service as user_service from lemur.users import service as user_service
from lemur.roles import service as role_service from lemur.roles import service as role_service
from lemur.auth.service import create_token, fetch_token_header, get_rsa_public_key from lemur.auth.service import create_token, fetch_token_header, get_rsa_public_key
import lemur.auth.ldap as ldap
mod = Blueprint('auth', __name__) mod = Blueprint('auth', __name__)
api = Api(mod) 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'])
metrics.send('successful_login', 'counter', 1)
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): class Login(Resource):
""" """
Provides an endpoint for Lemur's basic authentication. It takes a username and password Provides an endpoint for Lemur's basic authentication. It takes a username and password
@ -94,6 +261,7 @@ class Login(Resource):
else: else:
user = user_service.get_by_username(args['username']) user = user_service.get_by_username(args['username'])
# default to local authentication
if user and user.check_password(args['password']) and user.active: if user and user.check_password(args['password']) and user.active:
# Tell Flask-Principal the identity changed # Tell Flask-Principal the identity changed
identity_changed.send(current_app._get_current_object(), identity_changed.send(current_app._get_current_object(),
@ -102,8 +270,26 @@ class Login(Resource):
metrics.send('successful_login', 'counter', 1) metrics.send('successful_login', 'counter', 1)
return dict(token=create_token(user)) return dict(token=create_token(user))
# 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('successful_login', 'counter', 1)
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('invalid_login', 'counter', 1)
return dict(message=ldap_message), 403
# if not valid user - no certificates for you
metrics.send('invalid_login', 'counter', 1) metrics.send('invalid_login', 'counter', 1)
return dict(message='The supplied credentials are invalid'), 401 return dict(message='The supplied credentials are invalid'), 403
class Ping(Resource): class Ping(Resource):
@ -112,14 +298,17 @@ class Ping(Resource):
this example we use an 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: 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 \ 1. Define your own class that inherits from :class:`flask_restful.Resource` and create the HTTP methods the \
provider uses for it's callbacks. provider uses for its callbacks.
2. Add or change the Lemur AngularJS Configuration to point to your new provider 2. Add or change the Lemur AngularJS Configuration to point to your new provider
""" """
def __init__(self): def __init__(self):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(Ping, self).__init__() super(Ping, self).__init__()
def get(self):
return 'Redirecting...'
def post(self): def post(self):
self.reqparse.add_argument('clientId', type=str, required=True, location='json') 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('redirectUri', type=str, required=True, location='json')
@ -127,111 +316,26 @@ class Ping(Resource):
args = self.reqparse.parse_args() 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 # you can either discover these dynamically or simply configure them
access_token_url = current_app.config.get('PING_ACCESS_TOKEN_URL') access_token_url = current_app.config.get('PING_ACCESS_TOKEN_URL')
user_api_url = current_app.config.get('PING_USER_API_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 secret = current_app.config.get('PING_SECRET')
token = '{0}:{1}'.format(args['clientId'], current_app.config.get("PING_SECRET"))
basic = base64.b64encode(bytes(token, 'utf-8')) id_token, access_token = exchange_for_access_token(
headers = {'authorization': 'basic {0}'.format(basic.decode('utf-8'))} 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') 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 user, profile = retrieve_user(user_api_url, access_token)
r = requests.get(jwks_url) roles = create_user_roles(profile)
for key in r.json()['keys']: update_user(user, profile, roles)
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
# validate your token based on the key it was signed with
try:
jwt.decode(id_token, secret.decode('utf-8'), 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.active: if not user.active:
metrics.send('invalid_login', 'counter', 1) metrics.send('invalid_login', 'counter', 1)
@ -249,6 +353,9 @@ class OAuth2(Resource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(OAuth2, self).__init__() super(OAuth2, self).__init__()
def get(self):
return 'Redirecting...'
def post(self): def post(self):
self.reqparse.add_argument('clientId', type=str, required=True, location='json') 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('redirectUri', type=str, required=True, location='json')
@ -256,109 +363,32 @@ class OAuth2(Resource):
args = self.reqparse.parse_args() args = self.reqparse.parse_args()
# take the information we have received from the provider to create a new request
params = {
'grant_type': 'authorization_code',
'scope': 'openid email profile groups',
'redirect_uri': args['redirectUri'],
'code': args['code'],
}
# you can either discover these dynamically or simply configure them # you can either discover these dynamically or simply configure them
access_token_url = current_app.config.get('OAUTH2_ACCESS_TOKEN_URL') access_token_url = current_app.config.get('OAUTH2_ACCESS_TOKEN_URL')
user_api_url = current_app.config.get('OAUTH2_USER_API_URL') user_api_url = current_app.config.get('OAUTH2_USER_API_URL')
verify_cert = current_app.config.get('OAUTH2_VERIFY_CERT')
# the secret and cliendId will be given to you when you signup for the provider secret = current_app.config.get('OAUTH2_SECRET')
token = '{0}:{1}'.format(args['clientId'], current_app.config.get("OAUTH2_SECRET"))
basic = base64.b64encode(bytes(token, 'utf-8')) 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
)
headers = { jwks_url = current_app.config.get('PING_JWKS_URL')
'Content-Type': 'application/x-www-form-urlencoded', validate_id_token(id_token, args['clientId'], jwks_url)
'authorization': 'basic {0}'.format(basic.decode('utf-8'))
}
# exchange authorization code for access token. user, profile = retrieve_user(user_api_url, access_token)
r = requests.post(access_token_url, headers=headers, params=params) roles = create_user_roles(profile)
id_token = r.json()['id_token'] update_user(user, profile, roles)
access_token = r.json()['access_token']
# fetch token public key if not user.active:
header_data = fetch_token_header(id_token) metrics.send('invalid_login', 'counter', 1)
jwks_url = current_app.config.get('OAUTH2_JWKS_URL') return dict(message='The supplied credentials are invalid'), 403
# 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
# 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
headers = {'authorization': 'Bearer {0}'.format(access_token)}
# retrieve information about the current user.
r = requests.get(user_api_url, headers=headers)
profile = r.json()
user = user_service.get_by_email(profile['email'])
metrics.send('successful_login', 'counter', 1)
# update their google 'roles'
roles = []
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['name'],
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['name'],
profile['email'],
True,
profile.get('thumbnailPhotoUrl'), # incase profile isn't google+ enabled
roles
)
# Tell Flask-Principal the identity changed # Tell Flask-Principal the identity changed
identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) identity_changed.send(current_app._get_current_object(), identity=Identity(user.id))
@ -403,7 +433,7 @@ class Google(Resource):
if not user.active: if not user.active:
metrics.send('invalid_login', 'counter', 1) metrics.send('invalid_login', 'counter', 1)
return dict(message='The supplied credentials are invalid.'), 401 return dict(message='The supplied credentials are invalid.'), 403
if user: if user:
metrics.send('successful_login', 'counter', 1) metrics.send('successful_login', 'counter', 1)

View File

@ -23,7 +23,7 @@ class AuthorityInputSchema(LemurInputSchema):
name = fields.String(required=True) name = fields.String(required=True)
owner = fields.Email(required=True) owner = fields.Email(required=True)
description = fields.String() 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_start = ArrowDateTime()
validity_end = ArrowDateTime() validity_end = ArrowDateTime()

View File

@ -16,7 +16,7 @@ from lemur.roles import service as role_service
from lemur.certificates.service import upload 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 an authority with new values. Update an authority with new values.
@ -26,12 +26,11 @@ def update(authority_id, description=None, owner=None, active=None, roles=None):
""" """
authority = get(authority_id) authority = get(authority_id)
if roles: authority.roles = roles
authority.roles = roles
authority.active = active authority.active = active
authority.description = description authority.description = description
authority.owner = owner authority.owner = owner
return database.update(authority) return database.update(authority)

View File

@ -6,17 +6,35 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import sys import sys
import multiprocessing
from tabulate import tabulate
from sqlalchemy import or_
from flask import current_app from flask import current_app
from flask_script import Manager from flask_script import Manager
from flask_principal import Identity, identity_changed
from lemur import database from lemur import database
from lemur.extensions import sentry
from lemur.extensions import metrics from lemur.extensions import metrics
from lemur.plugins.base import plugins
from lemur.deployment import service as deployment_service from lemur.deployment import service as deployment_service
from lemur.endpoints import service as endpoint_service from lemur.endpoints import service as endpoint_service
from lemur.notifications.messaging import send_rotation_notification from lemur.notifications.messaging import send_rotation_notification
from lemur.certificates.service import reissue_certificate, get_certificate_primitives, get_all_pending_reissue, get_by_name, get_all_certs 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 from lemur.certificates.verify import verify_string
@ -29,28 +47,19 @@ def print_certificate_details(details):
:param details: :param details:
:return: :return:
""" """
details, errors = CertificateOutputSchema().dump(details)
print("[+] Re-issuing certificate with the following details: ") print("[+] Re-issuing certificate with the following details: ")
print( print(
"\t[+] Common Name: {common_name}\n" "\t[+] Common Name: {common_name}\n"
"\t[+] Subject Alternate Names: {sans}\n" "\t[+] Subject Alternate Names: {sans}\n"
"\t[+] Authority: {authority_name}\n" "\t[+] Authority: {authority_name}\n"
"\t[+] Validity Start: {validity_start}\n" "\t[+] Validity Start: {validity_start}\n"
"\t[+] Validity End: {validity_end}\n" "\t[+] Validity End: {validity_end}\n".format(
"\t[+] Organization: {organization}\n" common_name=details['commonName'],
"\t[+] Organizational Unit: {organizational_unit}\n" sans=",".join(x['value'] for x in details['extensions']['subAltNames']['names']) or None,
"\t[+] Country: {country}\n" authority_name=details['authority']['name'],
"\t[+] State: {state}\n" validity_start=details['validityStart'],
"\t[+] Location: {location}".format( validity_end=details['validityEnd']
common_name=details['common_name'],
sans=",".join(x['value'] for x in details['extensions']['sub_alt_names']['names']) or None,
authority_name=details['authority'].name,
validity_start=details['validity_start'].isoformat(),
validity_end=details['validity_end'].isoformat(),
organization=details['organization'],
organizational_unit=details['organizational_unit'],
country=details['country'],
state=details['state'],
location=details['location']
) )
) )
@ -123,22 +132,16 @@ def request_reissue(certificate, commit):
:param commit: :param commit:
:return: :return:
""" """
details = get_certificate_primitives(certificate) # 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) print_certificate_details(details)
if commit: if commit:
try: new_cert = reissue_certificate(certificate, replace=True)
new_cert = reissue_certificate(certificate, replace=True) metrics.send('certificate_reissue_success', 'counter', 1)
metrics.send('certificate_reissue_success', 'counter', 1) print("[+] New certificate named: {0}".format(new_cert.name))
print("[+] New certificate named: {0}".format(new_cert.name))
except Exception as e:
metrics.send('certificate_reissue_failure', 'counter', 1)
print(
"[!] Failed to reissue certificate {1} reason: {2}".format(
certificate.name,
e
)
)
@manager.option('-e', '--endpoint', dest='endpoint_name', help='Name of the endpoint you wish to rotate.') @manager.option('-e', '--endpoint', dest='endpoint_name', help='Name of the endpoint you wish to rotate.')
@ -156,34 +159,36 @@ def rotate(endpoint_name, new_certificate_name, old_certificate_name, message, c
print("[+] Starting endpoint rotation.") print("[+] Starting endpoint rotation.")
old_cert = validate_certificate(old_certificate_name) try:
new_cert = validate_certificate(new_certificate_name) old_cert = validate_certificate(old_certificate_name)
endpoint = validate_endpoint(endpoint_name) new_cert = validate_certificate(new_certificate_name)
endpoint = validate_endpoint(endpoint_name)
if endpoint and new_cert: if endpoint and new_cert:
print("[+] Rotating endpoint: {0} to certificate {1}".format(endpoint.name, new_cert.name)) 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) request_rotation(endpoint, new_cert, message, commit)
else: elif old_cert and new_cert:
print("[+] Rotating all endpoints that have new certificates available") print("[+] Rotating all endpoints from {0} to {1}".format(old_cert.name, new_cert.name))
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_failure', 'counter', 1)
print("[!] Failed to rotate endpoint {0} reason: Multiple replacement certificates found.".format(
endpoint.name
))
print("[+] Done!") 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_failure', 'counter', 1)
print("[!] Failed to rotate endpoint {0} reason: Multiple replacement certificates found.".format(
endpoint.name
))
print("[+] Done!")
except Exception as e:
sentry.captureException()
@manager.option('-o', '--old-certificate', dest='old_certificate_name', help='Name of the certificate you wish to reissue.') @manager.option('-o', '--old-certificate', dest='old_certificate_name', help='Name of the certificate you wish to reissue.')
@ -199,16 +204,114 @@ def reissue(old_certificate_name, commit):
print("[+] Starting certificate re-issuance.") print("[+] Starting certificate re-issuance.")
old_cert = validate_certificate(old_certificate_name) try:
old_cert = validate_certificate(old_certificate_name)
if not old_cert: if not old_cert:
for certificate in get_all_pending_reissue(): for certificate in get_all_pending_reissue():
print("[+] {0} is eligible for re-issuance".format(certificate.name)) print("[+] {0} is eligible for re-issuance".format(certificate.name))
request_reissue(certificate, commit) request_reissue(certificate, commit)
else: else:
request_reissue(old_cert, commit) request_reissue(old_cert, commit)
print("[+] Done!") print("[+] Done!")
except Exception as e:
sentry.captureException()
metrics.send('certificate_reissue_failure', 'counter', 1)
print(
"[!] Failed to reissue certificates. Reason: {}".format(
e
)
)
@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)
except Exception as e:
sentry.captureException()
metrics.send('certificate_revoke_failure', 'counter', 1)
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 @manager.command
@ -227,9 +330,10 @@ def check_revoked():
else: else:
status = verify_string(cert.body, "") status = verify_string(cert.body, "")
cert.status = 'valid' if status else 'invalid' cert.status = 'valid' if status else 'revoked'
except Exception as e: except Exception as e:
sentry.captureException()
current_app.logger.exception(e) current_app.logger.exception(e)
cert.status = 'unknown' cert.status = 'unknown'

View 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) 2016-2017 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)

View File

@ -6,6 +6,7 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import arrow import arrow
from datetime import timedelta
from flask import current_app from flask import current_app
@ -15,7 +16,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa
from idna.core import InvalidCodepoint from idna.core import InvalidCodepoint
from sqlalchemy.orm import relationship 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.ext.hybrid import hybrid_property
from sqlalchemy import event, Integer, ForeignKey, String, PassiveDefault, func, Column, Text, Boolean from sqlalchemy import event, Integer, ForeignKey, String, PassiveDefault, func, Column, Text, Boolean
@ -24,6 +25,7 @@ from sqlalchemy_utils.types.arrow import ArrowType
import lemur.common.utils import lemur.common.utils
from lemur.database import db from lemur.database import db
from lemur.extensions import sentry
from lemur.utils import Vault from lemur.utils import Vault
from lemur.common import defaults from lemur.common import defaults
@ -37,6 +39,7 @@ from lemur.models import certificate_associations, certificate_source_associatio
certificate_replacement_associations, roles_certificates certificate_replacement_associations, roles_certificates
from lemur.domains.models import Domain from lemur.domains.models import Domain
from lemur.policies.models import RotationPolicy
def get_sequence(name): def get_sequence(name):
@ -44,29 +47,35 @@ def get_sequence(name):
return name, None return name, None
parts = name.split('-') parts = name.split('-')
end = parts.pop(-1)
root = '-'.join(parts)
if len(end) == 8:
return root + '-' + end, None
# see if we have an int at the end of our name
try: try:
end = int(end) seq = int(parts[-1])
except ValueError: except ValueError:
end = None return name, None
return root, end # 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): def get_or_increase_name(name, serial):
name = '-'.join(name.strip().split(' '))
certificates = Certificate.query.filter(Certificate.name.ilike('{0}%'.format(name))).all() certificates = Certificate.query.filter(Certificate.name.ilike('{0}%'.format(name))).all()
if not certificates: if not certificates:
return name 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] ends = [0]
root, end = get_sequence(name) root, end = get_sequence(serial_name)
for cert in certificates: for cert in certificates:
root, end = get_sequence(cert.name) root, end = get_sequence(cert.name)
if end: if end:
@ -78,8 +87,9 @@ def get_or_increase_name(name):
class Certificate(db.Model): class Certificate(db.Model):
__tablename__ = 'certificates' __tablename__ = 'certificates'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
external_id = Column(String(128))
owner = Column(String(128), nullable=False) owner = Column(String(128), nullable=False)
name = Column(String(128), unique=True) name = Column(String(256), unique=True)
description = Column(String(1024)) description = Column(String(1024))
notify = Column(Boolean, default=True) notify = Column(Boolean, default=True)
@ -102,10 +112,10 @@ class Certificate(db.Model):
san = Column(String(1024)) # TODO this should be migrated to boolean san = Column(String(1024)) # TODO this should be migrated to boolean
rotation = Column(Boolean, default=False) rotation = Column(Boolean, default=False)
user_id = Column(Integer, ForeignKey('users.id')) user_id = Column(Integer, ForeignKey('users.id'))
authority_id = Column(Integer, ForeignKey('authorities.id', ondelete="CASCADE")) authority_id = Column(Integer, ForeignKey('authorities.id', ondelete="CASCADE"))
root_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') notifications = relationship('Notification', secondary=certificate_notification_associations, backref='certificate')
destinations = relationship('Destination', secondary=certificate_destination_associations, backref='certificate') destinations = relationship('Destination', secondary=certificate_destination_associations, backref='certificate')
@ -120,6 +130,9 @@ class Certificate(db.Model):
logs = relationship('Log', backref='certificate') logs = relationship('Log', backref='certificate')
endpoints = relationship('Endpoint', backref='certificate') endpoints = relationship('Endpoint', backref='certificate')
rotation_policy = relationship("RotationPolicy")
sensitive_fields = ('private_key',)
def __init__(self, **kwargs): def __init__(self, **kwargs):
cert = lemur.common.utils.parse_certificate(kwargs['body']) cert = lemur.common.utils.parse_certificate(kwargs['body'])
@ -129,12 +142,14 @@ class Certificate(db.Model):
self.san = defaults.san(cert) self.san = defaults.san(cert)
self.not_before = defaults.not_before(cert) self.not_before = defaults.not_before(cert)
self.not_after = defaults.not_after(cert) self.not_after = defaults.not_after(cert)
self.serial = defaults.serial(cert)
# when destinations are appended they require a valid name. # when destinations are appended they require a valid name.
if kwargs.get('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: 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.owner = kwargs['owner']
self.body = kwargs['body'].strip() self.body = kwargs['body'].strip()
@ -152,9 +167,10 @@ class Certificate(db.Model):
self.roles = list(set(kwargs.get('roles', []))) self.roles = list(set(kwargs.get('roles', [])))
self.replaces = kwargs.get('replaces', []) self.replaces = kwargs.get('replaces', [])
self.rotation = kwargs.get('rotation') self.rotation = kwargs.get('rotation')
self.rotation_policy = kwargs.get('rotation_policy')
self.signing_algorithm = defaults.signing_algorithm(cert) self.signing_algorithm = defaults.signing_algorithm(cert)
self.bits = defaults.bitstrength(cert) self.bits = defaults.bitstrength(cert)
self.serial = defaults.serial(cert) self.external_id = kwargs.get('external_id')
for domain in defaults.domains(cert): for domain in defaults.domains(cert):
self.domains.append(Domain(name=domain)) self.domains.append(Domain(name=domain))
@ -240,6 +256,33 @@ class Certificate(db.Model):
else_=False else_=False
) )
@hybrid_property
def in_rotation_window(self):
"""
Determines if a certificate is available for rotation based
on the rotation policy associated.
:return:
"""
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 @property
def extensions(self): def extensions(self):
# setup default values # setup default values
@ -291,20 +334,15 @@ class Certificate(db.Model):
else: else:
current_app.logger.warning('Custom OIDs not yet supported for clone operation.') current_app.logger.warning('Custom OIDs not yet supported for clone operation.')
except InvalidCodepoint as e: except InvalidCodepoint as e:
sentry.captureException()
current_app.logger.warning('Unable to parse extensions due to underscore in dns name') 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 return return_extensions
def get_arn(self, account_number):
"""
Generate a valid AWS IAM arn
:rtype : str
:param account_number:
:return:
"""
return "arn:aws:iam::{}:server-certificate/{}".format(account_number, self.name)
def __repr__(self): def __repr__(self):
return "Certificate(name={name})".format(name=self.name) return "Certificate(name={name})".format(name=self.name)
@ -325,8 +363,10 @@ def update_destinations(target, value, initiator):
if target.private_key: if target.private_key:
destination_plugin.upload(target.name, target.body, target.private_key, target.chain, value.options) destination_plugin.upload(target.name, target.body, target.private_key, target.chain, value.options)
except Exception as e: except Exception as e:
sentry.captureException()
current_app.logger.exception(e) current_app.logger.exception(e)
metrics.send('destination_upload_failure', 'counter', 1, metric_tags={'certificate': target.name, 'destination': value.label}) metrics.send('destination_upload_failure', 'counter', 1,
metric_tags={'certificate': target.name, 'destination': value.label})
@event.listens_for(Certificate.replaces, 'append') @event.listens_for(Certificate.replaces, 'append')

View File

@ -9,8 +9,17 @@ from flask import current_app
from marshmallow import fields, validate, validates_schema, post_load, pre_load from marshmallow import fields, validate, validates_schema, post_load, pre_load
from marshmallow.exceptions import ValidationError from marshmallow.exceptions import ValidationError
from lemur.schemas import AssociatedAuthoritySchema, AssociatedDestinationSchema, AssociatedCertificateSchema, \ from lemur.schemas import (
AssociatedNotificationSchema, PluginInputSchema, ExtensionSchema, AssociatedRoleSchema, EndpointNestedOutputSchema AssociatedAuthoritySchema,
AssociatedDestinationSchema,
AssociatedCertificateSchema,
AssociatedNotificationSchema,
PluginInputSchema,
ExtensionSchema,
AssociatedRoleSchema,
EndpointNestedOutputSchema,
AssociatedRotationPolicySchema
)
from lemur.authorities.schemas import AuthorityNestedOutputSchema from lemur.authorities.schemas import AuthorityNestedOutputSchema
from lemur.destinations.schemas import DestinationNestedOutputSchema from lemur.destinations.schemas import DestinationNestedOutputSchema
@ -18,17 +27,18 @@ from lemur.notifications.schemas import NotificationNestedOutputSchema
from lemur.roles.schemas import RoleNestedOutputSchema from lemur.roles.schemas import RoleNestedOutputSchema
from lemur.domains.schemas import DomainNestedOutputSchema from lemur.domains.schemas import DomainNestedOutputSchema
from lemur.users.schemas import UserNestedOutputSchema from lemur.users.schemas import UserNestedOutputSchema
from lemur.policies.schemas import RotationPolicyNestedOutputSchema
from lemur.common.schema import LemurInputSchema, LemurOutputSchema from lemur.common.schema import LemurInputSchema, LemurOutputSchema
from lemur.common import validators, missing from lemur.common import validators, missing
from lemur.notifications import service as notification_service from lemur.notifications import service as notification_service
from lemur.common.fields import ArrowDateTime from lemur.common.fields import ArrowDateTime, Hex
class CertificateSchema(LemurInputSchema): class CertificateSchema(LemurInputSchema):
owner = fields.Email(required=True) owner = fields.Email(required=True)
description = fields.String() description = fields.String(missing='', allow_none=True)
class CertificateCreationSchema(CertificateSchema): class CertificateCreationSchema(CertificateSchema):
@ -45,7 +55,7 @@ class CertificateCreationSchema(CertificateSchema):
class CertificateInputSchema(CertificateCreationSchema): class CertificateInputSchema(CertificateCreationSchema):
name = fields.String() 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) authority = fields.Nested(AssociatedAuthoritySchema, required=True)
validity_start = ArrowDateTime() validity_start = ArrowDateTime()
@ -63,6 +73,7 @@ class CertificateInputSchema(CertificateCreationSchema):
notify = fields.Boolean(default=True) notify = fields.Boolean(default=True)
rotation = fields.Boolean() rotation = fields.Boolean()
rotation_policy = fields.Nested(AssociatedRotationPolicySchema, missing={'name': 'default'}, default={'name': 'default'})
# certificate body fields # certificate body fields
organizational_unit = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_ORGANIZATIONAL_UNIT')) organizational_unit = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_ORGANIZATIONAL_UNIT'))
@ -73,6 +84,11 @@ class CertificateInputSchema(CertificateCreationSchema):
extensions = fields.Nested(ExtensionSchema) 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 @validates_schema
def validate_dates(self, data): def validate_dates(self, data):
validators.dates(data) validators.dates(data)
@ -124,7 +140,7 @@ class CertificateNestedOutputSchema(LemurOutputSchema):
creator = fields.Nested(UserNestedOutputSchema) creator = fields.Nested(UserNestedOutputSchema)
description = fields.String() description = fields.String()
status = fields.Boolean() status = fields.String()
bits = fields.Integer() bits = fields.Integer()
body = fields.String() body = fields.String()
@ -133,8 +149,9 @@ class CertificateNestedOutputSchema(LemurOutputSchema):
rotation = fields.Boolean() rotation = fields.Boolean()
notify = fields.Boolean() notify = fields.Boolean()
rotation_policy = fields.Nested(RotationPolicyNestedOutputSchema)
# Note aliasing is the first step in deprecating these fields. # Note aliasing is the first step in deprecating these fields.
cn = fields.String() # deprecated cn = fields.String() # deprecated
common_name = fields.String(attribute='cn') common_name = fields.String(attribute='cn')
@ -155,6 +172,7 @@ class CertificateCloneSchema(LemurOutputSchema):
class CertificateOutputSchema(LemurOutputSchema): class CertificateOutputSchema(LemurOutputSchema):
id = fields.Integer() id = fields.Integer()
external_id = fields.String()
bits = fields.Integer() bits = fields.Integer()
body = fields.String() body = fields.String()
chain = fields.String() chain = fields.String()
@ -165,7 +183,7 @@ class CertificateOutputSchema(LemurOutputSchema):
rotation = fields.Boolean() rotation = fields.Boolean()
# Note aliasing is the first step in deprecating these fields. # Note aliasing is the first step in deprecating these fields.
notify = fields.Boolean() notify = fields.Boolean()
active = fields.Boolean(attribute='notify') active = fields.Boolean(attribute='notify')
@ -181,9 +199,10 @@ class CertificateOutputSchema(LemurOutputSchema):
owner = fields.Email() owner = fields.Email()
san = fields.Boolean() san = fields.Boolean()
serial = fields.String() serial = fields.String()
serial_hex = Hex(attribute='serial')
signing_algorithm = fields.String() signing_algorithm = fields.String()
status = fields.Boolean() status = fields.String()
user = fields.Nested(UserNestedOutputSchema) user = fields.Nested(UserNestedOutputSchema)
extensions = fields.Nested(ExtensionSchema) extensions = fields.Nested(ExtensionSchema)
@ -197,6 +216,7 @@ class CertificateOutputSchema(LemurOutputSchema):
roles = fields.Nested(RoleNestedOutputSchema, many=True) roles = fields.Nested(RoleNestedOutputSchema, many=True)
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[]) endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])
replaced_by = fields.Nested(CertificateNestedOutputSchema, many=True, attribute='replaced') replaced_by = fields.Nested(CertificateNestedOutputSchema, many=True, attribute='replaced')
rotation_policy = fields.Nested(RotationPolicyNestedOutputSchema)
class CertificateUploadInputSchema(CertificateCreationSchema): class CertificateUploadInputSchema(CertificateCreationSchema):
@ -234,6 +254,10 @@ class CertificateNotificationOutputSchema(LemurOutputSchema):
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[]) endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])
class CertificateRevokeSchema(LemurInputSchema):
comments = fields.String()
certificate_input_schema = CertificateInputSchema() certificate_input_schema = CertificateInputSchema()
certificate_output_schema = CertificateOutputSchema() certificate_output_schema = CertificateOutputSchema()
certificates_output_schema = CertificateOutputSchema(many=True) certificates_output_schema = CertificateOutputSchema(many=True)
@ -241,3 +265,4 @@ certificate_upload_input_schema = CertificateUploadInputSchema()
certificate_export_input_schema = CertificateExportInputSchema() certificate_export_input_schema = CertificateExportInputSchema()
certificate_edit_input_schema = CertificateEditInputSchema() certificate_edit_input_schema = CertificateEditInputSchema()
certificate_notification_output_schema = CertificateNotificationOutputSchema() certificate_notification_output_schema = CertificateNotificationOutputSchema()
certificate_revoke_schema = CertificateRevokeSchema()

View File

@ -6,7 +6,6 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import arrow import arrow
from datetime import timedelta
from flask import current_app from flask import current_app
from sqlalchemy import func, or_, not_, cast, Boolean, Integer from sqlalchemy import func, or_, not_, cast, Boolean, Integer
@ -16,7 +15,7 @@ from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from lemur import database from lemur import database
from lemur.extensions import metrics from lemur.extensions import metrics, signals
from lemur.plugins.base import plugins from lemur.plugins.base import plugins
from lemur.common.utils import generate_private_key from lemur.common.utils import generate_private_key
@ -32,6 +31,12 @@ from lemur.certificates.schemas import CertificateOutputSchema, CertificateInput
from lemur.roles import service as role_service 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): def get(cert_id):
""" """
Retrieves certificate by its ID. Retrieves certificate by its ID.
@ -52,6 +57,15 @@ def get_by_name(name):
return database.get(Certificate, name, field='name') return database.get(Certificate, name, field='name')
def get_by_serial(serial):
"""
Retrieves certificate by it's Serial.
:param serial:
:return:
"""
return Certificate.query.filter(Certificate.serial == serial).all()
def delete(cert_id): def delete(cert_id):
""" """
Delete's a certificate. Delete's a certificate.
@ -85,20 +99,15 @@ def get_all_pending_reissue():
""" """
Retrieves all certificates that need to be rotated. Retrieves all certificates that need to be rotated.
Must be X days from expiration, uses `LEMUR_DEFAULT_ROTATION_INTERVAL` Must be X days from expiration, uses the certificates rotation
to determine how many days from expiration the certificate must be policy to determine how many days from expiration the certificate must be
for rotation to be pending. for rotation to be pending.
:return: :return:
""" """
now = arrow.utcnow()
interval = current_app.config.get('LEMUR_DEFAULT_ROTATION_INTERVAL', 30)
end = now + timedelta(days=interval)
return Certificate.query.filter(Certificate.rotation == True)\ return Certificate.query.filter(Certificate.rotation == True)\
.filter(Certificate.endpoints.any())\
.filter(not_(Certificate.replaced.any()))\ .filter(not_(Certificate.replaced.any()))\
.filter(Certificate.not_after <= end.format('YYYY-MM-DD')).all() # noqa .filter(Certificate.in_rotation_window == True).all() # noqa
def find_duplicates(cert): def find_duplicates(cert):
@ -174,12 +183,14 @@ def mint(**kwargs):
# allow the CSR to be specified by the user # allow the CSR to be specified by the user
if not kwargs.get('csr'): if not kwargs.get('csr'):
csr, private_key = create_csr(**kwargs) csr, private_key = create_csr(**kwargs)
csr_created.send(authority=authority, csr=csr)
else: else:
csr = str(kwargs.get('csr')) csr = str(kwargs.get('csr'))
private_key = None private_key = None
csr_imported.send(authority=authority, csr=csr)
cert_body, cert_chain = issuer.create_certificate(csr, kwargs) cert_body, cert_chain, external_id = issuer.create_certificate(csr, kwargs)
return cert_body, private_key, cert_chain, return cert_body, private_key, cert_chain, external_id
def import_certificate(**kwargs): def import_certificate(**kwargs):
@ -222,17 +233,21 @@ def upload(**kwargs):
cert = database.create(cert) cert = database.create(cert)
kwargs['creator'].certificates.append(cert) 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): def create(**kwargs):
""" """
Creates a new certificate. Creates a new certificate.
""" """
cert_body, private_key, cert_chain = mint(**kwargs) cert_body, private_key, cert_chain, external_id = mint(**kwargs)
kwargs['body'] = cert_body kwargs['body'] = cert_body
kwargs['private_key'] = private_key kwargs['private_key'] = private_key
kwargs['chain'] = cert_chain kwargs['chain'] = cert_chain
kwargs['external_id'] = external_id
roles = create_certificate_roles(**kwargs) roles = create_certificate_roles(**kwargs)
@ -245,6 +260,8 @@ def create(**kwargs):
kwargs['creator'].certificates.append(cert) kwargs['creator'].certificates.append(cert)
cert.authority = kwargs['authority'] cert.authority = kwargs['authority']
certificate_issued.send(certificate=cert, authority=cert.authority)
database.commit() database.commit()
metrics.send('certificate_issued', 'counter', 1, metric_tags=dict(owner=cert.owner, issuer=cert.issuer)) metrics.send('certificate_issued', 'counter', 1, metric_tags=dict(owner=cert.owner, issuer=cert.issuer))
@ -284,7 +301,6 @@ def render(args):
Certificate.authority_id.in_(sub_query) Certificate.authority_id.in_(sub_query)
) )
) )
return database.sort_and_page(query, Certificate, args)
elif 'destination' in terms: elif 'destination' in terms:
query = query.filter(Certificate.destinations.any(Destination.id == terms[1])) query = query.filter(Certificate.destinations.any(Destination.id == terms[1]))
@ -337,8 +353,9 @@ def create_csr(**csr_config):
private_key = generate_private_key(csr_config.get('key_type')) private_key = generate_private_key(csr_config.get('key_type'))
builder = x509.CertificateSigningRequestBuilder() builder = x509.CertificateSigningRequestBuilder()
name_list = [x509.NameAttribute(x509.OID_COMMON_NAME, csr_config['common_name']), name_list = [x509.NameAttribute(x509.OID_COMMON_NAME, csr_config['common_name'])]
x509.NameAttribute(x509.OID_EMAIL_ADDRESS, csr_config['owner'])] 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(): if 'organization' in csr_config and csr_config['organization'].strip():
name_list.append(x509.NameAttribute(x509.OID_ORGANIZATION_NAME, csr_config['organization'])) name_list.append(x509.NameAttribute(x509.OID_ORGANIZATION_NAME, csr_config['organization']))
if 'organizational_unit' in csr_config and csr_config['organizational_unit'].strip(): if 'organizational_unit' in csr_config and csr_config['organizational_unit'].strip():
@ -359,7 +376,8 @@ def create_csr(**csr_config):
if k in critical_extensions: if k in critical_extensions:
current_app.logger.debug('Adding Critical Extension: {0} {1}'.format(k, v)) current_app.logger.debug('Adding Critical Extension: {0} {1}'.format(k, v))
if k == 'sub_alt_names': if k == 'sub_alt_names':
builder = builder.add_extension(v['names'], critical=True) if v['names']:
builder = builder.add_extension(v['names'], critical=True)
else: else:
builder = builder.add_extension(v, critical=True) builder = builder.add_extension(v, critical=True)
@ -475,6 +493,12 @@ def get_certificate_primitives(certificate):
# we will rely on the Lemur generated name # we will rely on the Lemur generated name
data.pop('name', None) data.pop('name', None)
# 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_start'] = start
data['validity_end'] = end data['validity_end'] = end
return data return data

View File

@ -7,7 +7,7 @@
""" """
import requests import requests
import subprocess import subprocess
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError, InvalidSchema
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
@ -19,7 +19,7 @@ def ocsp_verify(cert_path, issuer_chain_path):
""" """
Attempts to verify a certificate via OCSP. OCSP is a more modern version 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 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 cert_path:
:param issuer_chain_path: :param issuer_chain_path:
@ -69,6 +69,9 @@ def crl_verify(cert_path):
if response.status_code != 200: if response.status_code != 200:
raise Exception("Unable to retrieve CRL: {0}".format(point)) raise Exception("Unable to retrieve CRL: {0}".format(point))
except InvalidSchema:
# Unhandled URI scheme (like ldap://); skip this distribution point.
continue
except ConnectionError: except ConnectionError:
raise Exception("Unable to retrieve CRL: {0}".format(point)) raise Exception("Unable to retrieve CRL: {0}".format(point))
@ -76,6 +79,15 @@ def crl_verify(cert_path):
for r in crl: for r in crl:
if cert.serial == r.serial_number: 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
return True return True

View File

@ -18,8 +18,15 @@ from lemur.auth.service import AuthenticatedResource
from lemur.auth.permissions import AuthorityPermission, CertificatePermission from lemur.auth.permissions import AuthorityPermission, CertificatePermission
from lemur.certificates import service from lemur.certificates import service
from lemur.certificates.schemas import certificate_input_schema, certificate_output_schema, \ from lemur.plugins.base import plugins
certificate_upload_input_schema, certificates_output_schema, certificate_export_input_schema, certificate_edit_input_schema 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.roles import service as role_service
from lemur.logs import service as log_service from lemur.logs import service as log_service
@ -84,7 +91,7 @@ class CertificatesList(AuthenticatedResource):
"deleted": null, "deleted": null,
"notifications": [{ "notifications": [{
"id": 1 "id": 1
}] }],
"signingAlgorithm": "sha256", "signingAlgorithm": "sha256",
"user": { "user": {
"username": "jane", "username": "jane",
@ -169,7 +176,7 @@ class CertificatesList(AuthenticatedResource):
}, },
"replacements": [{ "replacements": [{
"id": 1 "id": 1
}, }],
"notify": true, "notify": true,
"validityEnd": "2026-01-01T08:00:00.000Z", "validityEnd": "2026-01-01T08:00:00.000Z",
"authority": { "authority": {
@ -215,7 +222,7 @@ class CertificatesList(AuthenticatedResource):
"deleted": null, "deleted": null,
"notifications": [{ "notifications": [{
"id": 1 "id": 1
}] }],
"signingAlgorithm": "sha256", "signingAlgorithm": "sha256",
"user": { "user": {
"username": "jane", "username": "jane",
@ -232,6 +239,8 @@ class CertificatesList(AuthenticatedResource):
"replaces": [{ "replaces": [{
"id": 1 "id": 1
}], }],
"rotation": true,
"rotationPolicy": {"name": "default"},
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112", "name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{ "roles": [{
"id": 464, "id": 464,
@ -241,18 +250,6 @@ class CertificatesList(AuthenticatedResource):
"san": null "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 :reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error :statuscode 200: no error
:statuscode 403: unauthenticated :statuscode 403: unauthenticated
@ -269,9 +266,11 @@ class CertificatesList(AuthenticatedResource):
if authority_permission.can(): if authority_permission.can():
data['creator'] = g.user data['creator'] = g.user
return service.create(**data) cert = service.create(**data)
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): class CertificatesUpload(AuthenticatedResource):
@ -339,7 +338,7 @@ class CertificatesUpload(AuthenticatedResource):
"deleted": null, "deleted": null,
"notifications": [{ "notifications": [{
"id": 1 "id": 1
}] }],
"signingAlgorithm": "sha256", "signingAlgorithm": "sha256",
"user": { "user": {
"username": "jane", "username": "jane",
@ -354,6 +353,8 @@ class CertificatesUpload(AuthenticatedResource):
"name": "*.test.example.net" "name": "*.test.example.net"
}], }],
"replaces": [], "replaces": [],
"rotation": true,
"rotationPolicy": {"name": "default"},
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112", "name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{ "roles": [{
"id": 464, "id": 464,
@ -363,11 +364,6 @@ class CertificatesUpload(AuthenticatedResource):
"san": null "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 :reqheader Authorization: OAuth token to authenticate
:statuscode 403: unauthenticated :statuscode 403: unauthenticated
:statuscode 200: no error :statuscode 200: no error
@ -428,7 +424,7 @@ class CertificatePrivateKey(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"key": "-----BEGIN ...", "key": "-----BEGIN ..."
} }
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
@ -506,7 +502,7 @@ class Certificates(AuthenticatedResource):
"deleted": null, "deleted": null,
"notifications": [{ "notifications": [{
"id": 1 "id": 1
}] }],
"signingAlgorithm": "sha256", "signingAlgorithm": "sha256",
"user": { "user": {
"username": "jane", "username": "jane",
@ -520,6 +516,8 @@ class Certificates(AuthenticatedResource):
"id": 1090, "id": 1090,
"name": "*.test.example.net" "name": "*.test.example.net"
}], }],
"rotation": true,
"rotationPolicy": {"name": "default"},
"replaces": [], "replaces": [],
"replaced": [], "replaced": [],
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112", "name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
@ -614,6 +612,8 @@ class Certificates(AuthenticatedResource):
"description": "This is a google group based role created by Lemur", "description": "This is a google group based role created by Lemur",
"name": "joe@example.com" "name": "joe@example.com"
}], }],
"rotation": true,
"rotationPolicy": {"name": "default"},
"san": null "san": null
} }
@ -644,7 +644,9 @@ class Certificates(AuthenticatedResource):
) )
), 400 ), 400
return service.update(certificate_id, **data) cert = service.update(certificate_id, **data)
log_service.create(g.current_user, 'update_cert', certificate=cert)
return cert
class NotificationCertificatesList(AuthenticatedResource): class NotificationCertificatesList(AuthenticatedResource):
@ -702,7 +704,7 @@ class NotificationCertificatesList(AuthenticatedResource):
"deleted": null, "deleted": null,
"notifications": [{ "notifications": [{
"id": 1 "id": 1
}] }],
"signingAlgorithm": "sha256", "signingAlgorithm": "sha256",
"user": { "user": {
"username": "jane", "username": "jane",
@ -718,6 +720,8 @@ class NotificationCertificatesList(AuthenticatedResource):
}], }],
"replaces": [], "replaces": [],
"replaced": [], "replaced": [],
"rotation": true,
"rotationPolicy": {"name": "default"},
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112", "name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{ "roles": [{
"id": 464, "id": 464,
@ -823,6 +827,8 @@ class CertificatesReplacementsList(AuthenticatedResource):
}], }],
"replaces": [], "replaces": [],
"replaced": [], "replaced": [],
"rotation": true,
"rotationPolicy": {"name": "default"},
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112", "name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{ "roles": [{
"id": 464, "id": 464,
@ -945,6 +951,69 @@ class CertificateExport(AuthenticatedResource):
return dict(extension=extension, passphrase=passphrase, data=base64.b64encode(data).decode('utf-8')) return dict(extension=extension, passphrase=passphrase, data=base64.b64encode(data).decode('utf-8'))
class CertificateRevoke(AuthenticatedResource):
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(CertificateRevoke, self).__init__()
@validate_schema(None, None)
def put(self, certificate_id, data=None):
"""
.. http:put:: /certificates/1/revoke
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(CertificatesList, '/certificates', endpoint='certificates')
api.add_resource(Certificates, '/certificates/<int:certificate_id>', endpoint='certificate') api.add_resource(Certificates, '/certificates/<int:certificate_id>', endpoint='certificate')
api.add_resource(CertificatesStats, '/certificates/stats', endpoint='certificateStats') api.add_resource(CertificatesStats, '/certificates/stats', endpoint='certificateStats')

View File

@ -1,8 +1,26 @@
import re
import unicodedata
from cryptography import x509 from cryptography import x509
from flask import current_app from flask import current_app
from lemur.extensions import sentry
from lemur.constants import SAN_NAMING_TEMPLATE, DEFAULT_NAMING_TEMPLATE 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 (ä => a): 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): def certificate_name(common_name, issuer, not_before, not_after, san):
""" """
Create a name for our certificate. A naming standard 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( temp = t.format(
subject=common_name, subject=common_name,
issuer=issuer, issuer=issuer.replace(' ', ''),
not_before=not_before.strftime('%Y%m%d'), not_before=not_before.strftime('%Y%m%d'),
not_after=not_after.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") temp = temp.replace('*', "WILDCARD")
return text_to_slug(temp)
for c in disallowed_chars:
temp = temp.replace(c, "")
# white space is silly too
return temp.replace(" ", "-")
def signing_algorithm(cert): def signing_algorithm(cert):
@ -58,6 +68,7 @@ def common_name(cert):
x509.OID_COMMON_NAME x509.OID_COMMON_NAME
)[0].value.strip() )[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException()
current_app.logger.error("Unable to get common name! {0}".format(e)) current_app.logger.error("Unable to get common name! {0}".format(e))
@ -72,6 +83,7 @@ def organization(cert):
x509.OID_ORGANIZATION_NAME x509.OID_ORGANIZATION_NAME
)[0].value.strip() )[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException()
current_app.logger.error("Unable to get organization! {0}".format(e)) current_app.logger.error("Unable to get organization! {0}".format(e))
@ -86,6 +98,7 @@ def organizational_unit(cert):
x509.OID_ORGANIZATIONAL_UNIT_NAME x509.OID_ORGANIZATIONAL_UNIT_NAME
)[0].value.strip() )[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException()
current_app.logger.error("Unable to get organizational unit! {0}".format(e)) current_app.logger.error("Unable to get organizational unit! {0}".format(e))
@ -100,6 +113,7 @@ def country(cert):
x509.OID_COUNTRY_NAME x509.OID_COUNTRY_NAME
)[0].value.strip() )[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException()
current_app.logger.error("Unable to get country! {0}".format(e)) current_app.logger.error("Unable to get country! {0}".format(e))
@ -114,6 +128,7 @@ def state(cert):
x509.OID_STATE_OR_PROVINCE_NAME x509.OID_STATE_OR_PROVINCE_NAME
)[0].value.strip() )[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException()
current_app.logger.error("Unable to get state! {0}".format(e)) current_app.logger.error("Unable to get state! {0}".format(e))
@ -128,6 +143,7 @@ def location(cert):
x509.OID_LOCALITY_NAME x509.OID_LOCALITY_NAME
)[0].value.strip() )[0].value.strip()
except Exception as e: except Exception as e:
sentry.captureException()
current_app.logger.error("Unable to get location! {0}".format(e)) current_app.logger.error("Unable to get location! {0}".format(e))
@ -147,7 +163,7 @@ def domains(cert):
for entry in entries: for entry in entries:
domains.append(entry) domains.append(entry)
except Exception as e: except Exception as e:
pass sentry.captureException()
return domains return domains
@ -199,6 +215,7 @@ def bitstrength(cert):
try: try:
return cert.public_key().key_size return cert.public_key().key_size
except AttributeError: except AttributeError:
sentry.captureException()
current_app.logger.debug('Unable to get bitstrength.') current_app.logger.debug('Unable to get bitstrength.')
@ -219,6 +236,7 @@ def issuer(cert):
issuer = issuer.replace(c, "") issuer = issuer.replace(c, "")
return issuer return issuer
except Exception as e: except Exception as e:
sentry.captureException()
current_app.logger.error("Unable to get issuer! {0}".format(e)) current_app.logger.error("Unable to get issuer! {0}".format(e))
return "Unknown" return "Unknown"

View File

@ -18,6 +18,18 @@ from marshmallow import utils
from marshmallow.fields import Field from marshmallow.fields import Field
from marshmallow.exceptions import ValidationError 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): class ArrowDateTime(Field):
"""A formatted datetime string in UTC. """A formatted datetime string in UTC.
@ -317,7 +329,12 @@ class SubjectAlternativeNameExtension(Field):
name_type = 'DNSName' name_type = 'DNSName'
elif isinstance(name, x509.IPAddress): elif isinstance(name, x509.IPAddress):
name_type = 'IPAddress' if isinstance(value, ipaddress.IPv4Network):
name_type = 'IPNetwork'
else:
name_type = 'IPAddress'
value = str(value)
elif isinstance(name, x509.UniformResourceIdentifier): elif isinstance(name, x509.UniformResourceIdentifier):
name_type = 'uniformResourceIdentifier' name_type = 'uniformResourceIdentifier'
@ -342,6 +359,7 @@ class SubjectAlternativeNameExtension(Field):
general_names = [] general_names = []
for name in value: for name in value:
if name['nameType'] == 'DNSName': if name['nameType'] == 'DNSName':
validators.sensitive_domain(name['value'])
general_names.append(x509.DNSName(name['value'])) general_names.append(x509.DNSName(name['value']))
elif name['nameType'] == 'IPAddress': elif name['nameType'] == 'IPAddress':

View File

@ -7,10 +7,23 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
from flask import Blueprint from flask import Blueprint
from lemur.database import db
from lemur.extensions import sentry
mod = Blueprint('healthCheck', __name__) mod = Blueprint('healthCheck', __name__)
@mod.route('/healthcheck') @mod.route('/healthcheck')
def health(): 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

View File

@ -15,6 +15,8 @@ from sqlalchemy.orm.collections import InstrumentedList
from inflection import camelize, underscore from inflection import camelize, underscore
from marshmallow import Schema, post_dump, pre_load from marshmallow import Schema, post_dump, pre_load
from lemur.extensions import sentry
class LemurSchema(Schema): class LemurSchema(Schema):
""" """
@ -157,6 +159,7 @@ def validate_schema(input_schema, output_schema):
try: try:
resp = f(*args, **kwargs) resp = f(*args, **kwargs)
except Exception as e: except Exception as e:
sentry.captureException()
current_app.logger.exception(e) current_app.logger.exception(e)
return dict(message=str(e)), 500 return dict(message=str(e)), 500

View File

@ -53,6 +53,14 @@ def parse_certificate(body):
return x509.load_pem_x509_certificate(body, default_backend()) return x509.load_pem_x509_certificate(body, 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): def generate_private_key(key_type):
""" """
Generates a new private key based on key_type. Generates a new private key based on key_type.

View File

@ -1,9 +1,10 @@
import re import re
from flask import current_app
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.x509 import NameOID
from flask import current_app
from marshmallow.exceptions import ValidationError from marshmallow.exceptions import ValidationError
from lemur.auth.permissions import SensitiveDomainPermission from lemur.auth.permissions import SensitiveDomainPermission
@ -41,22 +42,33 @@ def private_key(key):
raise ValidationError('Private key presented is not valid.') 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): def sensitive_domain(domain):
""" """
Determines if domain has been marked as sensitive. Checks if user has the admin role, the domain does not match sensitive domains and whitelisted domain patterns.
:param domain: :param domain: domain name (str)
:return: :return:
""" """
restricted_domains = current_app.config.get('LEMUR_RESTRICTED_DOMAINS', []) if SensitiveDomainPermission().can():
if restricted_domains: # User has permission, no need to check anything
domains = domain_service.get_by_name(domain) return
for domain in domains:
# we only care about non-admins whitelist = current_app.config.get('LEMUR_WHITELISTED_DOMAINS', [])
if not SensitiveDomainPermission().can(): if whitelist and not any(re.match(pattern, domain) for pattern in whitelist):
if domain.sensitive or any([re.match(pattern, domain.name) for pattern in restricted_domains]): raise ValidationError('Domain {0} does not match whitelisted domain patterns. '
raise ValidationError( 'Contact an administrator to issue the certificate.'.format(domain))
'Domain {0} has been marked as sensitive, contact and 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): def encoding(oid_encoding):
@ -84,15 +96,27 @@ def sub_alt_type(alt_type):
def csr(data): def csr(data):
""" """
Determines if the CSR is valid. Determines if the CSR is valid and allowed.
:param data: :param data:
:return: :return:
""" """
try: try:
x509.load_pem_x509_csr(data.encode('utf-8'), default_backend()) request = x509.load_pem_x509_csr(data.encode('utf-8'), default_backend())
except Exception: except Exception:
raise ValidationError('CSR presented is not valid.') 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): def dates(data):
if not data.get('validity_start') and data.get('validity_end'): if not data.get('validity_start') and data.get('validity_end'):

View File

@ -9,6 +9,7 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
from inflection import underscore
from sqlalchemy import exc from sqlalchemy import exc
from sqlalchemy.sql import and_, or_ from sqlalchemy.sql import and_, or_
from sqlalchemy.orm import make_transient from sqlalchemy.orm import make_transient
@ -75,6 +76,16 @@ def add(model):
db.session.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): def find_all(query, model, kwargs):
""" """
Returns a query object that ensures that all 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): if not isinstance(value, list):
value = value.split(',') value = value.split(',')
conditions.append(getattr(model, attr).in_(value)) conditions.append(get_model_column(model, attr).in_(value))
return query.filter(and_(*conditions)) return query.filter(and_(*conditions))
@ -108,7 +119,7 @@ def find_any(query, model, kwargs):
""" """
or_args = [] or_args = []
for attr, value in kwargs.items(): 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) exprs = or_(*or_args)
return query.filter(exprs) return query.filter(exprs)
@ -123,7 +134,7 @@ def get(model, value, field="id"):
:return: :return:
""" """
query = session_query(model) 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"): def get_all(model, value, field="id"):
@ -136,7 +147,7 @@ def get_all(model, value, field="id"):
:return: :return:
""" """
query = session_query(model) query = session_query(model)
return query.filter(getattr(model, field) == value) return query.filter(get_model_column(model, field) == value)
def create(model): def create(model):
@ -188,7 +199,8 @@ def filter(query, model, terms):
:param terms: :param terms:
:return: :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): def sort(query, model, field, direction):
@ -201,13 +213,8 @@ def sort(query, model, field, direction):
:param field: :param field:
:param direction: :param direction:
""" """
try: column = get_model_column(model, underscore(field))
field = getattr(model, field) return query.order_by(column.desc() if direction == 'desc' else column.asc())
direction = getattr(field, direction)
query = query.order_by(direction())
return query
except AttributeError:
raise AttrNotFound(field)
def paginate(query, page, count): def paginate(query, page, count):

View File

@ -9,7 +9,7 @@ THREADS_PER_PAGE = 8
# These will need to be set to `True` if you are developing locally # These will need to be set to `True` if you are developing locally
CORS = False CORS = False
debug = False DEBUG = False
# Logging # Logging

View File

@ -10,7 +10,6 @@ def rotate_certificate(endpoint, new_cert):
:return: :return:
""" """
# ensure that certificate is available for rotation # ensure that certificate is available for rotation
endpoint.source.plugin.update_endpoint(endpoint, new_cert) endpoint.source.plugin.update_endpoint(endpoint, new_cert)
endpoint.certificate = new_cert endpoint.certificate = new_cert
database.update(endpoint) database.update(endpoint)

View File

@ -22,6 +22,11 @@ def create(label, plugin_name, options, description=None):
:rtype : Destination :rtype : Destination
:return: New 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) destination = Destination(label=label, options=options, plugin_name=plugin_name, description=description)
return database.create(destination) return database.create(destination)

View File

@ -15,7 +15,7 @@ from lemur.schemas import AssociatedCertificateSchema
class DomainInputSchema(LemurInputSchema): class DomainInputSchema(LemurInputSchema):
id = fields.Integer() id = fields.Integer()
name = fields.String(required=True) name = fields.String(required=True)
sensitive = fields.Boolean() sensitive = fields.Boolean(missing=False)
certificates = fields.Nested(AssociatedCertificateSchema, many=True, missing=[]) certificates = fields.Nested(AssociatedCertificateSchema, many=True, missing=[])

View File

@ -76,7 +76,7 @@ def render(args):
:param args: :param args:
:return: :return:
""" """
query = database.session_query(Domain).join(Certificate, Domain.certificate) query = database.session_query(Domain)
filt = args.pop('filter') filt = args.pop('filter')
certificate_id = args.pop('certificate_id', None) certificate_id = args.pop('certificate_id', None)
@ -85,6 +85,7 @@ def render(args):
query = database.filter(query, Domain, terms) query = database.filter(query, Domain, terms)
if certificate_id: if certificate_id:
query = query.join(Certificate, Domain.certificates)
query = query.filter(Certificate.id == certificate_id) query = query.filter(Certificate.id == certificate_id)
return database.sort_and_page(query, Domain, args) return database.sort_and_page(query, Domain, args)

View File

@ -14,7 +14,7 @@ from sqlalchemy import cast
from sqlalchemy_utils import ArrowType from sqlalchemy_utils import ArrowType
from lemur import database from lemur import database
from lemur.extensions import metrics from lemur.extensions import metrics, sentry
from lemur.endpoints.models import Endpoint from lemur.endpoints.models import Endpoint
@ -27,13 +27,17 @@ def expire(ttl):
Removed all endpoints that have not been recently updated. Removed all endpoints that have not been recently updated.
""" """
print("[+] Staring expiration of old endpoints.") print("[+] Staring expiration of old endpoints.")
now = arrow.utcnow()
expiration = now - timedelta(hours=ttl)
endpoints = database.session_query(Endpoint).filter(cast(Endpoint.last_updated, ArrowType) <= expiration)
for endpoint in endpoints: try:
print("[!] Expiring endpoint: {name} Last Updated: {last_updated}".format(name=endpoint.name, last_updated=endpoint.last_updated)) now = arrow.utcnow()
database.delete(endpoint) expiration = now - timedelta(hours=ttl)
metrics.send('endpoint_expired', 'counter', 1) endpoints = database.session_query(Endpoint).filter(cast(Endpoint.last_updated, ArrowType) <= expiration)
print("[+] Finished 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()

View File

@ -60,6 +60,16 @@ def get_by_dnsname(dnsname):
return database.get(Endpoint, dnsname, field='dnsname') 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): def get_by_source(source_label):
""" """
Retrieves all endpoints for a given source. Retrieves all endpoints for a given source.

View File

@ -29,7 +29,7 @@ class AttrNotFound(LemurException):
self.field = field self.field = field
def __str__(self): 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 InvalidConfiguration(Exception): class InvalidConfiguration(Exception):

View File

@ -13,10 +13,16 @@ from flask_bcrypt import Bcrypt
bcrypt = Bcrypt() bcrypt = Bcrypt()
from flask_principal import Principal from flask_principal import Principal
principal = Principal() principal = Principal(use_sessions=False)
from flask_mail import Mail from flask_mail import Mail
smtp_mail = Mail() smtp_mail = Mail()
from lemur.metrics import Metrics from lemur.metrics import Metrics
metrics = Metrics() metrics = Metrics()
from raven.contrib.flask import Sentry
sentry = Sentry()
from blinker import Namespace
signals = Namespace()

View File

@ -18,8 +18,10 @@ from logging import Formatter, StreamHandler
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from flask import Flask from flask import Flask
from lemur.certificates.hooks import activate_debug_dump
from lemur.common.health import mod as health 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
DEFAULT_BLUEPRINTS = ( DEFAULT_BLUEPRINTS = (
@ -73,7 +75,8 @@ def from_file(file_path, silent=False):
d.__file__ = file_path d.__file__ = file_path
try: try:
with open(file_path) as config_file: 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: except IOError as e:
if silent and e.errno in (errno.ENOENT, errno.EISDIR): if silent and e.errno in (errno.ENOENT, errno.EISDIR):
return False return False
@ -120,6 +123,7 @@ def configure_extensions(app):
principal.init_app(app) principal.init_app(app)
smtp_mail.init_app(app) smtp_mail.init_app(app)
metrics.init_app(app) metrics.init_app(app)
sentry.init_app(app)
def configure_blueprints(app, blueprints): def configure_blueprints(app, blueprints):
@ -152,9 +156,12 @@ def configure_logging(app):
app.logger.addHandler(handler) app.logger.addHandler(handler)
stream_handler = StreamHandler() 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) app.logger.addHandler(stream_handler)
if app.config.get('DEBUG_DUMP', False):
activate_debug_dump()
def install_plugins(app): def install_plugins(app):
""" """
@ -181,8 +188,10 @@ def install_plugins(app):
# ensure that we have some way to notify # ensure that we have some way to notify
with app.app_context(): with app.app_context():
slug = app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification")
try: try:
slug = app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification")
plugins.get(slug) plugins.get(slug)
except KeyError: 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))

View File

@ -18,6 +18,6 @@ class Log(db.Model):
__tablename__ = 'logs' __tablename__ = 'logs'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
certificate_id = Column(Integer, ForeignKey('certificates.id')) certificate_id = Column(Integer, ForeignKey('certificates.id'))
log_type = Column(Enum('key_view', name='log_type'), nullable=False) 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) logged_at = Column(ArrowType(), PassiveDefault(func.now()), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False) user_id = Column(Integer, ForeignKey('users.id'), nullable=False)

View File

@ -7,8 +7,12 @@
:license: Apache, see LICENSE for more details. :license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
from flask import current_app
from lemur import database from lemur import database
from lemur.logs.models import Log from lemur.logs.models import Log
from lemur.users.models import User
from lemur.certificates.models import Certificate
def create(user, type, certificate=None): def create(user, type, certificate=None):
@ -20,6 +24,7 @@ def create(user, type, certificate=None):
:param certificate: :param certificate:
:return: :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) view = Log(user_id=user.id, log_type=type, certificate_id=certificate.id)
database.add(view) database.add(view)
database.commit() database.commit()
@ -49,6 +54,20 @@ def render(args):
if filt: if filt:
terms = filt.split(';') terms = filt.split(';')
query = database.filter(query, Log, terms)
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) return database.sort_and_page(query, Log, args)

View File

@ -16,16 +16,18 @@ from flask_migrate import Migrate, MigrateCommand, stamp
from flask_script.commands import ShowUrls, Clean, Server from flask_script.commands import ShowUrls, Clean, Server
from lemur.sources.cli import manager as source_manager 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.certificates.cli import manager as certificate_manager
from lemur.notifications.cli import manager as notification_manager from lemur.notifications.cli import manager as notification_manager
from lemur.endpoints.cli import manager as endpoint_manager
from lemur.reporting.cli import manager as report_manager
from lemur import database from lemur import database
from lemur.users import service as user_service from lemur.users import service as user_service
from lemur.roles import service as role_service from lemur.roles import service as role_service
from lemur.policies import service as policy_service
from lemur.notifications import service as notification_service from lemur.notifications import service as notification_service
from lemur.common.utils import validate_conf from lemur.common.utils import validate_conf
from lemur import create_app from lemur import create_app
@ -40,6 +42,8 @@ from lemur.domains.models import Domain # noqa
from lemur.notifications.models import Notification # noqa from lemur.notifications.models import Notification # noqa
from lemur.sources.models import Source # noqa from lemur.sources.models import Source # noqa
from lemur.logs.models import Log # noqa from lemur.logs.models import Log # noqa
from lemur.endpoints.models import Endpoint # noqa
from lemur.policies.models import RotationPolicy # noqa
manager = Manager(create_app) manager = Manager(create_app)
@ -83,8 +87,8 @@ SECRET_KEY = '{flask_secret_key}'
LEMUR_TOKEN_SECRET = '{secret_token}' LEMUR_TOKEN_SECRET = '{secret_token}'
LEMUR_ENCRYPTION_KEYS = '{encryption_key}' LEMUR_ENCRYPTION_KEYS = '{encryption_key}'
# this is a list of domains as regexes that only admins can issue # List of domain regular expressions that non-admin users can issue
LEMUR_RESTRICTED_DOMAINS = [] LEMUR_WHITELISTED_DOMAINS = []
# Mail Server # Mail Server
@ -222,7 +226,7 @@ class InitializeApp(Command):
sys.stderr.write("[!] Passwords do not match!\n") sys.stderr.write("[!] Passwords do not match!\n")
sys.exit(1) sys.exit(1)
user_service.create("lemur", password, 'lemur@nobody', True, None, [admin_role]) 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") sys.stdout.write("[+] Created the user 'lemur' and granted it the 'admin' role!\n")
else: else:
@ -242,6 +246,12 @@ class InitializeApp(Command):
recipients = current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL') recipients = current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL')
notification_service.create_default_expiration_notifications("DEFAULT_SECURITY", recipients=recipients) notification_service.create_default_expiration_notifications("DEFAULT_SECURITY", recipients=recipients)
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')
sys.stdout.write("[/] Done!\n") sys.stdout.write("[/] Done!\n")
@ -365,7 +375,7 @@ class LemurServer(Command):
app = WSGIApplication() app = WSGIApplication()
# run startup tasks on a app like object # run startup tasks on an app like object
validate_conf(current_app, REQUIRED_VARIABLES) validate_conf(current_app, REQUIRED_VARIABLES)
app.app_uri = 'lemur:create_app(config="{0}")'.format(current_app.config.get('CONFIG_PATH')) app.app_uri = 'lemur:create_app(config="{0}")'.format(current_app.config.get('CONFIG_PATH'))
@ -531,6 +541,7 @@ def main():
manager.add_command("notify", notification_manager) manager.add_command("notify", notification_manager)
manager.add_command("endpoint", endpoint_manager) manager.add_command("endpoint", endpoint_manager)
manager.add_command("report", report_manager) manager.add_command("report", report_manager)
manager.add_command("policy", policy_manager)
manager.run() manager.run()

View File

@ -3,6 +3,9 @@ from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig from logging.config import fileConfig
import alembic_autogenerate_enums
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
config = context.config config = context.config

View File

@ -16,7 +16,7 @@ import sqlalchemy as sa
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('certificates', sa.Column('rotation', sa.Boolean(), nullable=False, server_default=False)) 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)) op.add_column('endpoints', sa.Column('last_updated', sa.DateTime(), server_default=sa.text('now()'), nullable=False))
# ### end Alembic commands ### # ### end Alembic commands ###

View 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'])

View File

@ -0,0 +1,22 @@
"""add third party roles to lemur
Revision ID: 5bc47fa7cac4
Revises: c05a8998b371
Create Date: 2017-12-08 14:19:11.903864
"""
# revision identifiers, used by Alembic.
revision = '5bc47fa7cac4'
down_revision = 'c05a8998b371'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('roles', sa.Column('third_party', sa.Boolean(), nullable=True, default=False))
def downgrade():
op.drop_column('roles', 'third_party')

View File

@ -15,16 +15,12 @@ import sqlalchemy as sa
def upgrade(): def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('endpoints', sa.Column('sensitive', sa.Boolean(), nullable=True)) op.add_column('endpoints', sa.Column('sensitive', sa.Boolean(), nullable=True))
op.add_column('endpoints', sa.Column('source_id', sa.Integer(), nullable=True)) op.add_column('endpoints', sa.Column('source_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'endpoints', 'sources', ['source_id'], ['id']) op.create_foreign_key(None, 'endpoints', 'sources', ['source_id'], ['id'])
### end Alembic commands ###
def downgrade(): def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'endpoints', type_='foreignkey') op.drop_constraint(None, 'endpoints', type_='foreignkey')
op.drop_column('endpoints', 'source_id') op.drop_column('endpoints', 'source_id')
op.drop_column('endpoints', 'sensitive') op.drop_column('endpoints', 'sensitive')
### end Alembic commands ###

View File

@ -0,0 +1,24 @@
"""Modifies the unique index for the certificate replacements
Revision ID: 8ae67285ff14
Revises: 5e680529b666
Create Date: 2017-05-10 11:56:13.999332
"""
# revision identifiers, used by Alembic.
revision = '8ae67285ff14'
down_revision = '5e680529b666'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.drop_index('certificate_replacement_associations_ix')
op.create_index('certificate_replacement_associations_ix', 'certificate_replacement_associations', ['replaced_certificate_id', 'certificate_id'], unique=True)
def downgrade():
op.drop_index('certificate_replacement_associations_ix')
op.create_index('certificate_replacement_associations_ix', 'certificate_replacement_associations', ['certificate_id', 'certificate_id'], unique=True)

View File

@ -0,0 +1,54 @@
"""Adds support for rotation policies.
Creates a default rotation policy (30 days) with the name
'default' ensures that all existing certificates use the default
policy.
Revision ID: a02a678ddc25
Revises: 8ae67285ff14
Create Date: 2017-07-12 11:45:49.257927
"""
# revision identifiers, used by Alembic.
revision = 'a02a678ddc25'
down_revision = '8ae67285ff14'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import text
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('rotation_policies',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('days', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.add_column('certificates', sa.Column('rotation_policy_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'certificates', 'rotation_policies', ['rotation_policy_id'], ['id'])
conn = op.get_bind()
stmt = text('insert into rotation_policies (days, name) values (:days, :name)')
stmt = stmt.bindparams(days=30, name='default')
conn.execute(stmt)
stmt = text('select id from rotation_policies where name=:name')
stmt = stmt.bindparams(name='default')
rotation_policy_id = conn.execute(stmt).fetchone()[0]
stmt = text('update certificates set rotation_policy_id=:rotation_policy_id')
stmt = stmt.bindparams(rotation_policy_id=rotation_policy_id)
conn.execute(stmt)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'certificates', type_='foreignkey')
op.drop_column('certificates', 'rotation_policy_id')
op.drop_index('certificate_replacement_associations_ix', table_name='certificate_replacement_associations')
op.create_index('certificate_replacement_associations_ix', 'certificate_replacement_associations', ['replaced_certificate_id', 'certificate_id'], unique=True)
op.drop_table('rotation_policies')
# ### end Alembic commands ###

View File

@ -0,0 +1,27 @@
"""empty message
Revision ID: ac483cfeb230
Revises: b29e2c4bf8c9
Create Date: 2017-10-11 10:16:39.682591
"""
# revision identifiers, used by Alembic.
revision = 'ac483cfeb230'
down_revision = 'b29e2c4bf8c9'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade():
op.alter_column('certificates', 'name',
existing_type=sa.VARCHAR(length=128),
type_=sa.String(length=256))
def downgrade():
op.alter_column('certificates', 'name',
existing_type=sa.VARCHAR(length=256),
type_=sa.String(length=128))

View File

@ -0,0 +1,28 @@
"""Adds external ID checking and modifying enum
Revision ID: b29e2c4bf8c9
Revises: 1ae8e3104db8
Create Date: 2017-09-26 10:50:35.740367
"""
# revision identifiers, used by Alembic.
revision = 'b29e2c4bf8c9'
down_revision = '1ae8e3104db8'
from alembic import op
import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('certificates', sa.Column('external_id', sa.String(128), nullable=True))
op.sync_enum_values('public', 'log_type', ['create_cert', 'key_view', 'update_cert'], ['create_cert', 'key_view', 'revoke_cert', 'update_cert'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values('public', 'log_type', ['create_cert', 'key_view', 'revoke_cert', 'update_cert'], ['create_cert', 'key_view', 'update_cert'])
op.drop_column('certificates', 'external_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,31 @@
"""Adds JWT Tokens to Users
Revision ID: c05a8998b371
Revises: ac483cfeb230
Create Date: 2017-11-10 14:51:28.975927
"""
# revision identifiers, used by Alembic.
revision = 'c05a8998b371'
down_revision = 'ac483cfeb230'
from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
def upgrade():
op.create_table('api_keys',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('ttl', sa.BigInteger(), nullable=False),
sa.Column('issued_at', sa.BigInteger(), nullable=False),
sa.Column('revoked', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
op.drop_table('api_keys')

View File

@ -53,7 +53,7 @@ certificate_replacement_associations = db.Table('certificate_replacement_associa
ForeignKey('certificates.id', ondelete='cascade')) ForeignKey('certificates.id', ondelete='cascade'))
) )
Index('certificate_replacement_associations_ix', certificate_replacement_associations.c.certificate_id, certificate_replacement_associations.c.certificate_id) Index('certificate_replacement_associations_ix', certificate_replacement_associations.c.replaced_certificate_id, certificate_replacement_associations.c.certificate_id)
roles_authorities = db.Table('roles_authorities', roles_authorities = db.Table('roles_authorities',
Column('authority_id', Integer, ForeignKey('authorities.id')), Column('authority_id', Integer, ForeignKey('authorities.id')),

View File

@ -12,7 +12,7 @@ from lemur.notifications.messaging import send_expiration_notifications
manager = Manager(usage="Handles notification related tasks.") manager = Manager(usage="Handles notification related tasks.")
@manager.option('-e', '--exclude', dest='exclude', nargs="*", help='Common name matching of certificates that should be excluded from notification') @manager.option('-e', '--exclude', dest='exclude', action='append', default=[], help='Common name matching of certificates that should be excluded from notification')
def expirations(exclude): def expirations(exclude):
""" """
Runs Lemur's notification engine, that looks for expired certificates and sends Runs Lemur's notification engine, that looks for expired certificates and sends

View File

@ -18,6 +18,7 @@ from flask import current_app
from sqlalchemy import and_ from sqlalchemy import and_
from lemur import database, metrics from lemur import database, metrics
from lemur.extensions import sentry
from lemur.common.utils import windowed_query from lemur.common.utils import windowed_query
from lemur.certificates.schemas import certificate_notification_output_schema from lemur.certificates.schemas import certificate_notification_output_schema
@ -98,6 +99,7 @@ def send_notification(event_type, data, targets, notification):
metrics.send('{0}_notification_sent'.format(event_type), 'counter', 1) metrics.send('{0}_notification_sent'.format(event_type), 'counter', 1)
return True return True
except Exception as e: except Exception as e:
sentry.captureException()
metrics.send('{0}_notification_failure'.format(event_type), 'counter', 1) metrics.send('{0}_notification_failure'.format(event_type), 'counter', 1)
current_app.logger.exception(e) current_app.logger.exception(e)
@ -131,17 +133,17 @@ def send_expiration_notifications(exclude):
else: else:
failure += 1 failure += 1
if send_notification('expiration', security_data, security_email, notification): if send_notification('expiration', security_data, security_email, notification):
success += 1 success += 1
else: else:
failure += 1 failure += 1
return success, failure return success, failure
def send_rotation_notification(certificate, notification_plugin=None): def send_rotation_notification(certificate, notification_plugin=None):
""" """
Sends a report to certificate owners when their certificate as been Sends a report to certificate owners when their certificate has been
rotated. rotated.
:param certificate: :param certificate:
@ -157,6 +159,7 @@ def send_rotation_notification(certificate, notification_plugin=None):
metrics.send('rotation_notification_sent', 'counter', 1) metrics.send('rotation_notification_sent', 'counter', 1)
return True return True
except Exception as e: except Exception as e:
sentry.captureException()
metrics.send('rotation_notification_failure', 'counter', 1) metrics.send('rotation_notification_failure', 'counter', 1)
current_app.logger.exception(e) current_app.logger.exception(e)
@ -173,7 +176,7 @@ def needs_notification(certificate):
days = (certificate.not_after - now).days days = (certificate.not_after - now).days
for notification in certificate.notifications: for notification in certificate.notifications:
if not notification.options: if not notification.active or not notification.options:
return return
interval = get_plugin_option('interval', notification.options) interval = get_plugin_option('interval', notification.options)

View File

@ -26,7 +26,6 @@ class NotificationOutputSchema(LemurOutputSchema):
active = fields.Boolean() active = fields.Boolean()
options = fields.List(fields.Dict()) options = fields.List(fields.Dict())
plugin = fields.Nested(PluginOutputSchema) plugin = fields.Nested(PluginOutputSchema)
certificates = fields.Nested(AssociatedCertificateSchema, many=True, missing=[])
@post_dump @post_dump
def fill_object(self, data): def fill_object(self, data):

View File

@ -101,7 +101,7 @@ class IPlugin(local):
Returns a list of tuples pointing to various resources for this plugin. Returns a list of tuples pointing to various resources for this plugin.
>>> def get_resource_links(self): >>> def get_resource_links(self):
>>> return [ >>> return [
>>> ('Documentation', 'http://lemury.readthedocs.org'), >>> ('Documentation', 'https://lemur.readthedocs.io'),
>>> ('Bug Tracker', 'https://github.com/Netflix/lemur/issues'), >>> ('Bug Tracker', 'https://github.com/Netflix/lemur/issues'),
>>> ('Source', 'https://github.com/Netflix/lemur'), >>> ('Source', 'https://github.com/Netflix/lemur'),
>>> ] >>> ]

View File

@ -1,4 +1,4 @@
from .destination import DestinationPlugin # noqa from .destination import DestinationPlugin, ExportDestinationPlugin # noqa
from .issuer import IssuerPlugin # noqa from .issuer import IssuerPlugin # noqa
from .source import SourcePlugin # noqa from .source import SourcePlugin # noqa
from .notification import NotificationPlugin, ExpirationNotificationPlugin # noqa from .notification import NotificationPlugin, ExpirationNotificationPlugin # noqa

View File

@ -6,7 +6,7 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
from lemur.plugins.base import Plugin from lemur.plugins.base import Plugin, plugins
class DestinationPlugin(Plugin): class DestinationPlugin(Plugin):
@ -15,3 +15,32 @@ class DestinationPlugin(Plugin):
def upload(self, name, body, private_key, cert_chain, options, **kwargs): def upload(self, name, body, private_key, cert_chain, options, **kwargs):
raise NotImplementedError raise NotImplementedError
class ExportDestinationPlugin(DestinationPlugin):
default_options = [
{
'name': 'exportPlugin',
'type': 'export-plugin',
'required': True,
'helpMessage': 'Export plugin to use before sending data to destination.'
}
]
@property
def options(self):
return self.default_options + self.additional_options
def export(self, body, private_key, cert_chain, options):
export_plugin = self.get_option('exportPlugin', options)
if export_plugin:
plugin = plugins.get(export_plugin['slug'])
extension, passphrase, data = plugin.export(body, cert_chain, private_key, export_plugin['plugin_options'])
return [(extension, passphrase, data)]
data = body + '\n' + cert_chain + '\n' + private_key
return [('.pem', '', data)]
def upload(self, name, body, private_key, cert_chain, options, **kwargs):
raise NotImplementedError

View File

@ -21,3 +21,6 @@ class IssuerPlugin(Plugin):
def create_authority(self, options): def create_authority(self, options):
raise NotImplementedError raise NotImplementedError
def revoke_certificate(self, certificate, comments):
raise NotImplementedError

View File

@ -46,7 +46,7 @@ class ExpirationNotificationPlugin(NotificationPlugin):
@property @property
def options(self): def options(self):
return list(self.default_options) + self.additional_options return self.default_options + self.additional_options
def send(self, notification_type, message, targets, options, **kwargs): def send(self, notification_type, message, targets, options, **kwargs):
raise NotImplementedError raise NotImplementedError

View File

@ -33,4 +33,4 @@ class SourcePlugin(Plugin):
@property @property
def options(self): def options(self):
return list(self.default_options) + self.additional_options return self.default_options + self.additional_options

View File

@ -19,8 +19,6 @@ from acme import challenges
from lemur.common.utils import generate_private_key from lemur.common.utils import generate_private_key
from cryptography.hazmat.primitives import serialization
import OpenSSL.crypto import OpenSSL.crypto
from lemur.common.utils import validate_conf from lemur.common.utils import validate_conf
@ -87,8 +85,8 @@ def request_certificate(acme_client, authorizations, csr):
cert_response, _ = acme_client.poll_and_request_issuance( cert_response, _ = acme_client.poll_and_request_issuance(
jose.util.ComparableX509( jose.util.ComparableX509(
OpenSSL.crypto.load_certificate_request( OpenSSL.crypto.load_certificate_request(
OpenSSL.crypto.FILETYPE_ASN1, OpenSSL.crypto.FILETYPE_PEM,
csr.public_bytes(serialization.Encoding.DER), csr
) )
), ),
authzrs=[authz_record.authz for authz_record in authorizations], authzrs=[authz_record.authz for authz_record in authorizations],
@ -96,12 +94,13 @@ def request_certificate(acme_client, authorizations, csr):
pem_certificate = OpenSSL.crypto.dump_certificate( pem_certificate = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, cert_response.body OpenSSL.crypto.FILETYPE_PEM, cert_response.body
) ).decode('utf-8')
pem_certificate_chain = "\n".join( # https://github.com/alex/letsencrypt-aws/commit/853ea7f93f141fe18d9ef12aee6b3388f98b4830
pem_certificate_chain = b"\n".join(
OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert) OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
for cert in acme_client.fetch_chain(cert_response) for cert in acme_client.fetch_chain(cert_response)
) ).decode('utf-8')
return pem_certificate, pem_certificate_chain return pem_certificate, pem_certificate_chain
@ -133,7 +132,7 @@ def get_domains(options):
domains = [options['common_name']] domains = [options['common_name']]
if options.get('extensions'): if options.get('extensions'):
for name in options['extensions']['sub_alt_names']['names']: for name in options['extensions']['sub_alt_names']['names']:
domains.append(name) domains.append(name.value)
return domains return domains
@ -194,7 +193,8 @@ class ACMEIssuerPlugin(IssuerPlugin):
domains = get_domains(issuer_options) domains = get_domains(issuer_options)
authorizations = get_authorizations(acme_client, account_number, domains) authorizations = get_authorizations(acme_client, account_number, domains)
pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, csr) pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, csr)
return pem_certificate, pem_certificate_chain # TODO add external ID (if possible)
return pem_certificate, pem_certificate_chain, None
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):

View File

@ -27,6 +27,7 @@ def find_zone_id(domain, client=None):
raise ValueError( raise ValueError(
"Unable to find a Route53 hosted zone for {}".format(domain) "Unable to find a Route53 hosted zone for {}".format(domain)
) )
return zones[0][1]
@sts_client('route53') @sts_client('route53')
@ -54,7 +55,7 @@ def change_txt_record(action, zone_id, domain, value, client=None):
return response["ChangeInfo"]["Id"] return response["ChangeInfo"]["Id"]
def create_txt_record(account_number, host, value): def create_txt_record(host, value, account_number):
zone_id = find_zone_id(host, account_number=account_number) zone_id = find_zone_id(host, account_number=account_number)
change_id = change_txt_record( change_id = change_txt_record(
"CREATE", "CREATE",

View File

@ -98,7 +98,7 @@ def get_all_elbs_v2(**kwargs):
@retry(retry_on_exception=retry_throttled, stop_max_attempt_number=7, wait_exponential_multiplier=1000) @retry(retry_on_exception=retry_throttled, stop_max_attempt_number=7, wait_exponential_multiplier=1000)
def get_listener_arn_from_endpoint(endpoint_name, endpoint_port, **kwargs): def get_listener_arn_from_endpoint(endpoint_name, endpoint_port, **kwargs):
""" """
Get a listener ARN from a endpoint. Get a listener ARN from an endpoint.
:param endpoint_name: :param endpoint_name:
:param endpoint_port: :param endpoint_port:
:return: :return:

View File

@ -53,7 +53,7 @@ def create_arn_from_cert(account_number, region, certificate_name):
@sts_client('iam') @sts_client('iam')
@retry(retry_on_exception=retry_throttled, stop_max_attempt_number=7, wait_exponential_multiplier=100) @retry(retry_on_exception=retry_throttled, stop_max_attempt_number=7, wait_exponential_multiplier=100)
def upload_cert(name, body, private_key, cert_chain=None, **kwargs): def upload_cert(name, body, private_key, path, cert_chain=None, **kwargs):
""" """
Upload a certificate to AWS Upload a certificate to AWS
@ -61,12 +61,20 @@ def upload_cert(name, body, private_key, cert_chain=None, **kwargs):
:param body: :param body:
:param private_key: :param private_key:
:param cert_chain: :param cert_chain:
:param path:
:return: :return:
""" """
client = kwargs.pop('client') client = kwargs.pop('client')
if not path:
path = '/'
else:
name = name + '-' + path.strip('/')
try: try:
if cert_chain: if cert_chain:
return client.upload_server_certificate( return client.upload_server_certificate(
Path=path,
ServerCertificateName=name, ServerCertificateName=name,
CertificateBody=str(body), CertificateBody=str(body),
PrivateKey=str(private_key), PrivateKey=str(private_key),
@ -74,6 +82,7 @@ def upload_cert(name, body, private_key, cert_chain=None, **kwargs):
) )
else: else:
return client.upload_server_certificate( return client.upload_server_certificate(
Path=path,
ServerCertificateName=name, ServerCertificateName=name,
CertificateBody=str(body), CertificateBody=str(body),
PrivateKey=str(private_key) PrivateKey=str(private_key)

View File

@ -34,9 +34,9 @@
""" """
from flask import current_app from flask import current_app
from lemur.plugins.bases import DestinationPlugin, SourcePlugin
from lemur.plugins.lemur_aws import iam, s3, elb, ec2
from lemur.plugins import lemur_aws as aws from lemur.plugins import lemur_aws as aws
from lemur.plugins.lemur_aws import iam, s3, elb, ec2
from lemur.plugins.bases import DestinationPlugin, ExportDestinationPlugin, SourcePlugin
def get_region_from_dns(dns): def get_region_from_dns(dns):
@ -105,9 +105,12 @@ def get_elb_endpoints(account_number, region, elb_dict):
) )
if listener['PolicyNames']: if listener['PolicyNames']:
policy = elb.describe_load_balancer_policies(elb_dict['LoadBalancerName'], listener['PolicyNames'], account_number=account_number, region=region) policy = elb.describe_load_balancer_policies(elb_dict['LoadBalancerName'], listener['PolicyNames'],
account_number=account_number, region=region)
endpoint['policy'] = format_elb_cipher_policy(policy) endpoint['policy'] = format_elb_cipher_policy(policy)
current_app.logger.debug("Found new endpoint. Endpoint: {}".format(endpoint))
endpoints.append(endpoint) endpoints.append(endpoint)
return endpoints return endpoints
@ -122,7 +125,8 @@ def get_elb_endpoints_v2(account_number, region, elb_dict):
:return: :return:
""" """
endpoints = [] endpoints = []
listeners = elb.describe_listeners_v2(account_number=account_number, region=region, LoadBalancerArn=elb_dict['LoadBalancerArn']) listeners = elb.describe_listeners_v2(account_number=account_number, region=region,
LoadBalancerArn=elb_dict['LoadBalancerArn'])
for listener in listeners['Listeners']: for listener in listeners['Listeners']:
if not listener.get('Certificates'): if not listener.get('Certificates'):
continue continue
@ -161,6 +165,12 @@ class AWSDestinationPlugin(DestinationPlugin):
'required': True, 'required': True,
'validation': '/^[0-9]{12,12}$/', 'validation': '/^[0-9]{12,12}$/',
'helpMessage': 'Must be a valid AWS account number!', 'helpMessage': 'Must be a valid AWS account number!',
},
{
'name': 'path',
'type': 'str',
'default': '/',
'helpMessage': 'Path to upload certificate.'
} }
] ]
@ -172,6 +182,7 @@ class AWSDestinationPlugin(DestinationPlugin):
def upload(self, name, body, private_key, cert_chain, options, **kwargs): def upload(self, name, body, private_key, cert_chain, options, **kwargs):
iam.upload_cert(name, body, private_key, iam.upload_cert(name, body, private_key,
self.get_option('path', options),
cert_chain=cert_chain, cert_chain=cert_chain,
account_number=self.get_option('accountNumber', options)) account_number=self.get_option('accountNumber', options))
@ -205,7 +216,8 @@ class AWSSourcePlugin(SourcePlugin):
def get_certificates(self, options, **kwargs): def get_certificates(self, options, **kwargs):
cert_data = iam.get_all_certificates(account_number=self.get_option('accountNumber', options)) cert_data = iam.get_all_certificates(account_number=self.get_option('accountNumber', options))
return [dict(body=c['CertificateBody'], chain=c.get('CertificateChain'), name=c['ServerCertificateMetadata']['ServerCertificateName']) for c in cert_data] return [dict(body=c['CertificateBody'], chain=c.get('CertificateChain'),
name=c['ServerCertificateMetadata']['ServerCertificateName']) for c in cert_data]
def get_endpoints(self, options, **kwargs): def get_endpoints(self, options, **kwargs):
endpoints = [] endpoints = []
@ -242,8 +254,10 @@ class AWSSourcePlugin(SourcePlugin):
arn = iam.create_arn_from_cert(account_number, region, certificate.name) arn = iam.create_arn_from_cert(account_number, region, certificate.name)
if endpoint.type == 'elbv2': if endpoint.type == 'elbv2':
listener_arn = elb.get_listener_arn_from_endpoint(endpoint.name, endpoint.port, account_number=account_number, region=region) listener_arn = elb.get_listener_arn_from_endpoint(endpoint.name, endpoint.port,
elb.attach_certificate_v2(listener_arn, endpoint.port, [{'CertificateArn': arn}], account_number=account_number, region=region) account_number=account_number, region=region)
elb.attach_certificate_v2(listener_arn, endpoint.port, [{'CertificateArn': arn}],
account_number=account_number, region=region)
else: else:
elb.attach_certificate(endpoint.name, endpoint.port, arn, account_number=account_number, region=region) elb.attach_certificate(endpoint.name, endpoint.port, arn, account_number=account_number, region=region)
@ -252,7 +266,7 @@ class AWSSourcePlugin(SourcePlugin):
iam.delete_cert(certificate.name, account_number=account_number) iam.delete_cert(certificate.name, account_number=account_number)
class S3DestinationPlugin(DestinationPlugin): class S3DestinationPlugin(ExportDestinationPlugin):
title = 'AWS-S3' title = 'AWS-S3'
slug = 'aws-s3' slug = 'aws-s3'
description = 'Allow the uploading of certificates to Amazon S3' description = 'Allow the uploading of certificates to Amazon S3'
@ -260,7 +274,7 @@ class S3DestinationPlugin(DestinationPlugin):
author = 'Mikhail Khodorovskiy, Harm Weites <harm@weites.com>' author = 'Mikhail Khodorovskiy, Harm Weites <harm@weites.com>'
author_url = 'https://github.com/Netflix/lemur' author_url = 'https://github.com/Netflix/lemur'
options = [ additional_options = [
{ {
'name': 'bucket', 'name': 'bucket',
'type': 'str', 'type': 'str',
@ -278,56 +292,42 @@ class S3DestinationPlugin(DestinationPlugin):
{ {
'name': 'region', 'name': 'region',
'type': 'str', 'type': 'str',
'default': 'eu-west-1', 'default': 'us-east-1',
'required': False, 'required': False,
'validation': '/^\w+-\w+-\d+$/', 'helpMessage': 'Region bucket exists',
'helpMessage': 'Availability zone to use', 'available': ['us-east-1', 'us-west-2', 'eu-west-1']
}, },
{ {
'name': 'encrypt', 'name': 'encrypt',
'type': 'bool', 'type': 'bool',
'required': False, 'required': False,
'helpMessage': 'Availability zone to use', 'helpMessage': 'Enable server side encryption',
'default': True 'default': True
}, },
{ {
'name': 'key', 'name': 'prefix',
'type': 'str', 'type': 'str',
'required': False, 'required': False,
'validation': '/^$|\s+/', 'validation': '/^$|\s+/',
'helpMessage': 'Must be a valid S3 object key!', 'helpMessage': 'Must be a valid S3 object prefix!',
},
{
'name': 'caKey',
'type': 'str',
'required': False,
'validation': '/^$|\s+/',
'helpMessage': 'Must be a valid S3 object key!',
},
{
'name': 'certKey',
'type': 'str',
'required': False,
'validation': '/^$|\s+/',
'helpMessage': 'Must be a valid S3 object key!',
} }
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(S3DestinationPlugin, self).__init__(*args, **kwargs) super(S3DestinationPlugin, self).__init__(*args, **kwargs)
def upload(self, name, body, private_key, cert_chain, options, **kwargs): def upload(self, name, body, private_key, chain, options, **kwargs):
account_number = self.get_option('accountNumber', options) files = self.export(body, private_key, chain, options)
encrypt = self.get_option('encrypt', options)
bucket = self.get_option('bucket', options)
key = self.get_option('key', options)
ca_key = self.get_option('caKey', options)
cert_key = self.get_option('certKey', options)
if key and ca_key and cert_key: for ext, passphrase, data in files:
s3.write_to_s3(account_number, bucket, key, private_key, encrypt=encrypt) s3.put(
s3.write_to_s3(account_number, bucket, ca_key, cert_chain, encrypt=encrypt) self.get_option('bucket', options),
s3.write_to_s3(account_number, bucket, cert_key, body, encrypt=encrypt) self.get_option('region', options),
else: '{prefix}/{name}.{extension}'.format(
pem_body = key + '\n' + body + '\n' + cert_chain + '\n' prefix=self.get_option('prefix', options),
s3.write_to_s3(account_number, bucket, name, pem_body, encrypt=encrypt) name=name,
extension=ext),
data,
self.get_option('encrypt', options),
account_number=self.get_option('accountNumber', options)
)

View File

@ -6,21 +6,32 @@
:license: Apache, see LICENSE for more details. :license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
from boto.s3.key import Key from flask import current_app
from lemur.plugins.lemur_aws.sts import assume_service from .sts import sts_client
def write_to_s3(account_number, bucket_name, key, data, encrypt=True): @sts_client('s3', service_type='resource')
def put(bucket_name, region, prefix, data, encrypt, **kwargs):
""" """
Use STS to write to an S3 bucket Use STS to write to an S3 bucket
:param account_number:
:param bucket_name:
:param data:
""" """
conn = assume_service(account_number, 's3') bucket = kwargs['resource'].Bucket(bucket_name)
b = conn.get_bucket(bucket_name, validate=False) # validate=False removes need for ListObjects permission current_app.logger.debug('Persisting data to S3. Bucket: {0} Prefix: {1}'.format(bucket_name, prefix))
k = Key(bucket=b, name=key) # get data ready for writing
k.set_contents_from_string(data, encrypt_key=encrypt) if isinstance(data, str):
k.set_canned_acl("bucket-owner-read") data = data.encode('utf-8')
if encrypt:
bucket.put_object(
Key=prefix,
Body=data,
ACL='bucket-owner-full-control',
ServerSideEncryption='AES256'
)
else:
bucket.put_object(
Key=prefix,
Body=data,
ACL='bucket-owner-full-control'
)

View File

@ -7,46 +7,11 @@
""" """
from functools import wraps from functools import wraps
import boto
import boto.ec2.elb
import boto3 import boto3
from flask import current_app from flask import current_app
def assume_service(account_number, service, region='us-east-1'):
conn = boto.connect_sts()
role = conn.assume_role('arn:aws:iam::{0}:role/{1}'.format(
account_number, current_app.config.get('LEMUR_INSTANCE_PROFILE', 'Lemur')), 'blah')
if service in 'iam':
return boto.connect_iam(
aws_access_key_id=role.credentials.access_key,
aws_secret_access_key=role.credentials.secret_key,
security_token=role.credentials.session_token)
elif service in 'elb':
return boto.ec2.elb.connect_to_region(
region,
aws_access_key_id=role.credentials.access_key,
aws_secret_access_key=role.credentials.secret_key,
security_token=role.credentials.session_token)
elif service in 'vpc':
return boto.connect_vpc(
aws_access_key_id=role.credentials.access_key,
aws_secret_access_key=role.credentials.secret_key,
security_token=role.credentials.session_token)
elif service in 's3':
return boto.s3.connect_to_region(
region,
aws_access_key_id=role.credentials.access_key,
aws_secret_access_key=role.credentials.secret_key,
security_token=role.credentials.session_token)
def sts_client(service, service_type='client'): def sts_client(service, service_type='client'):
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)

View File

@ -1,4 +1,4 @@
import boto import boto3
from moto import mock_sts, mock_elb from moto import mock_sts, mock_elb
@ -6,9 +6,23 @@ from moto import mock_sts, mock_elb
@mock_elb() @mock_elb()
def test_get_all_elbs(app): def test_get_all_elbs(app):
from lemur.plugins.lemur_aws.elb import get_all_elbs from lemur.plugins.lemur_aws.elb import get_all_elbs
conn = boto.ec2.elb.connect_to_region('us-east-1') client = boto3.client('elb', region_name='us-east-1')
elbs = get_all_elbs(account_number='123456789012', region='us-east-1') elbs = get_all_elbs(account_number='123456789012', region='us-east-1')
assert not elbs assert not elbs
conn.create_load_balancer('example-lb', ['us-east-1a', 'us-east-1b'], [(443, 5443, 'tcp')])
client.create_load_balancer(
LoadBalancerName='example-lb',
Listeners=[
{
'Protocol': 'string',
'LoadBalancerPort': 443,
'InstanceProtocol': 'tcp',
'InstancePort': 5443,
'SSLCertificateId': 'tcp'
}
]
)
elbs = get_all_elbs(account_number='123456789012', region='us-east-1') elbs = get_all_elbs(account_number='123456789012', region='us-east-1')
assert elbs assert elbs

View File

@ -13,8 +13,11 @@ import requests
from flask import current_app from flask import current_app
from lemur.common.utils import parse_certificate
from lemur.common.utils import get_authority_key
from lemur.plugins.bases import IssuerPlugin from lemur.plugins.bases import IssuerPlugin
from lemur.plugins import lemur_cfssl as cfssl from lemur.plugins import lemur_cfssl as cfssl
from lemur.extensions import metrics
class CfsslIssuerPlugin(IssuerPlugin): class CfsslIssuerPlugin(IssuerPlugin):
@ -46,10 +49,15 @@ class CfsslIssuerPlugin(IssuerPlugin):
data = json.dumps(data) data = json.dumps(data)
response = self.session.post(url, data=data.encode(encoding='utf_8', errors='strict')) response = self.session.post(url, data=data.encode(encoding='utf_8', errors='strict'))
if response.status_code > 399:
metrics.send('cfssl_create_certificate_failure', 'counter', 1)
raise Exception(
"Error revoking cert. Please check your CFSSL API server")
response_json = json.loads(response.content.decode('utf_8')) response_json = json.loads(response.content.decode('utf_8'))
cert = response_json['result']['certificate'] cert = response_json['result']['certificate']
parsed_cert = parse_certificate(cert)
return cert, current_app.config.get('CFSSL_INTERMEDIATE'), metrics.send('cfssl_create_certificate_success', 'counter', 1)
return cert, current_app.config.get('CFSSL_INTERMEDIATE'), parsed_cert.serial_number
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):
@ -62,3 +70,20 @@ class CfsslIssuerPlugin(IssuerPlugin):
""" """
role = {'username': '', 'password': '', 'name': 'cfssl'} role = {'username': '', 'password': '', 'name': 'cfssl'}
return current_app.config.get('CFSSL_ROOT'), "", [role] return current_app.config.get('CFSSL_ROOT'), "", [role]
def revoke_certificate(self, certificate, comments):
"""Revoke a CFSSL certificate."""
base_url = current_app.config.get('CFSSL_URL')
create_url = '{0}/api/v1/cfssl/revoke'.format(base_url)
data = '{"serial": "' + certificate.external_id + '","authority_key_id": "' + \
get_authority_key(certificate.body) + \
'", "reason": "superseded"}'
current_app.logger.debug("Revoking cert: {0}".format(data))
response = self.session.post(
create_url, data=data.encode(encoding='utf_8', errors='strict'))
if response.status_code > 399:
metrics.send('cfssl_revoke_certificate_failure', 'counter', 1)
raise Exception(
"Error revoking cert. Please check your CFSSL API server")
metrics.send('cfssl_revoke_certificate_success', 'counter', 1)
return response.json()

View File

@ -187,7 +187,7 @@ class CryptographyIssuerPlugin(IssuerPlugin):
""" """
current_app.logger.debug("Issuing new cryptography certificate with options: {0}".format(options)) current_app.logger.debug("Issuing new cryptography certificate with options: {0}".format(options))
cert_pem, chain_cert_pem = issue_certificate(csr, options) cert_pem, chain_cert_pem = issue_certificate(csr, options)
return cert_pem, chain_cert_pem return cert_pem, chain_cert_pem, None
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):

View File

@ -0,0 +1,5 @@
try:
VERSION = __import__('pkg_resources') \
.get_distribution(__name__).version
except Exception as e:
VERSION = 'unknown'

View File

@ -0,0 +1,104 @@
"""
.. module: lemur.plugins.lemur_csr.plugin
An export plugin that exports CSR from a private key and certificate.
"""
from io import open
import subprocess
from flask import current_app
from lemur.utils import mktempfile, mktemppath
from lemur.plugins.bases import ExportPlugin
from lemur.plugins import lemur_csr as csr
def run_process(command):
"""
Runs a given command with pOpen and wraps some
error handling around it.
:param command:
:return:
"""
p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
current_app.logger.debug(command)
stdout, stderr = p.communicate()
if p.returncode != 0:
current_app.logger.debug(" ".join(command))
current_app.logger.error(stderr)
raise Exception(stderr)
def create_csr(cert, chain, csr_tmp, key):
"""
Creates a csr from key and cert file.
:param cert:
:param chain:
:param csr_tmp:
:param key:
"""
if isinstance(cert, bytes):
cert = cert.decode('utf-8')
if isinstance(chain, bytes):
chain = chain.decode('utf-8')
if isinstance(key, bytes):
key = key.decode('utf-8')
with mktempfile() as key_tmp:
with open(key_tmp, 'w') as f:
f.write(key)
with mktempfile() as cert_tmp:
with open(cert_tmp, 'w') as f:
if chain:
f.writelines([cert.strip() + "\n", chain.strip() + "\n"])
else:
f.writelines([cert.strip() + "\n"])
output = subprocess.check_output([
"openssl",
"x509",
"-x509toreq",
"-in", cert_tmp,
"-signkey", key_tmp,
])
subprocess.run([
"openssl",
"req",
"-out", csr_tmp
], input=output)
class CSRExportPlugin(ExportPlugin):
title = 'CSR'
slug = 'openssl-csr'
description = 'Exports a CSR'
version = csr.VERSION
author = 'jchuong'
author_url = 'https://github.com/jchuong'
def export(self, body, chain, key, options, **kwargs):
"""
Creates CSR from certificate
:param key:
:param chain:
:param body:
:param options:
:param kwargs:
"""
with mktemppath() as output_tmp:
if not key:
raise Exception("Private Key required by CSR")
create_csr(body, chain, output_tmp, key)
extension = "csr"
with open(output_tmp, 'rb') as f:
raw = f.read()
# passphrase is None
return extension, None, raw

View File

@ -0,0 +1 @@
from lemur.tests.conftest import * # noqa

View File

@ -0,0 +1,13 @@
import pytest
from lemur.tests.vectors import INTERNAL_PRIVATE_KEY_A_STR, INTERNAL_CERTIFICATE_A_STR
def test_export_certificate_to_csr(app):
from lemur.plugins.base import plugins
p = plugins.get('openssl-csr')
options = []
with pytest.raises(Exception):
p.export(INTERNAL_CERTIFICATE_A_STR, "", "", options)
raw = p.export(INTERNAL_CERTIFICATE_A_STR, "", INTERNAL_PRIVATE_KEY_A_STR, options)
assert raw != b""

View File

@ -83,26 +83,6 @@ def determine_validity_years(end_date):
" years in validity") " years in validity")
def get_issuance(options):
"""Get the time range for certificates.
:param options:
:return:
"""
validity_years = options.get('validity_years')
if validity_years:
options['validity_end'] = None
return options
else:
if not options.get('validity_end'):
options['validity_end'] = arrow.utcnow().replace(years=current_app.config.get('DIGICERT_DEFAULT_VALIDITY', 1))
options['validity_years'] = determine_validity_years(options['validity_end'])
return options
def get_additional_names(options): def get_additional_names(options):
""" """
Return a list of strings to be added to a SAN certificates. Return a list of strings to be added to a SAN certificates.
@ -126,7 +106,9 @@ def map_fields(options, csr):
:param csr: :param csr:
:return: dict or valid DigiCert options :return: dict or valid DigiCert options
""" """
options = get_issuance(options) if not options.get('validity_years'):
if not options.get('validity_end'):
options['validity_years'] = current_app.config.get('DIGICERT_DEFAULT_VALIDITY', 1)
data = dict(certificate={ data = dict(certificate={
"common_name": options['common_name'], "common_name": options['common_name'],
@ -139,10 +121,16 @@ def map_fields(options, csr):
data['certificate']['dns_names'] = get_additional_names(options) data['certificate']['dns_names'] = get_additional_names(options)
if options.get('validity_end'): if options.get('validity_years'):
data['validity_years'] = options['validity_years']
else:
data['custom_expiration_date'] = options['validity_end'].format('YYYY-MM-DD') data['custom_expiration_date'] = options['validity_end'].format('YYYY-MM-DD')
data['validity_years'] = options.get('validity_years') if current_app.config.get('DIGICERT_PRIVATE', False):
if 'product' in data:
data['product']['type_hint'] = 'private'
else:
data['product'] = dict(type_hint='private')
return data return data
@ -155,7 +143,13 @@ def map_cis_fields(options, csr):
:param csr: :param csr:
:return: :return:
""" """
options = get_issuance(options) if not options.get('validity_years'):
if not options.get('validity_end'):
options['validity_end'] = arrow.utcnow().replace(years=current_app.config.get('DIGICERT_DEFAULT_VALIDITY', 1))
options['validity_years'] = determine_validity_years(options['validity_end'])
else:
options['validity_end'] = arrow.utcnow().replace(years=options['validity_years'])
data = { data = {
"profile_name": current_app.config.get('DIGICERT_CIS_PROFILE_NAME'), "profile_name": current_app.config.get('DIGICERT_CIS_PROFILE_NAME'),
"common_name": options['common_name'], "common_name": options['common_name'],
@ -181,7 +175,7 @@ def handle_response(response):
:return: :return:
""" """
if response.status_code > 399: if response.status_code > 399:
raise Exception(response.json()['message']) raise Exception(response.json()['errors'][0]['message'])
return response.json() return response.json()
@ -241,7 +235,6 @@ class DigiCertSourcePlugin(SourcePlugin):
'DIGICERT_URL', 'DIGICERT_URL',
'DIGICERT_ORG_ID', 'DIGICERT_ORG_ID',
'DIGICERT_ROOT', 'DIGICERT_ROOT',
'DIGICERT_INTERMEDIATE'
] ]
validate_conf(current_app, required_vars) validate_conf(current_app, required_vars)
@ -279,7 +272,6 @@ class DigiCertIssuerPlugin(IssuerPlugin):
'DIGICERT_URL', 'DIGICERT_URL',
'DIGICERT_ORG_ID', 'DIGICERT_ORG_ID',
'DIGICERT_ROOT', 'DIGICERT_ROOT',
'DIGICERT_INTERMEDIATE'
] ]
validate_conf(current_app, required_vars) validate_conf(current_app, required_vars)
@ -311,16 +303,26 @@ class DigiCertIssuerPlugin(IssuerPlugin):
response = self.session.post(determinator_url, data=json.dumps(data)) response = self.session.post(determinator_url, data=json.dumps(data))
if response.status_code > 399: if response.status_code > 399:
raise Exception(response.json()['message']) raise Exception(response.json()['errors'][0]['message'])
order_id = response.json()['id'] order_id = response.json()['id']
certificate_id = get_certificate_id(self.session, base_url, order_id) certificate_id = get_certificate_id(self.session, base_url, order_id)
# retrieve ceqrtificate # retrieve certificate
certificate_url = "{0}/services/v2/certificate/{1}/download/format/pem_all".format(base_url, certificate_id) certificate_url = "{0}/services/v2/certificate/{1}/download/format/pem_all".format(base_url, certificate_id)
end_entity, intermediate, root = pem.parse(self.session.get(certificate_url).content) end_entity, intermediate, root = pem.parse(self.session.get(certificate_url).content)
return "\n".join(str(end_entity).splitlines()), "\n".join(str(end_entity).splitlines()) return "\n".join(str(end_entity).splitlines()), "\n".join(str(intermediate).splitlines()), certificate_id
def revoke_certificate(self, certificate, comments):
"""Revoke a Digicert certificate."""
base_url = current_app.config.get('DIGICERT_URL')
# make certificate revoke request
create_url = '{0}/certificate/{1}/revoke'.format(base_url, certificate.external_id)
metrics.send('digicert_revoke_certificate', 'counter', 1)
response = self.session.put(create_url, data=json.dumps({'comments': comments}))
return handle_response(response)
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):
@ -337,6 +339,74 @@ class DigiCertIssuerPlugin(IssuerPlugin):
return current_app.config.get('DIGICERT_ROOT'), "", [role] return current_app.config.get('DIGICERT_ROOT'), "", [role]
class DigiCertCISSourcePlugin(SourcePlugin):
"""Wrap the Digicert CIS Certifcate API."""
title = 'DigiCert'
slug = 'digicert-cis-source'
description = "Enables the use of Digicert as a source of existing certificates."
version = digicert.VERSION
author = 'Kevin Glisson'
author_url = 'https://github.com/netflix/lemur.git'
additional_options = []
def __init__(self, *args, **kwargs):
"""Initialize source with appropriate details."""
required_vars = [
'DIGICERT_CIS_API_KEY',
'DIGICERT_CIS_URL',
'DIGICERT_CIS_ROOT',
'DIGICERT_CIS_INTERMEDIATE',
'DIGICERT_CIS_PROFILE_NAME'
]
validate_conf(current_app, required_vars)
self.session = requests.Session()
self.session.headers.update(
{
'X-DC-DEVKEY': current_app.config['DIGICERT_CIS_API_KEY'],
'Content-Type': 'application/json'
}
)
self.session.hooks = dict(response=log_status_code)
a = requests.adapters.HTTPAdapter(max_retries=3)
self.session.mount('https://', a)
super(DigiCertCISSourcePlugin, self).__init__(*args, **kwargs)
def get_certificates(self, options, **kwargs):
"""Fetch all Digicert certificates."""
base_url = current_app.config.get('DIGICERT_CIS_URL')
# make request
search_url = '{0}/platform/cis/certificate/search'.format(base_url)
certs = []
page = 1
while True:
response = self.session.get(search_url, params={'status': ['issued'], 'page': page})
data = handle_cis_response(response)
for c in data['certificates']:
download_url = '{0}/platform/cis/certificate/{1}'.format(base_url, c['id'])
certificate = self.session.get(download_url)
# normalize serial
serial = str(int(c['serial_number'], 16))
cert = {'body': certificate.content, 'serial': serial, 'external_id': c['id']}
certs.append(cert)
if page == data['total_pages']:
break
page += 1
return certs
class DigiCertCISIssuerPlugin(IssuerPlugin): class DigiCertCISIssuerPlugin(IssuerPlugin):
"""Wrap the Digicert Certificate Issuing API.""" """Wrap the Digicert Certificate Issuing API."""
title = 'DigiCert CIS' title = 'DigiCert CIS'
@ -387,7 +457,22 @@ class DigiCertCISIssuerPlugin(IssuerPlugin):
self.session.headers.pop('Accept') self.session.headers.pop('Accept')
end_entity = pem.parse(certificate_pem)[0] end_entity = pem.parse(certificate_pem)[0]
return "\n".join(str(end_entity).splitlines()), current_app.config.get('DIGICERT_CIS_INTERMEDIATE') return "\n".join(str(end_entity).splitlines()), current_app.config.get('DIGICERT_CIS_INTERMEDIATE'), data['id']
def revoke_certificate(self, certificate, comments):
"""Revoke a Digicert certificate."""
base_url = current_app.config.get('DIGICERT_CIS_URL')
# make certificate revoke request
revoke_url = '{0}/platform/cis/certificate/{1}/revoke'.format(base_url, certificate.external_id)
metrics.send('digicert_revoke_certificate_success', 'counter', 1)
response = self.session.put(revoke_url, data=json.dumps({'comments': comments}))
if response.status_code != 204:
metrics.send('digicert_revoke_certificate_failure', 'counter', 1)
raise Exception('Failed to revoke certificate.')
metrics.send('digicert_revoke_certificate_success', 'counter', 1)
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):

View File

@ -1,5 +1,6 @@
import pytest import pytest
import arrow import arrow
import json
from freezegun import freeze_time from freezegun import freeze_time
from lemur.tests.vectors import CSR_STR from lemur.tests.vectors import CSR_STR
@ -35,8 +36,7 @@ def test_map_fields_with_validity_end_and_start(app):
'signature_hash': 'sha256' 'signature_hash': 'sha256'
}, },
'organization': {'id': 111111}, 'organization': {'id': 111111},
'custom_expiration_date': arrow.get(2017, 5, 7).format('YYYY-MM-DD'), 'custom_expiration_date': arrow.get(2017, 5, 7).format('YYYY-MM-DD')
'validity_years': 1
} }
@ -106,35 +106,35 @@ def test_map_cis_fields(app):
'profile_name': None 'profile_name': None
} }
options = {
def test_issuance(): 'common_name': 'example.com',
from lemur.plugins.lemur_digicert.plugin import get_issuance 'owner': 'bob@example.com',
'description': 'test certificate',
'extensions': {
'sub_alt_names': {
'names': [x509.DNSName(x) for x in names]
}
},
'organization': 'Example, Inc.',
'organizational_unit': 'Example Org',
'validity_years': 2
}
with freeze_time(time_to_freeze=arrow.get(2016, 11, 3).datetime): with freeze_time(time_to_freeze=arrow.get(2016, 11, 3).datetime):
options = { data = map_cis_fields(options, CSR_STR)
'validity_end': arrow.get(2018, 5, 7),
'validity_start': arrow.get(2016, 10, 30) assert data == {
'common_name': 'example.com',
'csr': CSR_STR,
'additional_dns_names': names,
'signature_hash': 'sha256',
'organization': {'name': 'Example, Inc.', 'units': ['Example Org']},
'validity': {
'valid_to': arrow.get(2018, 11, 3).format('YYYY-MM-DD')
},
'profile_name': None
} }
new_options = get_issuance(options)
assert new_options['validity_years'] == 2
options = {
'validity_end': arrow.get(2017, 5, 7),
'validity_start': arrow.get(2016, 10, 30)
}
new_options = get_issuance(options)
assert new_options['validity_years'] == 1
options = {
'validity_end': arrow.get(2020, 5, 7),
'validity_start': arrow.get(2016, 10, 30)
}
with pytest.raises(Exception):
period = get_issuance(options)
def test_signature_hash(app): def test_signature_hash(app):
from lemur.plugins.lemur_digicert.plugin import signature_hash from lemur.plugins.lemur_digicert.plugin import signature_hash
@ -146,3 +146,32 @@ def test_signature_hash(app):
with pytest.raises(Exception): with pytest.raises(Exception):
signature_hash('sdfdsf') signature_hash('sdfdsf')
def test_issuer_plugin_create_certificate():
import requests_mock
from lemur.plugins.lemur_digicert.plugin import DigiCertIssuerPlugin
pem_fixture = """\
-----BEGIN CERTIFICATE-----
abc
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
def
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
ghi
-----END CERTIFICATE-----
"""
subject = DigiCertIssuerPlugin()
adapter = requests_mock.Adapter()
adapter.register_uri('POST', 'mock://www.digicert.com/services/v2/order/certificate/ssl', text=json.dumps({'id': 'id123'}))
adapter.register_uri('GET', 'mock://www.digicert.com/services/v2/order/certificate/id123', text=json.dumps({'status': 'issued', 'certificate': {'id': 'cert123'}}))
adapter.register_uri('GET', 'mock://www.digicert.com/services/v2/certificate/cert123/download/format/pem_all', text=pem_fixture)
subject.session.mount('mock', adapter)
cert, intermediate, external_id = subject.create_certificate("", {'common_name': 'test.com'})
assert cert == "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----"
assert intermediate == "-----BEGIN CERTIFICATE-----\ndef\n-----END CERTIFICATE-----"

View File

@ -6,7 +6,7 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import boto.ses import boto3
from flask import current_app from flask import current_app
from flask_mail import Message from flask_mail import Message
@ -54,8 +54,25 @@ def send_via_ses(subject, body, targets):
:param targets: :param targets:
:return: :return:
""" """
conn = boto.connect_ses() client = boto3.client('ses', region_name='us-east-1')
conn.send_email(current_app.config.get("LEMUR_EMAIL"), subject, body, targets, format='html') client.send_email(
Source=current_app.config.get('LEMUR_EMAIL'),
Destination={
'ToAddresses': targets
},
Message={
'Subject': {
'Data': subject,
'Charset': 'UTF-8'
},
'Body': {
'Html': {
'Data': body,
'Charset': 'UTF-8'
}
}
}
)
class EmailNotificationPlugin(ExpirationNotificationPlugin): class EmailNotificationPlugin(ExpirationNotificationPlugin):

View File

@ -1,11 +1,12 @@
import os import os
import arrow import arrow
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader, select_autoescape
from lemur.plugins.utils import get_plugin_option from lemur.plugins.utils import get_plugin_option
loader = FileSystemLoader(searchpath=os.path.dirname(os.path.realpath(__file__))) loader = FileSystemLoader(searchpath=os.path.dirname(os.path.realpath(__file__)))
env = Environment(loader=loader) env = Environment(loader=loader, # nosec: potentially dangerous types esc.
autoescape=select_autoescape(['html', 'xml']))
def human_time(time): def human_time(time):

View File

@ -19,19 +19,15 @@ def test_render(certificate, endpoint):
template = env.get_template('{}.html'.format('expiration')) template = env.get_template('{}.html'.format('expiration'))
with open(os.path.join(dir_path, 'expiration-rendered.html'), 'w') as f: body = template.render(dict(message=data, hostname='lemur.test.example.com'))
body = template.render(dict(message=data, hostname='lemur.test.example.com'))
f.write(body)
template = env.get_template('{}.html'.format('rotation')) template = env.get_template('{}.html'.format('rotation'))
certificate.endpoints.append(endpoint) certificate.endpoints.append(endpoint)
with open(os.path.join(dir_path, 'rotation-rendered.html'), 'w') as f: body = template.render(
body = template.render( dict(
dict( certificate=certificate_notification_output_schema.dump(certificate).data,
certificate=certificate_notification_output_schema.dump(certificate).data, hostname='lemur.test.example.com'
hostname='lemur.test.example.com'
)
) )
f.write(body) )

Some files were not shown because too many files have changed in this diff Show More