Compare commits

..

193 Commits
0.2.2 ... 0.4.0

Author SHA1 Message Date
d11f254476 Closes: #469 (#510) 2016-11-17 12:16:30 -08:00
d54a11ad11 Ensuring coverage is run. (#509) 2016-11-17 11:11:09 -08:00
a9361fe428 Endpoints should be visible to all. (#508) 2016-11-17 10:45:26 -08:00
5345170a4f Ensuring that the passed in configuration has precedence over the environment config. (#507) 2016-11-17 09:31:37 -08:00
d0ccd85afe Adding coverage. (#506)
* Adding coverage.

* Attempting to adding coverage.

* Adding coveragerc.
2016-11-16 16:44:51 -08:00
520404c215 fix string -> byte conversion on python2 (#472) 2016-11-16 16:03:38 -08:00
9ac1756011 removing new 'active' logic for the time being (#505) 2016-11-16 15:56:24 -08:00
851d74da3d Ensuring that private key is in string format before it gets stored (#504)
* Ensuring that private key is in string format before it gets stored

* Fixing failing test.
2016-11-16 15:05:25 -08:00
3f2691c5d4 Minor fixes. (#502) 2016-11-16 13:23:35 -08:00
eaf34b1c8b Disabling the protect active flag (#498) 2016-11-16 09:31:02 -08:00
e9219adfb5 Ensuring model's have a basic __repr__. (#499) 2016-11-16 09:30:54 -08:00
9eddaf66cb adding human readable string (#500) 2016-11-16 09:30:46 -08:00
0a29a3fa2a Adding release notes. (#459) 2016-11-15 16:44:40 -08:00
9bb0787410 Ensuring that duplicates are migrated correctly. (#496)
* Ensuring that duplicates are migrated correctly.

* fixing typo
2016-11-15 16:43:45 -08:00
dd14fd202d clean out ADMINS references (#495)
* add variables to the documentation forwq oauth2

* remove old reference to ADMINS to get rid of any confusion
2016-11-15 16:43:28 -08:00
114deba06e Adding the ability to silence notifications on creation. (#490) 2016-11-12 09:29:42 -08:00
0334f1094d fixing documentation typo (#489) 2016-11-11 13:35:24 -08:00
7af68c3cc0 Adding additional metric gathering for failed sync operations. (#488) 2016-11-11 13:28:01 -08:00
953d3a08e7 Adding example request to documentation. (#487) 2016-11-11 12:54:12 -08:00
f141ae78f3 Typo. (#485) 2016-11-10 14:40:59 -08:00
94d619cfa6 Minor errors. (#484) 2016-11-10 14:34:45 -08:00
89470a0ce0 Adding default validity and retry logic. (#483) 2016-11-10 11:23:37 -08:00
e6b291d034 Time (#482)
* adding python 3.5 as a target

* adding env flag

* Aligning on arrow dates.
2016-11-09 10:56:22 -08:00
b0eef03c73 adding python 3.5 as a target (#481)
* adding python 3.5 as a target

* adding env flag
2016-11-08 15:22:50 -08:00
25a6c722b6 Adding digicert documentation. (#480) 2016-11-08 14:56:05 -08:00
67a5993926 fixing type in ciphers (#479) 2016-11-08 12:23:21 -08:00
aa979e31fd Digicert plugin (#478)
* Initial work on digicert plugin.

* Adding certificate pickup, to digicert plugin.

* Removing and rotating test api key.
2016-11-07 14:40:00 -08:00
b74df2b3e4 Minor changes for python3. (#477) 2016-11-07 14:33:07 -08:00
4afedaf537 Fixes (#476)
* Ensures that Vault can accept bytes and strings.

* Make restricted domains optional.

* Fixing notify flag.
2016-11-04 09:16:41 -07:00
2b79474060 Trying this to fix defaulting org to Netflix (#475) 2016-11-02 09:12:47 -07:00
a6360ebfe5 Adding pending certificate metric. (#473) 2016-11-01 14:24:45 -07:00
d99681904e Fixing test to take python3 into account. (#460)
* Fixing test to take python3 into account.
2016-10-31 17:02:08 -07:00
1ac1a44e83 San alt name (#468) 2016-10-31 11:00:15 -07:00
f990f92977 Fixing typo in documentation for LEMUR_DEFAULT_ORGANIZATIONAL_UNIT spelling (#467) 2016-10-27 20:26:28 -07:00
490d5b6e6c python2.x .base64url_decode has a single parameter and incoming data is utf-8.. need to convert so string (#463) 2016-10-26 00:50:00 -07:00
4b7fc8551c fix(web): send JSON for all errors (#464)
Configure werkzeug to output JSON error messages for the benefit of
downstream clients. This also allows for metrics collection in all cases
where werkzeug is outputting an exception.
2016-10-26 00:46:43 -07:00
cd9c112218 Implement a CFSSL issuer plugin (#452)
* Implement CFSSL issuer plugin

Implement a Lemur plugin for generating certificates from the open
source certificate authority CFSSL
(https://github.com/cloudflare/cfssl). The plugin interacts with CFSSL
through the CFSSL REST API. The CFSSL configuration is defined in the
lemur.conf.py property file using property names prefixed with "CFSSL_".

* Update documentation to include CFSSL plugin
2016-10-22 00:52:18 -07:00
a8f44944b1 Closes #415 2016-10-17 23:23:14 -07:00
d31c9b19ce Closes #412. Allows 'name' be a valid attribute to specify a role. (#457) 2016-10-16 03:56:13 -07:00
fb178866f4 Fixes an issue with the source tests failing. (#456) 2016-10-16 03:55:37 -07:00
f921b67fff Removing the ability to use spaces in custom names. (#455) 2016-10-15 04:56:25 -07:00
c367e4f73f Prevents the silencing of notifications that are actively deployed. (#454)
* Renaming 'active' to 'notify' as this is clearer and more aligned to what this value is actually controlling. 'active' is now a property that depends on whether any endpoints were found to be using the certificate. Also added logic for issue #405 disallowing for a certificates' notifications to be silenced when it is actively deployed on an endpoint.

* Adding migration script to alter 'active' column.
2016-10-15 00:12:11 -07:00
dcb18a57c4 Adds option to restrict certificate expiration dates to weekdays. (#453)
* Adding ability to restrict certificate creation to weekdays.

* Ensuring that we test for weekends.
2016-10-15 00:04:35 -07:00
1b861baf0a Updating gulp-protractor dependency (#451)
Following the quickstart instructions, I ran into issues at `make develop` because
several dependencies couldn't be resolved. Several errors like this in the output
of `npm install`:

```
npm ERR! TypeError: Cannot read property 'latest' of undefined
npm ERR!     at next (/usr/share/npm/lib/cache.js:687:35)
npm ERR!     at /usr/share/npm/lib/cache.js:675:5
npm ERR!     at saved (/usr/share/npm/node_modules/npm-registry-client/lib/get.js:142:7)
npm ERR!     at /usr/lib/nodejs/graceful-fs/polyfills.js:133:7
npm ERR!     at Object.oncomplete (fs.js:107:15)
npm ERR! If you need help, you may report this log at:
npm ERR!     <http://github.com/isaacs/npm/issues>
npm ERR! or email it to:
npm ERR!     <npm-@googlegroups.com>

npm ERR! System Linux 3.13.0-92-generic
npm ERR! command "/usr/bin/nodejs" "/usr/bin/npm" "install"
npm ERR! cwd /home/lemur/lemur
npm ERR! node -v v0.10.25
npm ERR! npm -v 1.3.10
npm ERR! type non_object_property_load
```

`npm list` yielded this output at the bottom:

```
npm ERR! missing: @types/jasmine@^2.2.31, required by protractor@4.0.9
npm ERR! missing: @types/node@^6.0.35, required by protractor@4.0.9
npm ERR! missing: @types/q@^0.0.30, required by protractor@4.0.9
npm ERR! missing: @types/selenium-webdriver@~2.53.30, required by protractor@4.0.9
npm ERR! missing: adm-zip@0.4.7, required by protractor@4.0.9
npm ERR! missing: chalk@^1.1.3, required by protractor@4.0.9
npm ERR! missing: glob@^7.0.3, required by protractor@4.0.9
npm ERR! missing: jasmine@2.5.2, required by protractor@4.0.9
npm ERR! missing: jasminewd2@0.0.10, required by protractor@4.0.9
npm ERR! missing: optimist@~0.6.0, required by protractor@4.0.9
npm ERR! missing: q@1.4.1, required by protractor@4.0.9
npm ERR! missing: saucelabs@~1.3.0, required by protractor@4.0.9
npm ERR! missing: selenium-webdriver@2.53.3, required by protractor@4.0.9
npm ERR! missing: source-map-support@~0.4.0, required by protractor@4.0.9
npm ERR! missing: webdriver-manager@^10.2.2, required by protractor@4.0.9
npm ERR! not ok code 0
```

lemur depends explicitly on gulp-protractor 0.0.11 explicitly
gulp-protractor 0.0.11 depends on protractor at _any_ version (*)
The latest versions of protractor (@4) require much newer versions of nodejs
according to the Compatibility section of
https://www.npmjs.com/package/protractor.

gulp-protractor 0.0.12 fixes and constrains some of these dependencies better
and adds a debug option, and fixes a few typos in comments and metadata.
https://github.com/mllrsohn/gulp-protractor/compare/0.0.11...0.0.12
2016-10-14 14:06:13 -07:00
10d833e598 Added Symantec plugin error checking for invalid domain suffix (#449) 2016-10-13 15:23:56 -07:00
708d85abeb Fixes a bug where certificates discovered by lemur's source plugins were not given the appropriate default notifications. (#447) 2016-10-11 21:08:13 -07:00
ee028382df Show only roles that the user is a member of, in list view, for other views show all roles such that certificates and authorities can be shared across teams/groups. (#446) 2016-10-11 17:56:38 -07:00
c05a49f8c9 Fixes an issuer where a member of a role is not able to add new users to said role. (#445) 2016-10-11 17:24:15 -07:00
35cfb50955 add variables to the documentation forwq oauth2 (#444) 2016-10-11 17:23:25 -07:00
f179e74a4a Fix Java export default password generator (#441)
When exporting a certificate, the password is an optional parameter.
When a password is not supplied by the caller, a default password is
generated by the method. The generation library creates the random
password as a bytes object. The bytes object raises an error in the
'keytool' command used to export the certificate. The keytool is
expecting the password to be a str object.

The fix is to decode the generated password from a bytes object to a str
object.

The associated Java plugin tests have been updated to verify the export
method returns the password as a str object. In addition, the tests have
been updated to correctly test the export methods response object. The
original tests treated the response as a single object. The current
export methods return a tuple of data (type, password, data).

In order to make the tests compatible with both Python2 and Python3, the
'six' library was used to test the password is in fact a string.
2016-10-10 22:43:23 -07:00
9065aa3750 Update the private key regex validation (#435)
* Update the private key regex validation

Private keys provided by the Let's Encrypt certificate authority as part
of their certificate bundle fail the import/upload certificate private
key validation. The validation is looking for a specific character
sequence at the begin of the certificate. In order to support valid
Let's Encrypt private keys, the regex has been updated to check for both
the existing sequence and the Let's Encrypt character sequence.

Example Let's Encrypt private key:

-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvsiwV8A5+r0tQ
QzUAJO0DfoEb9tMWvoFi0DLs9tx88IwMqItPPl9+RNzQnv9qqZR1h4W97sxP8aWY
...
AeS667IJO/2DMKaGiEldaVZtgqdUhCL8Rm4XUFVb1GjLa03E4VRU6W7eQ4hgT2a7
cHDAR8MiovNyfT0fm8Xz3ac=
-----END PRIVATE KEY-----

* Add private key regex for footer

Update the import/upload private key validation regex to verify both the
header and footer are matching.
2016-10-10 22:42:09 -07:00
96e42c793e Refactors the default notification option. Also ensures that notifications and destinations are easier to test. (#437) 2016-10-09 00:06:53 -07:00
72a390c563 Ensure the openssl and cryptography work under python3. (#438) 2016-10-09 00:06:15 -07:00
a19c918c68 Closes #411 (#439) 2016-10-09 00:06:03 -07:00
c45c23ae6f Detect root (#440)
* Closes #430
2016-10-09 00:05:50 -07:00
5cbf5365c5 Active S3 destination plugin (#433)
* Activate the AWS S3 destination plugin

Add the AWS S3 destination plugin to the list of available Lemur
plugins.

Update the S3 destination plugin's "accountNumber" option to be of type
'str' to handle account numbers starting with zeros.

Update Lemur's utils for parsing certificates to correctly encode the
X509 certificates before loading for python3.

* Add S3 destination plugin test

Added simple test to verify S3 destination plugin is available.
2016-10-08 17:06:20 -07:00
3ad7a37f95 Fix import certificate private key encoding (#434)
When importing a certificate, the private key is passed to the
import/upload process from the UI as a str object. In Python3 this
raises two issues when processing the private key - the private key
validation fails and database insert of the certificate fails.

The fix in both cases is to correctly encode the private key as a bytes
object.
2016-10-08 17:04:54 -07:00
6cac2838e3 Fix for missing profile pic. (#429) 2016-09-27 13:02:01 -07:00
fbbf7f90f6 Fix test certificates module hanging issue (#427)
* Fix test certificates module hanging issue

When executing the lemur/tests/test_certificates.py module's tests, all
tests are executed, but the test process appears to hang and never
completes with the display of the results for the tests.

The hanging issue is traced to the two test methods:
test_import(logged_in_user) and test_upload(logged_in_user). The issue
has to do with the test methods' using the logged_in_user(app) fixture from
the conftest.py module as the method parameter.

The test methods at issue require the session, db, and app fixtures to
be initialized for the tests to complete successfully. The
logged_in_user() fixture only initializes the app fixture. Updating the
test_import() and test_upload() methods parameters to be the "session"
fixture fixes the hanging issue and the tests complete successfully.

This is the command being used to execute the tests...
$ py.test -s -v lemur/tests/test_certificates.py

* Update fix for test certificates hanging issue

Based on feedback from the original pull request for this fix, added the
session fixture to the logged_in_user fixture and reverted the
test_import() and test_upload() methods to use the logged_in_user
(instead of the session fixture).
2016-09-27 13:01:37 -07:00
1ea75a5d2d fix(certificates): import re module (#428) 2016-09-21 22:54:46 -07:00
3ce87c8a6b Fix code reference to older version of flake8 (#426)
The pre-commit module contained a reference to the variable
DEFAULT_CONFIG which does not exist in the version of flake8 packaged
with the project. The DEFAULT_CONFIG defines the path to the current
user's local flake8 config file.

The flake8 2.6.2 version packaged with project has been refactored to
set the local flake8 config file in the flake8/main.py module.
2016-09-18 11:05:29 -07:00
39645a1a84 feat(certificates): add support for restricted domains (#424)
Lemur's documentation already mentions LEMUR_RESTRICTED_DOMAINS, a list
of regular expressions matching domains only administrators can issue
certificates for. An option to mark domains as sensitive existed in the
API, however the configuration option was not implemented.

Now both ways of sensitivity are checked in the same place.
2016-09-12 16:59:14 -07:00
a60e372c5a Ensuring that password hashes are compared correctly under python3 2016-09-07 13:25:51 -07:00
76cece7b90 Ensuring that private keys are retrieved correctly under python3. (#422) 2016-09-07 12:34:50 -07:00
ca2944d566 Ensuring the inactive certificates are not alerted on. (#418) 2016-08-29 15:46:35 -07:00
53d0636574 Python3 (#417)
* Fixing tests.

* Fixing issue where decrypted credentials were not returning valid strings.

* Fixing issues with python3 authentication.
2016-08-29 08:58:53 -07:00
7e6278684c Python3 (#416)
* Fixing issue where decrypted credentials were not returning valid strings.
2016-08-26 16:02:23 -07:00
2d7a6ccf3c Owner email (#414)
* Ensuring python2 works with unicode strings.

* adding in owner DN

* fixing tests

* Upgrading requests.

* Fixing tests.
2016-08-25 10:09:46 -07:00
18b99c0de4 Fixing an issue where openssl can't find the certificates to create PKCS12 files (#408) 2016-08-17 10:33:59 -07:00
96674571a5 Fix a typo. UI -> API (#407) 2016-07-29 18:29:44 -07:00
29a330b1f4 Orphaned certificates (#406)
* Fixing whitespace.

* Fixing syncing.

* Fixing tests
2016-07-28 13:08:24 -07:00
a644f45625 Adding some simplified reporting. (#403)
* Adding issuance report.

* Fixing whitespace.
2016-07-27 12:41:32 -07:00
3db669b24d Ensuring that the temporary certificate is created correctly (#400) 2016-07-12 18:07:11 -07:00
f38868a97f Fixing various problems with the syncing of endpoints, throttling sta… (#398)
* Fixing various problems with the syncing of endpoints, throttling stale endpoints etc.
2016-07-12 08:40:49 -07:00
4f3dc5422c Allowing the role-user associated to be updated. (#396)
* Allowing the role-user associated to be updated.

* Fixing tests

* Fixing tests, for real.
2016-07-07 13:03:10 -07:00
1ba7181067 Fixed an issue were default notifications were added even when updati… (#395)
* Fixed an issue were default notifications were added even when updating a certificate, resulting in duplicate notifications.

* Ensuring imported certificates get the same treatment.
2016-07-07 11:44:11 -07:00
74bf54cb8f Slack spruce up (#394)
* Formatting slack message.

* Tweaking tests.
2016-07-06 10:27:13 -07:00
d4732d3ab0 Closes #335. (#392) 2016-07-04 16:08:16 -07:00
cb9631b122 Closes #356. (#391) 2016-07-04 15:38:51 -07:00
4077893d08 Ensuring that destinations require private keys by default. (#390)
* Ensuring that destinations require private keys by default.
2016-07-04 15:30:20 -07:00
4ee1c21144 Closes #372 (#389)
* Closes #372
2016-07-04 14:32:46 -07:00
c8eca56690 Closes #366 (#387) 2016-07-04 13:03:46 -07:00
300e2d0b7d Adding plugin tests. (#385)
* Adding plugin tests.

* Fixing some python 2/3 incompatibilities.
2016-07-01 11:32:19 -07:00
a8040777b3 Upgrading plugin docs with better example. (#386) 2016-07-01 10:50:18 -07:00
e34de921b6 Target Individuals for Certificates (#384)
* Allowing individual users to be targeted for a role.

* Ensuring that even new users get a per user-role
2016-07-01 09:04:39 -07:00
a04f707f63 Fixing readme badges (#382) 2016-06-30 09:06:14 -07:00
9aec899bfd Fixing a few errors.
* Fixing organizational_unit and common name

* FIxing organization name and allow creaters to view CA.
2016-06-29 16:16:37 -07:00
afb66df1a4 Adding plugin information to docs. (#379)
* Adding documentation about the installed plugins.

* Adding new default option.
2016-06-29 10:08:54 -07:00
54b888bb08 Adding a toy certificate authority. (#378) 2016-06-29 09:05:39 -07:00
eefff8497a Adding a new default issuer. 2016-06-28 17:46:26 -07:00
ecbab64c35 Adding endpoint migration script. (#376) 2016-06-28 16:12:56 -07:00
c8447dea3d Fixing a few issues with startup. (#374) 2016-06-28 14:28:05 -07:00
5021e8ba91 Adding ACME Support (#178) 2016-06-27 15:57:53 -07:00
f846d78778 S3 destination (#371) 2016-06-27 15:11:46 -07:00
fe9703dd94 Closes #284 (#336) 2016-06-27 14:40:46 -07:00
b44a7c73d8 Kubernetes desination plugin (#357)
* Kubernetes desination plugin

* fixing build warnings

* fixing build warnings
2016-06-27 14:40:01 -07:00
9ae27f1415 Merge pull request #368 from kevgliss/367-role-permission
Fixes #367
2016-06-23 13:44:46 -07:00
19b928d663 Fixes #367 2016-06-23 13:29:59 -07:00
5193342b3a Merge pull request #365 from kevgliss/docs
Updating flake8 ignore
2016-06-23 09:59:08 -07:00
109fb4bb45 Updating flake8 ignore 2016-06-23 09:40:55 -07:00
d6ccd812c2 Merge pull request #364 from kevgliss/docs
Updating requirements.txt
2016-06-23 09:20:56 -07:00
81a6228028 Updating requirements.txt 2016-06-23 09:20:35 -07:00
eeb216b75e Merge pull request #362 from kevgliss/docs
Fixing documentation requirement.
2016-06-22 14:05:13 -07:00
6714595fee Fixing documentation requirement. 2016-06-22 14:04:41 -07:00
025924c4f7 Merge pull request #361 from kevgliss/docs
Aadding an httpdomain version
2016-06-22 14:02:50 -07:00
7c10c8dac7 adding an httpdomain version 2016-06-22 13:59:32 -07:00
daea8f6ae4 Bug fixes (#355)
* we should not require password to update users

* Fixing an issue were roles would not be added.
2016-06-13 17:22:45 -07:00
41d1fe9191 Using UTC time in JWT token creation (#354)
As stated in PyJWT's documentation [1] and JWT specification [2][3], UTC
times must be used. This commit fixes JWT decoding in servers not using
UTC time.

[1] https://pypi.python.org/pypi/PyJWT/1.4.0
[2] https://tools.ietf.org/html/rfc7519#section-4.1.6
[3] https://tools.ietf.org/html/rfc7519#section-2
2016-06-13 11:18:07 -07:00
7d50e4d65f Merge pull request #353 from mikegrima/issue352
Fix for Issue #352.
2016-06-09 15:13:37 -07:00
9a653403ae Fix for Issue #352. 2016-06-08 16:41:31 -07:00
77f13c9edb Fixing issue were, after a user changes their mind validity years wil… (#349) 2016-06-06 12:11:40 -07:00
d95b1a0a41 release bump (#348) 2016-06-06 09:01:19 -07:00
d9cc4980e8 Fixing destination upload. (#347)
* Fixing an issue where uploaded certificates would have a name of 'None'

* Clarifying comment.

* Improving order.
2016-06-03 18:45:58 -07:00
5e987fa8b6 Adding additional data migrations. (#346) 2016-06-03 17:56:32 -07:00
42001be9ec Fixing the way filters were toggled. (#345) 2016-06-03 09:24:17 -07:00
dc198fec8c Docs (#344)
* Adding release info.

* adding some fields

* Adding Source Plugin change.

* Updating docs
2016-06-03 08:28:09 -07:00
acd47d5ec9 Fixing an issue were authorities were not related to their roles (#342) 2016-06-02 09:07:17 -07:00
72e3fb5bfe Fixing several small issues. (#341)
* Fixing several small issues.

* Fixing tests.
2016-06-01 11:18:00 -07:00
b2539b843b Fixing and error causing duplicate roles to be created. (#339)
* Fixing and error causing duplicate roles to be created.

* Fixing python3

* Fixing python2 and python3
2016-05-31 15:44:54 -07:00
be5dff8472 Adding a visualization for authorities. (#338)
* Adding a visualization for authorities.

* Fixing some lint.

* Fixing some lint.
2016-05-30 21:52:34 -07:00
76037e8b3a Fixing certificate names. (#337) 2016-05-27 12:00:10 -07:00
11f4bd503b Fixes (#332)
* Ensuring domains are returned correctly.

* Ensuring certificates receive owner role
2016-05-24 17:10:19 -07:00
6688b279e7 Fixing some bad renaming. (#331) 2016-05-24 10:43:40 -07:00
1ca38015bc Fixes (#329)
* Modifying the way roles are assigned.

* Adding migration scripts.

* Adding endpoints field for future use.

* Fixing dropdowns.
2016-05-23 18:38:04 -07:00
656269ff17 Closes #147 (#328)
* Closes #147

* Fixing tests

* Ensuring we can validate max dates.
2016-05-23 11:28:25 -07:00
bd727b825d Making roles more apparent for certificates and authorities. (#327) 2016-05-20 12:48:12 -07:00
e04c1e7dc9 Fixing a few things, adding tests. (#326) 2016-05-20 09:03:34 -07:00
615df76dd5 Closes 262 (#324)
Moves the authority -> role relationship from a 1 -> many to a many -> many. This will allow one role to control and have access to many authorities.
2016-05-19 13:37:05 -07:00
112c6252d6 Adding password reset command to the cli. (#325) 2016-05-19 10:07:15 -07:00
b13370bf0d Making dropdowns look a bit better. (#322)
* Making dropdowns look a bit better.

* Pleasing Lint.
2016-05-19 09:04:50 -07:00
88aa5d3fdb Making nested notifications less verbose (#321) 2016-05-19 08:48:55 -07:00
b187d8f836 Adding a better comparison. (#320) 2016-05-16 19:03:10 -07:00
1763a1a717 254 duplication certificate name (#319) 2016-05-16 15:59:40 -07:00
62b61ed980 Fixing various issues. (#318)
* Fixing various issues.

* Fixing tests
2016-05-16 11:09:50 -07:00
c11034b9bc Fixes various issues. (#317) 2016-05-16 09:23:48 -07:00
58e8fe0bd0 Fixes various issues. (#316) 2016-05-13 14:35:38 -07:00
a0c8765588 Various bug fixes. (#314) 2016-05-12 12:38:44 -07:00
9022059dc6 Marshmallowing roles (#313) 2016-05-10 14:22:22 -07:00
7f790be1e4 Marsmallowing users (#312) 2016-05-10 14:19:24 -07:00
93791c999d Marsmallowing destinations (#311) 2016-05-10 13:43:26 -07:00
5e9f1437ad Marsmallowing sources (#310) 2016-05-10 13:16:33 -07:00
f9655213b3 Marshmallowing notifications. (#308) 2016-05-10 11:27:57 -07:00
008d608ec4 Fixing error in notifications. (#307) 2016-05-09 17:35:18 -07:00
78c8d12ad8 Cleaning up the way authorities are selected and upgrading uib dependencies. 2016-05-09 17:17:00 -07:00
df0ad4d875 Authorities marshmallow addition (#303) 2016-05-09 11:00:16 -07:00
776e0fcd11 Slack plugin for notifications (#305) 2016-05-08 09:07:16 -07:00
6ec3bad49a Closes #278 (#298)
* Closes #278
2016-05-05 15:28:17 -07:00
52f44c3ea6 Closes #278 and #199, Starting transition to marshmallow (#299)
* Closes #278  and #199, Starting transition to marshmallow
2016-05-05 12:52:08 -07:00
941d36ebfe Merge pull request #302 from kevgliss/301-p12-no-chain
Closes #301
2016-05-04 17:07:42 -07:00
db8243b4b4 Closes #301 2016-05-04 16:56:05 -07:00
f919b7360e Merge pull request #294 from kevgliss/regex
Regex
2016-04-25 17:20:52 -07:00
8e1b7c0036 Removing validation because regex is hard 2016-04-25 16:13:33 -07:00
9b0e0fa9c2 removing validtion from openssl 2016-04-25 16:11:37 -07:00
565d7afa92 Merge pull request #293 from kevgliss/devdocs
Fixes #291
2016-04-25 12:30:54 -07:00
c914ba946f Merge pull request #292 from kevgliss/docs
Fixes #285 Renames sync_sources function to sync to align documentation.
2016-04-25 12:16:47 -07:00
6f9280f64a Adding gulp path 2016-04-25 12:16:33 -07:00
8fe460e401 Fixes #291 2016-04-25 11:34:05 -07:00
b9fe359d23 Fixes #285 Renames sync_sources function to sync to align documentation. 2016-04-25 11:21:25 -07:00
2c6d494c32 Merge pull request #290 from kevgliss/289-java-export-intermediates
Fixes #289 and #275
2016-04-21 16:46:11 -07:00
dbd1279226 Fixes #289 and #275 2016-04-21 16:22:19 -07:00
b463fcf61b Merge pull request #280 from kevgliss/SAN-hotfix
Fixes an issue where custom OIDs would clear out san extensions
2016-04-11 12:04:24 -07:00
82b4f5125d Fixes an issue where custom OIDs would clear out san extensions 2016-04-11 11:17:18 -07:00
3f89d6d009 Merge pull request #271 from kevgliss/195
Closes #195
2016-04-08 12:01:10 -07:00
676f843c92 Merge pull request #276 from kevgliss/san-hotfix
Fixes an issue where custom OIDs would clear out san extensions
2016-04-07 10:30:12 -07:00
c2387dc120 Fixes an issue where custom OIDs would clear out san extensions 2016-04-07 10:29:08 -07:00
9a8e1534c0 Merge pull request #274 from kevgliss/metric_fix
Fixing an issue were metrics would not be sent
2016-04-05 10:50:46 -07:00
dbc4964e94 Fixing an issue were metrics would not be sent 2016-04-05 10:23:33 -07:00
00b263f345 Merge pull request #273 from kevgliss/216
Closes #216
2016-04-01 16:59:49 -07:00
62d03b0d41 Closes #216 2016-04-01 16:54:33 -07:00
b5a4b293a9 Merge pull request #270 from kevgliss/248
Closes #248
2016-04-01 14:28:52 -07:00
bfcfdb83a7 Closes #195 2016-04-01 14:27:57 -07:00
4ccbfa8164 Closes #248 2016-04-01 13:29:08 -07:00
675d10c8a6 Merge pull request #269 from kevgliss/263
Closes #263
2016-04-01 13:08:13 -07:00
2cde7336dc Closes #263 2016-04-01 13:01:56 -07:00
169490dbec Merge pull request #268 from kevgliss/252
Closes #252
2016-04-01 10:16:10 -07:00
3ceb297276 Merge pull request #267 from kevgliss/261
Closes #261
2016-04-01 10:12:10 -07:00
12633bfed6 Merge pull request #266 from kevgliss/tox
removing testing support for py33
2016-04-01 10:11:59 -07:00
5958bac2a2 Merge pull request #265 from kevgliss/257
Closes #257
2016-04-01 10:11:32 -07:00
37f2d5b8b0 Closes #252 2016-04-01 10:09:28 -07:00
47891d2953 Closes #261 2016-04-01 09:58:19 -07:00
af68571f4e removing testing support for py33 2016-04-01 09:52:19 -07:00
d0ec925ca3 Merge pull request #264 from kevgliss/246
Closes #246
2016-04-01 09:51:10 -07:00
939194158a Closes #257 2016-04-01 09:49:44 -07:00
576265e09c Closes #246 2016-04-01 09:19:36 -07:00
dfaf45344c Merge pull request #250 from lfaraone/patch-1
Remove duplicate `install` in Quickstart
2016-03-01 09:21:04 -08:00
6c378957e9 Remove duplicate install in Quickstart 2016-03-01 04:12:10 +00:00
e8f9bc80a0 Merge pull request #249 from kevgliss/master
Updating docs
2016-02-29 12:51:47 -08:00
a30b8b21e4 updating postgres login 2016-02-29 08:53:35 -08:00
12204852aa changeing the default port to 8000 2016-02-29 08:48:27 -08:00
edba980b56 Merge pull request #245 from mikegrima/issue243
Removed deprecated auth api endpoint.
2016-02-16 17:11:41 -08:00
ba666ddbfa Removed deprecated auth api endpoint. 2016-02-16 15:04:53 -08:00
35f9f59c57 Merge pull request #242 from kevgliss/version_bump
version bump
2016-02-05 13:13:01 -08:00
ac1f493338 version bump 2016-02-05 13:12:21 -08:00
221 changed files with 10940 additions and 4820 deletions

2
.coveragerc Normal file
View File

@ -0,0 +1,2 @@
[report]
include = lemur/*.py

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/.cache
.coverage .coverage
.tox .tox
.DS_Store .DS_Store

View File

@ -8,7 +8,7 @@
"eqeqeq": true, "eqeqeq": true,
"immed": true, "immed": true,
"indent": 2, "indent": 2,
"latedef": true, "latedef": false,
"newcap": false, "newcap": false,
"noarg": true, "noarg": true,
"quotmark": "single", "quotmark": "single",
@ -22,6 +22,8 @@
"angular": false, "angular": false,
"moment": false, "moment": false,
"toaster": false, "toaster": false,
"d3": false,
"self": false,
"_": false "_": false
} }
} }

5
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,5 @@
- repo: git://github.com/pre-commit/pre-commit-hooks
sha: 18d7035de5388cc7775be57f529c154bf541aab9
hooks:
- id: trailing-whitespace
- id: flake8

View File

@ -1,6 +1,9 @@
sudo: false
language: python language: python
sudo: required
dist: trusty
node_js:
- "4.2"
addons: addons:
postgresql: "9.4" postgresql: "9.4"
@ -9,10 +12,8 @@ matrix:
include: include:
- python: "2.7" - python: "2.7"
env: TOXENV=py27 env: TOXENV=py27
- python: "3.3" - python: "3.5"
env: TOXENV=py33 env: TOXENV=py35
- python: "3.4"
env: TOXENV=py34
cache: cache:
directories: directories:
@ -22,15 +23,26 @@ cache:
env: env:
global: global:
- PIP_DOWNLOAD_CACHE=".pip_download_cache" - PIP_DOWNLOAD_CACHE=".pip_download_cache"
# do not load /etc/boto.cfg with Python 3 incompatible plugin
# https://github.com/travis-ci/travis-ci/issues/5246#issuecomment-166460882
- BOTO_CONFIG=/doesnotexist
before_script: before_script:
- psql -c "create database lemur;" -U postgres - psql -c "create database lemur;" -U postgres
- psql -c "create user lemur with password 'lemur;'" -U postgres - psql -c "create user lemur with password 'lemur;'" -U postgres
- npm config set registry https://registry.npmjs.org
- npm install -g bower - npm install -g bower
- pip install --upgrade setuptools
install:
- pip install coveralls
script: script:
- make test - make test
after_success:
- coveralls
notifications: notifications:
email: email:
kglisson@netflix.com kglisson@netflix.com

View File

@ -1,21 +1,103 @@
Changelog Changelog
========= =========
0.5 - `master`
~~~~~~~~~~~~~~
.. note:: This version is not yet released and is under active development
0.4 - ``
~~~~~~~~
There have been quite a few issues closed in this release. Some notables:
* Closed `#284 <https://github.com/Netflix/lemur/issues/284>`_ - Created new models for `Endpoints` created associated
AWS ELB endpoint tracking code. This was the major stated goal of this milestone and should serve as the basis for
future enhancements of Lemur's certificate 'deployment' capabilities.
* Closed `#334 <https://github.com/Netflix/lemur/issues/334>`_ - Lemur not has the ability
to restrict certificate expiration dates to weekdays.
Several fixes/tweaks to Lemurs python3 support (thanks chadhendrie!)
This will most likely be the last release to support python2.7 moving Lemur to target python3 exclusively. Please comment
on issue #340 if this negatively affects your usage of Lemur.
Upgrading
---------
See the full list of issues closed in `0.4 <https://github.com/Netflix/lemur/milestone/3>`_.
.. note:: This release will need a slight migration change. Please follow the `documentation <https://lemur.readthedocs.io/en/latest/administration.html#upgrading-lemur>`_ to upgrade Lemur.
0.3.0 - `2016-06-06`
~~~~~~~~~~~~~~~~~~~~
This is quite a large upgrade, it is highly advised you backup your database before attempting to upgrade as this release
requires the migration of database structure as well as data.
Upgrading
---------
Please follow the `documentation <https://lemur.readthedocs.io/en/latest/administration.html#upgrading-lemur>`_ to upgrade Lemur.
Source Plugin Owners
--------------------
The dictionary returned from a source plugin has changed keys from `public_certificate` to `body` and `intermediate_certificate` to chain.
Issuer Plugin Owners
--------------------
This release may break your plugins, the keys in `issuer_options` have been changed from `camelCase` to `under_score`.
This change was made to break a undue reliance on downstream options maintains a more pythonic naming convention. Renaming
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.
* Closed `#63 <https://github.com/Netflix/lemur/issues/63>`_ - Validates all endpoints with Marshmallow schemas, this allows for
stricter input validation and better error messages when validation fails.
* Closed `#146 <https://github.com/Netflix/lemur/issues/146>`_ - Moved authority type to first pane of authority creation wizard.
* Closed `#147 <https://github.com/Netflix/lemur/issues/147>`_ - Added and refactored the relationship between authorities and their
root certificates. Displays the certificates (and chains) next the the authority in question.
* Closed `#199 <https://github.com/Netflix/lemur/issues/199>`_ - Ensures that the dates submitted to Lemur during authority and
certificate creation are actually dates.
* Closed `#230 <https://github.com/Netflix/lemur/issues/230>`_ - Migrated authority dropdown to a ui-select based dropdown, this
should be easier to determine what authorities are available and when an authority has actually been selected.
* Closed `#254 <https://github.com/Netflix/lemur/issues/254>`_ - Forces certificate names to be generally unique. If a certificate name
(generated or otherwise) is found to be a duplicate we increment by appending a counter.
* Closed `#254 <https://github.com/Netflix/lemur/issues/275>`_ - Switched to using Fernet generated passphrases for exported items.
These are more sounds that pseudo random passphrases generated before and have the nice property of being in base64.
* Closed `#278 <https://github.com/Netflix/lemur/issues/278>`_ - Added ability to specify a custom name to certificate creation, previously
this was only available in the certificate import wizard.
* Closed `#281 <https://github.com/Netflix/lemur/issues/281>`_ - Fixed an issue where notifications could not be removed from a certificate
via the UI.
* Closed `#289 <https://github.com/Netflix/lemur/issues/289>`_ - Fixed and issue where intermediates were not being properly exported.
* Closed `#315 <https://github.com/Netflix/lemur/issues/315>`_ - Made how roles are associated with certificates and authorities much more
explict, including adding the ability to add roles directly to certificates and authorities on creation.
0.2.2 - 2016-02-05 0.2.2 - 2016-02-05
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~
* Closed [#234](https://github.com/Netflix/lemur/issues/234) - Allows export plugins to define whether they need * Closed `#234 <https://github.com/Netflix/lemur/issues/234>`_ - Allows export plugins to define whether they need
private key material (default is True) private key material (default is True)
* Closed [#231](https://github.com/Netflix/lemur/issues/231) - Authorities were not respecting 'owning' roles and their * Closed `#231 <https://github.com/Netflix/lemur/issues/231>`_ - Authorities were not respecting 'owning' roles and their
users users
* Closed [#228](https://github.com/Netflix/lemur/issues/228) - Fixed documentation with correct filter values * Closed `#228 <https://github.com/Netflix/lemur/issues/228>`_ - Fixed documentation with correct filter values
* Closed [#226](https://github.com/Netflix/lemur/issues/226) - Fixes issue were `import_certificate` was requiring * Closed `#226 <https://github.com/Netflix/lemur/issues/226>`_ - Fixes issue were `import_certificate` was requiring
replacement certificates to be specified replacement certificates to be specified
* Closed [#224](https://github.com/Netflix/lemur/issues/224) - Fixed an issue where NPM might not be globally available (thanks AlexClineBB!) * Closed `#224 <https://github.com/Netflix/lemur/issues/224>`_ - Fixed an issue where NPM might not be globally available (thanks AlexClineBB!)
* Closed [#221](https://github.com/Netflix/lemur/issues/234) - Fixes several reported issues where older migration scripts were * Closed `#221 <https://github.com/Netflix/lemur/issues/234>`_ - Fixes several reported issues where older migration scripts were
missing tables, this change removes pre 0.2 migration scripts missing tables, this change removes pre 0.2 migration scripts
* Closed [#218](https://github.com/Netflix/lemur/issues/234) - Fixed an issue where export passphrases would not validate * Closed `#218 <https://github.com/Netflix/lemur/issues/234>`_ - Fixed an issue where export passphrases would not validate
0.2.1 - 2015-12-14 0.2.1 - 2015-12-14
@ -30,7 +112,7 @@ Changelog
0.2.0 - 2015-12-02 0.2.0 - 2015-12-02
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~
* Closed #120 - Error messages not displaying long enough * Closed #120 - Error messages not displaying long enough
* Closed #121 - Certificate create form should not be valid until a Certificate Authority object is available * Closed #121 - Certificate create form should not be valid until a Certificate Authority object is available
@ -46,7 +128,7 @@ Changelog
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 a 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.

View File

@ -1,9 +1,16 @@
NPM_ROOT = ./node_modules NPM_ROOT = ./node_modules
STATIC_DIR = src/lemur/static/app STATIC_DIR = src/lemur/static/app
USER := $(shell whoami)
develop: update-submodules setup-git develop: update-submodules setup-git
@echo "--> Installing dependencies" @echo "--> Installing dependencies"
ifeq ($(USER), root)
@echo "WARNING: It looks like you are installing Lemur as root. This is not generally advised."
npm install --unsafe-perm
else
npm install npm install
endif
pip install "setuptools>=0.9.8" pip install "setuptools>=0.9.8"
# order matters here, base package must install first # order matters here, base package must install first
pip install -e . pip install -e .
@ -41,7 +48,7 @@ test: develop lint test-python
testloop: develop testloop: develop
pip install pytest-xdist pip install pytest-xdist
py.test tests -f coverage run --source lemur -m py.test
test-cli: test-cli:
@echo "--> Testing CLI" @echo "--> Testing CLI"
@ -60,7 +67,7 @@ test-js:
test-python: test-python:
@echo "--> Running Python tests" @echo "--> Running Python tests"
py.test lemur/tests || exit 1 coverage run --source lemur -m py.test
@echo "" @echo ""
lint: lint-python lint-js lint: lint-python lint-js
@ -82,4 +89,4 @@ coverage: develop
publish: publish:
python setup.py sdist bdist_wheel upload python setup.py sdist bdist_wheel upload
.PHONY: develop dev-postgres dev-docs setup-git build clean update-submodules test testloop test-cli test-js test-python lint lint-python lint-js coverage publish .PHONY: develop dev-postgres dev-docs setup-git build clean update-submodules test testloop test-cli test-js test-python lint lint-python lint-js coverage publish

View File

@ -5,24 +5,15 @@ 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://img.shields.io/pypi/v/lemur.svg
:target: https://pypi.python.org/pypi/lemur/
:alt: Latest Version
.. image:: https://readthedocs.org/projects/lemur/badge/?version=latest .. image:: https://readthedocs.org/projects/lemur/badge/?version=latest
:target: https://lemur.readthedocs.org :target: https://lemur.readthedocs.org
:alt: Latest Docs :alt: Latest Docs
.. image:: https://img.shields.io/badge/NetflixOSS-active-brightgreen.svg
.. image:: https://travis-ci.org/Netflix/lemur.svg .. image:: https://travis-ci.org/Netflix/lemur.svg
:target: https://travis-ci.org/Netflix/lemur :target: https://travis-ci.org/Netflix/lemur
.. image:: https://requires.io/github/Netflix/lemur/requirements.svg?branch=master
:target: https://requires.io/github/Netflix/lemur/requirements/?branch=master
:alt: Requirements Status
.. image:: https://badge.waffle.io/Netflix/lemur.png?label=ready&title=Ready
:target: https://waffle.io/Netflix/lemur
:alt: 'Stories in Ready'
Lemur manages TLS certificate creation. While not able to issue certificates itself, Lemur acts as a broker between CAs Lemur manages TLS certificate creation. While not able to issue certificates itself, Lemur acts as a broker between CAs
and environments providing a central portal for developers to issue TLS certificates with 'sane' defaults. and environments providing a central portal for developers to issue TLS certificates with 'sane' defaults.

View File

@ -6,43 +6,46 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"angular": "1.3", "jquery": "~2.2.0",
"json3": "~3.3",
"es5-shim": "~4.0",
"jquery": "~2.1",
"angular-resource": "1.2.15",
"angular-cookies": "1.2.15",
"angular-sanitize": "1.2.15",
"angular-route": "1.2.15",
"angular-strap": "~2.0.2",
"restangular": "~1.4.0",
"ng-table": "~0.5.4",
"ngAnimate": "*",
"moment": "~2.6.0",
"angular-animate": "~1.4.0",
"angular-loading-bar": "~0.6.0",
"fontawesome": "~4.2.0",
"angular-wizard": "~0.4.0", "angular-wizard": "~0.4.0",
"bootswatch": "3.3.1+2", "angular": "1.4.9",
"json3": "~3.3",
"es5-shim": "~4.5.0",
"bootstrap": "~3.3.6",
"angular-bootstrap": "~1.1.1",
"angular-animate": "~1.4.9",
"restangular": "~1.5.1",
"ng-table": "~0.8.3",
"moment": "~2.11.1",
"angular-loading-bar": "~0.8.0",
"angular-moment": "~0.10.3",
"moment-range": "~2.1.0",
"angular-spinkit": "~0.3.3", "angular-spinkit": "~0.3.3",
"angular-bootstrap": "~0.12.0", "angular-clipboard": "~1.3.0",
"angular-ui-switch": "~0.1.0", "angularjs-toaster": "~1.0.0",
"angular-chart.js": "~0.7.1", "angular-chart.js": "~0.8.8",
"satellizer": "~0.9.4", "ngletteravatar": "~4.0.0",
"angularjs-toaster": "~0.4.14", "bootswatch": "~3.3.6",
"ngletteravatar": "~3.0.1", "fontawesome": "~4.5.0",
"satellizer": "~0.13.4",
"angular-ui-router": "~0.2.15", "angular-ui-router": "~0.2.15",
"angular-clipboard": "~1.1.1", "font-awesome": "~4.5.0",
"angular-file-saver": "~1.0.1" "lodash": "~4.0.1",
}, "underscore": "~1.8.3",
"devDependencies": { "angular-smart-table": "~2.1.6",
"angular-mocks": "~1.3", "angular-strap": ">= 2.2.2",
"angular-scenario": "~1.3", "angular-underscore": "^0.5.0",
"ngletteravatar": "~3.0.1" "angular-translate": "^2.9.0",
"angular-ui-switch": "~0.1.0",
"angular-sanitize": "^1.5.0",
"angular-file-saver": "~1.0.1",
"angular-ui-select": "~0.17.1",
"d3": "^3.5.17"
}, },
"resolutions": { "resolutions": {
"bootstrap": "~3.3.1", "moment": ">=2.8.0 <2.11.0",
"angular": "1.3" "lodash": ">=1.3.0 <2.5.0",
"angular": "1.4.9"
}, },
"ignore": [ "ignore": [
"**/.*", "**/.*",

View File

@ -51,7 +51,7 @@ Basic Configuration
CORS = False CORS = False
.. data:: SQLACHEMY_DATABASE_URI .. data:: SQLALCHEMY_DATABASE_URI
:noindex: :noindex:
If you have ever used sqlalchemy before this is the standard connection string used. Lemur uses a postgres database and the connection string would look something like: If you have ever used sqlalchemy before this is the standard connection string used. Lemur uses a postgres database and the connection string would look something like:
@ -61,6 +61,11 @@ Basic Configuration
SQLALCHEMY_DATABASE_URI = 'postgresql://<user>:<password>@<hostname>:5432/lemur' SQLALCHEMY_DATABASE_URI = 'postgresql://<user>:<password>@<hostname>:5432/lemur'
.. data:: LEMUR_ALLOW_WEEKEND_EXPIRATION
:noindex:
Specifies whether to allow certificates created by Lemur to expire on weekends. Default is True.
.. data:: LEMUR_RESTRICTED_DOMAINS .. data:: LEMUR_RESTRICTED_DOMAINS
:noindex: :noindex:
@ -143,7 +148,7 @@ and are used when Lemur creates the CSR for your certificates.
LEMUR_DEFAULT_ORGANIZATION = "Netflix" LEMUR_DEFAULT_ORGANIZATION = "Netflix"
.. data:: LEMUR_DEFAULT_ORGANIZATION_UNIT .. data:: LEMUR_DEFAULT_ORGANIZATIONAL_UNIT
:noindex: :noindex:
:: ::
@ -151,6 +156,14 @@ and are used when Lemur creates the CSR for your certificates.
LEMUR_DEFAULT_ORGANIZATIONAL_UNIT = "Operations" LEMUR_DEFAULT_ORGANIZATIONAL_UNIT = "Operations"
.. data:: LEMUR_DEFAULT_ISSUER_PLUGIN
:noindex:
::
LEMUR_DEFAULT_ISSUER_PLUGIN = "verisign-issuer"
Notification Options Notification Options
-------------------- --------------------
@ -174,7 +187,7 @@ Lemur supports sending certification expiration notifications through SES and SM
Specifies which service will be delivering notification emails. Valid values are `SMTP` or `SES` Specifies which service will be delivering notification emails. Valid values are `SMTP` or `SES`
.. note:: .. note::
If using SMP as your provider you will need to define additional configuration options as specified by Flask-Mail. If using SMTP as your provider you will need to define additional configuration options as specified by Flask-Mail.
See: `Flask-Mail <https://pythonhosted.org/Flask-Mail>`_ See: `Flask-Mail <https://pythonhosted.org/Flask-Mail>`_
If you are using SES the email specified by the `LEMUR_MAIL` configuration will need to be verified by AWS before If you are using SES the email specified by the `LEMUR_MAIL` configuration will need to be verified by AWS before
@ -268,12 +281,26 @@ For more information about how to use social logins, see: `Satellizer <https://g
PING_CLIENT_ID = "client-id" PING_CLIENT_ID = "client-id"
.. data:: PING_REDIRECT_URI
:noindex:
::
PING_REDIRECT_URI = "https://<yourlemurserver>/api/1/auth/ping"
.. data:: PING_AUTH_ENDPOINT
:noindex:
::
PING_AUTH_ENDPOINT = "https://<yourpingserver>/oauth2/authorize"
.. data:: GOOGLE_CLIENT_ID .. data:: GOOGLE_CLIENT_ID
:noindex: :noindex:
:: ::
GOOGLE_CLIENT_ID = "client-id" GOOGLE_CLIENT_ID = "client-id"
.. data:: GOOGLE_SECRET .. data:: GOOGLE_SECRET
:noindex: :noindex:
@ -334,6 +361,69 @@ for those plugins.
This is the root to be used for your CA chain This is the root to be used for your CA chain
Digicert Issuer Plugin
~~~~~~~~~~~~~~~~~~~~~~
The following configuration properties are required to use the Digicert issuer plugin.
.. data:: DIGICERT_URL
:noindex:
This is the url for the Digicert API
.. data:: DIGICERT_API_KEY
:noindex:
This is the Digicert API key
.. data:: DIGICERT_ORG_ID
:noindex:
This is the Digicert organization ID tied to your API key
.. data:: DIGICERT_INTERMEDIATE
:noindex:
This is the intermediate to be used for your CA chain
.. data:: DIGICERT_ROOT
:noindex:
This is the root to be used for your CA chain
.. data:: DIGICERT_DEFAULT_VALIDITY
:noindex:
This is the default validity (in years), if no end date is specified. (Default: 1)
CFSSL Issuer Plugin
^^^^^^^^^^^^^^^^^^^
The following configuration properties are required to use the the CFSSL issuer plugin.
.. data:: CFSSL_URL
:noindex:
This is the URL for the CFSSL API
.. data:: CFSSL_ROOT
:noindex:
This is the root to be used for your CA chain
.. data:: CFSSL_INTERMEDIATE
:noindex:
This is the intermediate to be used for your CA chain
AWS Source/Destination Plugin AWS Source/Destination Plugin
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -588,24 +678,33 @@ All commands default to `~/.lemur/lemur.conf.py` if a configuration is not speci
Traverses every certificate that Lemur is aware of and attempts to understand its validity. Traverses every certificate that Lemur is aware of and attempts to understand its validity.
It utilizes both OCSP and CRL. If Lemur is unable to come to a conclusion about a certificates It utilizes both OCSP and CRL. If Lemur is unable to come to a conclusion about a certificates
validity its status is marked 'unknown' validity its status is marked 'unknown'.
.. data:: sync .. data:: sync
Sync attempts to discover certificates in the environment that were not created by Lemur. If you wish to only sync Sync attempts to discover certificates in the environment that were not created by Lemur. If you wish to only sync
a few sources you can pass a comma delimited list of sources to sync a few sources you can pass a comma delimited list of sources to sync.
:: ::
lemur sync source1,source2 lemur sync -s source1,source2
Additionally you can also list the available sources that Lemur can sync Additionally you can also list the available sources that Lemur can sync.
:: ::
lemur sync -list lemur sync
.. data:: notify
Will traverse all current notifications and see if any of them need to be triggered.
::
lemur notify
Sub-commands Sub-commands
@ -673,15 +772,161 @@ Plugins
There are several interfaces currently available to extend Lemur. These are a work in There are several interfaces currently available to extend Lemur. These are a work in
progress and the API is not frozen. progress and the API is not frozen.
Bundled Plugins Lemur includes several plugins by default. Including extensive support for AWS, VeriSign/Symantec.
---------------
Lemur includes several plugins by default. Including extensive support for AWS, VeriSign/Symantec and CloudCA services. Verisign/Symantec
-----------------
3rd Party Extensions :Authors:
-------------------- Kevin Glisson <kglisson@netflix.com>
:Type:
Issuer
:Description:
Basic support for the VICE 2.0 API
Cryptography
------------
:Authors:
Kevin Glisson <kglisson@netflix.com>,
Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
:Type:
Issuer
:Description:
Toy certificate authority that creates self-signed certificate authorities.
Allows for the creation of arbitrary authorities and end-entity certificates.
This is *not* recommended for production use.
Acme
----
:Authors:
Kevin Glisson <kglisson@netflix.com>,
Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
:Type:
Issuer
:Description:
Adds support for the ACME protocol (including LetsEncrypt) with domain validation being handled Route53.
Atlas
-----
:Authors:
Kevin Glisson <kglisson@netflix.com>
:Type:
Metric
:Description:
Adds basic support for the `Atlas <https://github.com/Netflix/atlas/wiki>`_ telemetry system.
Email
-----
:Authors:
Kevin Glisson <kglisson@netflix.com>
:Type:
Notification
:Description:
Adds support for basic email notifications via SES.
Slack
-----
:Authors:
Harm Weites <harm@weites.com>
:Type:
Notification
:Description:
Adds support for slack notifications.
AWS
----
:Authors:
Kevin Glisson <kglisson@netflix.com>
:Type:
Source
:Description:
Uses AWS IAM as a source of certificates to manage. Supports a multi-account deployment.
AWS
----
:Authors:
Kevin Glisson <kglisson@netflix.com>
:Type:
Destination
:Description:
Uses AWS IAM as a destination for Lemur generated certificates. Support a multi-account deployment.
Kubernetes
----------
:Authors:
Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
:Type:
Destination
:Description:
Allows Lemur to upload generated certificates to the Kubernetes certificate store.
Java
----
:Authors:
Kevin Glisson <kglisson@netflix.com>
:Type:
Export
:Description:
Generates java compatible .jks keystores and truststores from Lemur managed certificates.
Openssl
-------
:Authors:
Kevin Glisson <kglisson@netflix.com>
:Type:
Export
:Description:
Leverages Openssl to support additional export formats (pkcs12)
CFSSL
-----
:Authors:
Charles Hendrie <chad.hendrie@thomsonreuters.com>
:Type:
Issuer
:Description:
Basic support for generating certificates from the private certificate authority CFSSL
3rd Party Plugins
=================
The following plugins are available and maintained by members of the Lemur community:
Digicert
--------
:Authors:
Chris Dorros
:Type:
Issuer
:Description:
Adds support for basic Digicert
:Links:
https://github.com/opendns/lemur-digicert
The following extensions are available and maintained by members of the Lemur community:
Have an extension that should be listed here? Submit a `pull request <https://github.com/netflix/lemur>`_ and we'll Have an extension that should be listed here? Submit a `pull request <https://github.com/netflix/lemur>`_ and we'll
get it added. get it added.
@ -715,4 +960,3 @@ These permissions are applied to the user upon login and refreshed on every requ
.. seealso:: .. seealso::
`Flask-Principal <https://pythonhosted.org/Flask-Principal>`_ `Flask-Principal <https://pythonhosted.org/Flask-Principal>`_

View File

@ -144,6 +144,17 @@ If you've made changes and need to compile them by hand for any reason, you can
The minified and processed files should be committed alongside the unprocessed changes. The minified and processed files should be committed alongside the unprocessed changes.
It's also important to note that Lemur's frontend and API are not tied together. The API does not serve any of the static assets, we rely on nginx or some other file server to server all of the static assets.
During development that means we need an additional server to serve those static files for the GUI.
This is accomplished with a Gulp task:
::
./node_modules/.bin/gulp serve
The gulp task compiles all the JS/CSS/HTML files and opens the Lemur welcome page in your default browsers. Additionally any changes to made to the JS/CSS/HTML with be reloaded in your browsers.
Developing with Flask Developing with Flask
---------------------- ----------------------
@ -194,7 +205,7 @@ REST API
======== ========
Lemur's front end is entirely API driven. Any action that you can accomplish via the UI can also be accomplished by the Lemur's front end is entirely API driven. Any action that you can accomplish via the UI can also be accomplished by the
UI. The following is documents and provides examples on how to make requests to the Lemur API. API. The following is documents and provides examples on how to make requests to the Lemur API.
Authentication Authentication
-------------- --------------

View File

@ -0,0 +1,20 @@
lemur_cfssl Package
===================
:mod:`lemur_cfssl` Package
--------------------------
.. automodule:: lemur.plugins.lemur_cfssl
:noindex:
:members:
:undoc-members:
:show-inheritance:
:mod:`plugin` Module
--------------------
.. automodule:: lemur.plugins.lemur_cfssl.plugin
:noindex:
:members:
:undoc-members:
:show-inheritance:

View File

@ -27,5 +27,6 @@ Subpackages
lemur.plugins.base lemur.plugins.base
lemur.plugins.bases lemur.plugins.bases
lemur.plugins.lemur_aws lemur.plugins.lemur_aws
lemur.plugins.lemur_cfssl
lemur.plugins.lemur_email lemur.plugins.lemur_email
lemur.plugins.lemur_verisign lemur.plugins.lemur_verisign

View File

@ -137,6 +137,12 @@ Destination
Destination plugins allow you to propagate certificates managed by Lemur to additional third parties. This provides flexibility when Destination plugins allow you to propagate certificates managed by Lemur to additional third parties. This provides flexibility when
different orchestration systems have their own way of manage certificates or there is an existing system you wish to integrate with Lemur. different orchestration systems have their own way of manage certificates or there is an existing system you wish to integrate with Lemur.
By default destination plugins have a private key requirement. If your plugin does not require a certificates private key mark `requires_key = False`
in the plugins base class like so::
class MyDestinationPlugin(DestinationPlugin):
requires_key = False
The DestinationPlugin requires only one function to be implemented:: The DestinationPlugin requires only one function to be implemented::
def upload(self, cert, private_key, cert_chain, options, **kwargs): def upload(self, cert, private_key, cert_chain, options, **kwargs):
@ -211,8 +217,8 @@ certificate Lemur does not know about and adding the certificate to it's invento
The `SourcePlugin` object has one default option of `pollRate`. This controls the number of seconds which to get new certificates. The `SourcePlugin` object has one default option of `pollRate`. This controls the number of seconds which to get new certificates.
.. warning:: .. warning::
Lemur currently has a very basic polling system of running a cron job every 15min to see which source plugins need to be run. A lock file is generated to guarantee that Lemur currently has a very basic polling system of running a cron job every 15min to see which source plugins need to be run. A lock file is generated to guarantee that
only one sync is running at a time. It also means that the minimum resolution of a source plugin poll rate is effectively 15min. You can always specify a faster cron only one sync is running at a time. It also means that the minimum resolution of a source plugin poll rate is effectively 15min. You can always specify a faster cron
job if you need a higher resolution sync job. job if you need a higher resolution sync job.
@ -223,8 +229,8 @@ The `SourcePlugin` object requires implementation of one function::
# request.get("some source of certificates") # request.get("some source of certificates")
.. Note:: .. note::
Often times to facilitate code re-use it makes sense put source and destination plugins into one package. Often times to facilitate code re-use it makes sense put source and destination plugins into one package.
Export Export
@ -244,9 +250,8 @@ The `ExportPlugin` object requires the implementation of one function::
# return "extension", passphrase, raw # return "extension", passphrase, raw
.. Note:: .. note::
Support of various formats sometimes relies on external tools system calls. Always be mindful of sanitizing any input to Support of various formats sometimes relies on external tools system calls. Always be mindful of sanitizing any input to these calls.
these calls.
Testing Testing
@ -278,11 +283,7 @@ The ``conftest.py`` file is our main entry-point for py.test. We need to configu
.. code-block:: python .. code-block:: python
from __future__ import absolute_import from lemur.tests.conftest import * # noqa
pytest_plugins = [
'lemur.utils.pytest'
]
Test Cases Test Cases
@ -292,14 +293,18 @@ You can now inherit from Lemur's core test classes. These are Django-based and e
.. code-block:: python .. code-block:: python
# test_myextension.py import pytest
from __future__ import absolute_import from lemur.tests.vectors import INTERNAL_CERTIFICATE_A_STR, INTERNAL_PRIVATE_KEY_A_STR
from lemur.testutils import TestCase def test_export_keystore(app):
from lemur.plugins.base import plugins
p = plugins.get('java-keystore-jks')
options = [{'name': 'passphrase', 'value': 'test1234'}]
with pytest.raises(Exception):
p.export(INTERNAL_CERTIFICATE_A_STR, "", "", options)
class MyExtensionTest(TestCase): raw = p.export(INTERNAL_CERTIFICATE_A_STR, "", INTERNAL_PRIVATE_KEY_A_STR, options)
def test_simple(self): assert raw != b""
assert 1 != 2
Running Tests Running Tests
@ -311,13 +316,14 @@ Running tests follows the py.test standard. As long as your test files and metho
$ py.test -v $ py.test -v
============================== test session starts ============================== ============================== test session starts ==============================
platform darwin -- Python 2.7.9 -- py-1.4.26 -- pytest-2.6.4/python2.7 platform darwin -- Python 2.7.10, pytest-2.8.5, py-1.4.30, pluggy-0.3.1
plugins: django cachedir: .cache
collected 1 items plugins: flask-0.10.0
collected 346 items
tests/test_myextension.py::MyExtensionTest::test_simple PASSED lemur/plugins/lemur_acme/tests/test_acme.py::test_get_certificates PASSED
=========================== 1 passed in 0.35 seconds ============================ =========================== 1 passed in 0.35 seconds ============================
.. SeeAlso:: Lemur bundles several plugins that use the same interfaces mentioned above. View the source: # TODO .. SeeAlso:: Lemur bundles several plugins that use the same interfaces mentioned above.

View File

@ -110,7 +110,7 @@ You can make some adjustments to get a better user experience::
error_log /var/log/nginx/log/lemur.error.log; error_log /var/log/nginx/log/lemur.error.log;
location /api { location /api {
proxy_pass http://127.0.0.1:5000; proxy_pass http://127.0.0.1:8000;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_redirect off; proxy_redirect off;
proxy_buffering off; proxy_buffering off;
@ -176,7 +176,7 @@ sensitive nature of Lemur and what it controls makes this essential. This is a s
resolver <IP DNS resolver>; resolver <IP DNS resolver>;
location /api { location /api {
proxy_pass http://127.0.0.1:5000; proxy_pass http://127.0.0.1:8000;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_redirect off; proxy_redirect off;
proxy_buffering off; proxy_buffering off;
@ -295,3 +295,25 @@ Then you can manage the process by running::
It will start a shell from which you can start/stop/restart the service. It will start a shell from which you can start/stop/restart the service.
You can read all errors that might occur from /tmp/lemur.log. You can read all errors that might occur from /tmp/lemur.log.
Periodic Tasks
==============
Lemur contains a few tasks that are run and scheduled basis, currently the recommend way to run these tasks is to create
a cron job that runs the commands.
There are currently three commands that could/should be run on a periodic basis:
- `notify`
- `check_revoked`
- `sync`
How often you run these commands is largely up to the user. `notify` and `check_revoked` are typically run at least once a day.
`sync` is typically run every 15 minutes.
Example cron entries::
0 22 * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur notify
*/15 * * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur sync -s all
0 22 * * * lemuruser export LEMUR_CONF=/Users/me/.lemur/lemur.conf.py; /www/lemur/bin/lemur check_revoked

View File

@ -27,7 +27,7 @@ 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 install nodejs-legacy python-pip python-dev libpq-dev build-essential libssl-dev libffi-dev nginx git supervisor npm postgresql $ sudo apt-get install nodejs-legacy python-pip python-dev libpq-dev build-essential libssl-dev libffi-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).
@ -118,7 +118,7 @@ First, set a password for the postgres user. For this guide, we will use ``lemu
.. code-block:: bash .. code-block:: bash
$ sudo -u postgres psql postgres $ sudo -u postgres -i
# \password postgres # \password postgres
Enter new password: lemur Enter new password: lemur
Enter it again: lemur Enter it again: lemur
@ -133,17 +133,8 @@ Next, we will create our new database:
.. _InitializingLemur: .. _InitializingLemur:
Set a password for lemur user inside Postgres: .. note::
For this guide we assume you will use the `postgres` user to connect to your database, when deploying to a VM or container this is often all you will need. If you have a shared database it is recommend you give Lemur it's own user.
.. code-block:: bash
$ sudo -u postgres psql postgres
\password lemur
Enter new password: lemur
Enter it again: lemur
Again, enter CTRL-D to exit the Postgres shell.
Initializing Lemur Initializing Lemur
------------------ ------------------
@ -161,6 +152,7 @@ Additional notifications can be created through the UI or API. See :ref:`Creati
$ cd /www/lemur/lemur $ cd /www/lemur/lemur
$ lemur init $ lemur init
.. note:: It is recommended that once the ``lemur`` user is created that you create individual users for every day access. There is currently no way for a user to self enroll for Lemur access, they must have an administrator create an account for them or be enrolled automatically through SSO. This can be done through the CLI or UI. See :ref:`Creating Users <CreatingUsers>` and :ref:`Command Line Interface <CommandLineInterface>` for details. .. note:: It is recommended that once the ``lemur`` user is created that you create individual users for every day access. There is currently no way for a user to self enroll for Lemur access, they must have an administrator create an account for them or be enrolled automatically through SSO. This can be done through the CLI or UI. See :ref:`Creating Users <CreatingUsers>` and :ref:`Command Line Interface <CommandLineInterface>` for details.
@ -178,7 +170,7 @@ You'll use the builtin ``HttpProxyModule`` within Nginx to handle proxying. Edi
:: ::
location /api { location /api {
proxy_pass http://127.0.0.1:5000; proxy_pass http://127.0.0.1:8000;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_redirect off; proxy_redirect off;
proxy_buffering off; proxy_buffering off;
@ -251,13 +243,14 @@ See :ref:`Using Supervisor <UsingSupervisor>` for more details on using Supervis
Syncing Syncing
------- -------
Lemur uses periodic sync tasks to make sure it is up-to-date with its environment. As always, things can change outside of Lemur, but we do our best to reconcile those changes, for example, using Cron: Lemur uses periodic sync tasks to make sure it is up-to-date with its environment. Things change outside of Lemur we do our best to reconcile those changes. The recommended method is to use CRON:
.. code-block:: bash .. code-block:: bash
$ crontab -e $ crontab -e
* 3 * * * lemur sync --all */15 * * * * lemur sync -s all
* 3 * * * lemur check_revoked 0 22 * * * lemur check_revoked
0 22 * * * lemur notify
Additional Utilities Additional Utilities

View File

@ -1,29 +1,54 @@
Jinja2>=2.3 alabaster==0.7.8
Pygments>=1.2 alembic==0.8.6
Sphinx>=1.3 aniso8601==1.1.0
docutils>=0.7
markupsafe
sphinxcontrib-httpdomain
Flask==0.10.1
Flask-RESTful==0.3.3
Flask-SQLAlchemy==2.1
Flask-Script==2.0.5
Flask-Migrate==1.7.0
Flask-Bcrypt==0.7.1
Flask-Principal==0.4.0
Flask-Mail==0.9.1
SQLAlchemy-Utils==0.31.4
BeautifulSoup4
requests==2.9.1
psycopg2==2.6.1
arrow==0.7.0 arrow==0.7.0
boto==2.38.0 # we might make this optional Babel==2.3.4
six==1.10.0 bcrypt==2.0.0
gunicorn==19.4.4 beautifulsoup4==4.4.1
pycrypto==2.6.1 blinker==1.4
cryptography==1.1.2 boto==2.38.0
pyopenssl==0.15.1 cffi==1.7.0
pyjwt==1.4.0 cryptography==1.3.2
xmltodict==0.9.2 docutils==0.12
lockfile==0.12.2 enum34==1.1.6
Flask==0.10.1
Flask-Bcrypt==0.7.1
Flask-Mail==0.9.1
Flask-Migrate==1.7.0
Flask-Principal==0.4.0
Flask-RESTful==0.3.3
Flask-Script==2.0.5
Flask-SQLAlchemy==2.1
future==0.15.2 future==0.15.2
gunicorn==19.4.1
idna==2.1
imagesize==0.7.1
inflection==0.3.1
ipaddress==1.0.16
itsdangerous==0.24
Jinja2==2.8
lockfile==0.12.2
Mako==1.0.4
MarkupSafe==0.23
marshmallow==2.4.0
marshmallow-sqlalchemy==0.8.0
psycopg2==2.6.1
pyasn1==0.1.9
pycparser==2.14
pycrypto==2.6.1
Pygments==2.1.3
PyJWT==1.4.0
pyOpenSSL==0.15.1
python-dateutil==2.5.3
python-editor==1.0.1
pytz==2016.4
requests==2.9.1
six==1.10.0
snowballstemmer==1.2.1
Sphinx==1.4.4
sphinx-rtd-theme==0.1.9
sphinxcontrib-httpdomain==1.5.0
SQLAlchemy==1.0.13
SQLAlchemy-Utils==0.31.4
Werkzeug==0.11.10
xmltodict==0.9.2

View File

@ -79,8 +79,9 @@ gulp.task('dev:styles', function () {
'bower_components/angular-loading-bar/src/loading-bar.css', 'bower_components/angular-loading-bar/src/loading-bar.css',
'bower_components/angular-ui-switch/angular-ui-switch.css', 'bower_components/angular-ui-switch/angular-ui-switch.css',
'bower_components/angular-wizard/dist/angular-wizard.css', 'bower_components/angular-wizard/dist/angular-wizard.css',
'bower_components/ng-table/ng-table.css', 'bower_components/ng-table/dist/ng-table.css',
'bower_components/angularjs-toaster/toaster.css', 'bower_components/angularjs-toaster/toaster.css',
'bower_components/angular-ui-select/dist/select.css',
'lemur/static/app/styles/lemur.css' 'lemur/static/app/styles/lemur.css'
]; ];

View File

@ -17,13 +17,12 @@ if 'VIRTUAL_ENV' in os.environ:
def py_lint(files_modified): def py_lint(files_modified):
from flake8.main import DEFAULT_CONFIG
from flake8.engine import get_style_guide from flake8.engine import get_style_guide
# remove non-py files and files which no longer exist # remove non-py files and files which no longer exist
files_modified = filter(lambda x: x.endswith('.py'), files_modified) files_modified = filter(lambda x: x.endswith('.py'), files_modified)
flake8_style = get_style_guide(parse_argv=True, config_file=DEFAULT_CONFIG) flake8_style = get_style_guide(parse_argv=True)
report = flake8_style.check_files(files_modified) report = flake8_style.check_files(files_modified)
return report.total_errors != 0 return report.total_errors != 0

View File

@ -9,7 +9,7 @@ __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.2.2" __version__ = "0.4.0"
__author__ = "The Lemur developers" __author__ = "The Lemur developers"
__email__ = "security@netflix.com" __email__ = "security@netflix.com"

View File

@ -11,6 +11,7 @@
from __future__ import absolute_import, division, print_function from __future__ import absolute_import, division, print_function
from lemur import factory from lemur import factory
from lemur.extensions import metrics
from lemur.users.views import mod as users_bp from lemur.users.views import mod as users_bp
from lemur.roles.views import mod as roles_bp from lemur.roles.views import mod as roles_bp
@ -23,6 +24,7 @@ from lemur.defaults.views import mod as defaults_bp
from lemur.plugins.views import mod as plugins_bp from lemur.plugins.views import mod as plugins_bp
from lemur.notifications.views import mod as notifications_bp 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.__about__ import ( from lemur.__about__ import (
__author__, __copyright__, __email__, __license__, __summary__, __title__, __author__, __copyright__, __email__, __license__, __summary__, __title__,
@ -46,7 +48,8 @@ LEMUR_BLUEPRINTS = (
defaults_bp, defaults_bp,
plugins_bp, plugins_bp,
notifications_bp, notifications_bp,
sources_bp sources_bp,
endpoints_bp
) )
@ -62,7 +65,8 @@ def configure_hook(app):
:param app: :param app:
:return: :return:
""" """
from flask.ext.principal import PermissionDenied from flask import jsonify
from werkzeug.exceptions import default_exceptions
from lemur.decorators import crossdomain from lemur.decorators import crossdomain
if app.config.get('CORS'): if app.config.get('CORS'):
@app.after_request @app.after_request
@ -70,8 +74,13 @@ def configure_hook(app):
def after(response): def after(response):
return response return response
@app.errorhandler(PermissionDenied) def make_json_handler(code):
def handle_invalid_usage(error): def json_handler(error):
response = {'message': 'You are not allow to access this resource'} metrics.send('{}_status_code'.format(code), 'counter', 1)
response.status_code = 403 response = jsonify(message=str(error))
return response response.status_code = code
return response
return json_handler
for code, value in default_exceptions.items():
app.error_handler_spec[None][code] = make_json_handler(code)

View File

@ -18,32 +18,32 @@ admin_permission = Permission(RoleNeed('admin'))
CertificateCreator = namedtuple('certificate', ['method', 'value']) CertificateCreator = namedtuple('certificate', ['method', 'value'])
CertificateCreatorNeed = partial(CertificateCreator, 'key') CertificateCreatorNeed = partial(CertificateCreator, 'key')
CertificateOwner = namedtuple('certificate', ['method', 'value'])
CertificateOwnerNeed = partial(CertificateOwner, 'role')
class SensitiveDomainPermission(Permission): class SensitiveDomainPermission(Permission):
def __init__(self): def __init__(self):
super(SensitiveDomainPermission, self).__init__(RoleNeed('admin')) super(SensitiveDomainPermission, self).__init__(RoleNeed('admin'))
class ViewKeyPermission(Permission): class CertificatePermission(Permission):
def __init__(self, certificate_id, owner): def __init__(self, certificate_id, owner, roles):
c_need = CertificateCreatorNeed(certificate_id) needs = [RoleNeed('admin'), CertificateCreatorNeed(certificate_id), RoleNeed(owner)]
super(ViewKeyPermission, self).__init__(c_need, RoleNeed(owner), RoleNeed('admin')) for r in roles:
needs.append(CertificateOwnerNeed(str(r)))
super(CertificatePermission, self).__init__(*needs)
class UpdateCertificatePermission(Permission): RoleMember = namedtuple('role', ['method', 'value'])
def __init__(self, certificate_id, owner): RoleMemberNeed = partial(RoleMember, 'member')
c_need = CertificateCreatorNeed(certificate_id)
super(UpdateCertificatePermission, self).__init__(c_need, RoleNeed(owner), RoleNeed('admin'))
RoleUser = namedtuple('role', ['method', 'value']) class RoleMemberPermission(Permission):
ViewRoleCredentialsNeed = partial(RoleUser, 'roleView')
class ViewRoleCredentialsPermission(Permission):
def __init__(self, role_id): def __init__(self, role_id):
need = ViewRoleCredentialsNeed(role_id) needs = [RoleNeed('admin'), RoleMemberNeed(role_id)]
super(ViewRoleCredentialsPermission, self).__init__(need, RoleNeed('admin')) super(RoleMemberPermission, self).__init__(*needs)
AuthorityCreator = namedtuple('authority', ['method', 'value']) AuthorityCreator = namedtuple('authority', ['method', 'value'])

View File

@ -8,11 +8,9 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
from __future__ import unicode_literals import sys
from builtins import bytes
import jwt import jwt
import json import json
import base64
import binascii import binascii
from functools import wraps from functools import wraps
@ -31,20 +29,7 @@ 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.auth.permissions import CertificateCreatorNeed, \ from lemur.auth.permissions import CertificateCreatorNeed, \
AuthorityCreatorNeed, ViewRoleCredentialsNeed AuthorityCreatorNeed, RoleMemberNeed
def base64url_decode(data):
rem = len(data) % 4
if rem > 0:
data += '=' * (4 - rem)
return base64.urlsafe_b64decode(bytes(data.encode('latin-1')))
def base64url_encode(data):
return base64.urlsafe_b64encode(data).replace('=', '')
def get_rsa_public_key(n, e): def get_rsa_public_key(n, e):
@ -55,8 +40,13 @@ def get_rsa_public_key(n, e):
:param e: :param e:
:return: a RSA Public Key in PEM format :return: a RSA Public Key in PEM format
""" """
n = int(binascii.hexlify(base64url_decode(n)), 16) if sys.version_info >= (3, 0):
e = int(binascii.hexlify(base64url_decode(e)), 16) n = int(binascii.hexlify(jwt.utils.base64url_decode(bytes(n, 'utf-8'))), 16)
e = int(binascii.hexlify(jwt.utils.base64url_decode(bytes(e, 'utf-8'))), 16)
else:
n = int(binascii.hexlify(jwt.utils.base64url_decode(str(n))), 16)
e = int(binascii.hexlify(jwt.utils.base64url_decode(str(e))), 16)
pub = RSAPublicNumbers(e, n).public_key(default_backend()) pub = RSAPublicNumbers(e, n).public_key(default_backend())
return pub.public_bytes( return pub.public_bytes(
encoding=serialization.Encoding.PEM, encoding=serialization.Encoding.PEM,
@ -75,8 +65,8 @@ 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, 'sub': user.id,
'iat': datetime.now(), 'iat': datetime.utcnow(),
'exp': datetime.now() + expiration_delta 'exp': datetime.utcnow() + expiration_delta
} }
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')
@ -138,13 +128,13 @@ def fetch_token_header(token):
raise jwt.DecodeError('Not enough segments') raise jwt.DecodeError('Not enough segments')
try: try:
return json.loads(base64url_decode(header_segment)) if sys.version_info >= (3, 0):
return json.loads(jwt.utils.base64url_decode(header_segment).decode('utf-8'))
else:
return json.loads(jwt.utils.base64url_decode(header_segment))
except TypeError as e: except TypeError as e:
current_app.logger.exception(e) current_app.logger.exception(e)
raise jwt.DecodeError('Invalid header padding') raise jwt.DecodeError('Invalid header padding')
except binascii.Error as e:
current_app.logger.exception(e)
raise jwt.DecodeError('Invalid header padding')
@identity_loaded.connect @identity_loaded.connect
@ -165,8 +155,8 @@ def on_identity_loaded(sender, identity):
# identity with the roles that the user provides # identity with the roles that the user provides
if hasattr(user, 'roles'): if hasattr(user, 'roles'):
for role in user.roles: for role in user.roles:
identity.provides.add(ViewRoleCredentialsNeed(role.id))
identity.provides.add(RoleNeed(role.name)) identity.provides.add(RoleNeed(role.name))
identity.provides.add(RoleMemberNeed(role.id))
# apply ownership for authorities # apply ownership for authorities
if hasattr(user, 'authorities'): if hasattr(user, 'authorities'):

View File

@ -5,15 +5,17 @@
: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>
""" """
import sys
import jwt import jwt
import base64 import base64
import requests import requests
from flask import g, Blueprint, current_app from flask import Blueprint, current_app
from flask.ext.restful import reqparse, Resource, Api from flask.ext.restful import reqparse, Resource, Api
from flask.ext.principal import Identity, identity_changed from flask.ext.principal import Identity, identity_changed
from lemur.extensions import metrics
from lemur.common.utils import get_psuedo_random_string 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
@ -96,13 +98,13 @@ class Login(Resource):
# 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(),
identity=Identity(user.id)) identity=Identity(user.id))
metrics.send('successful_login', 'counter', 1)
return dict(token=create_token(user)) return dict(token=create_token(user))
metrics.send('invalid_login', 'counter', 1)
return dict(message='The supplied credentials are invalid'), 401 return dict(message='The supplied credentials are invalid'), 401
def get(self):
return {'username': g.current_user.username, 'roles': [r.name for r in g.current_user.roles]}
class Ping(Resource): class Ping(Resource):
""" """
@ -139,8 +141,14 @@ class Ping(Resource):
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 # the secret and cliendId will be given to you when you signup for the provider
basic = base64.b64encode('{0}:{1}'.format(args['clientId'], current_app.config.get("PING_SECRET"))) token = '{0}:{1}'.format(args['clientId'], current_app.config.get("PING_SECRET"))
headers = {'Authorization': 'Basic {0}'.format(basic)}
if sys.version_info >= (3, 0):
basic = base64.b64encode(bytes(token, 'utf-8'))
headers = {'authorization': 'basic {0}'.format(basic.decode('utf-8'))}
else:
basic = base64.b64encode(token, 'utf-8')
headers = {'authorization': 'basic {0}'.format(basic)}
# exchange authorization code for access token. # exchange authorization code for access token.
@ -164,7 +172,10 @@ class Ping(Resource):
# validate your token based on the key it was signed with # validate your token based on the key it was signed with
try: try:
jwt.decode(id_token, secret, algorithms=[algo], audience=args['clientId']) 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: except jwt.DecodeError:
return dict(message='Token is invalid'), 403 return dict(message='Token is invalid'), 403
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
@ -179,6 +190,7 @@ class Ping(Resource):
profile = r.json() profile = r.json()
user = user_service.get_by_email(profile['email']) user = user_service.get_by_email(profile['email'])
metrics.send('successful_login', 'counter', 1)
# update their google 'roles' # update their google 'roles'
roles = [] roles = []
@ -189,10 +201,13 @@ class Ping(Resource):
role = role_service.create(group, description='This is a google group based role created by Lemur') role = role_service.create(group, description='This is a google group based role created by Lemur')
roles.append(role) roles.append(role)
# if we get an sso user create them an account role = role_service.get_by_name(profile['email'])
# we still pick a random password in case sso is down if not role:
if not user: 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) # every user is an operator (tied to a default role)
if current_app.config.get('LEMUR_DEFAULT_ROLE'): if current_app.config.get('LEMUR_DEFAULT_ROLE'):
v = role_service.get_by_name(current_app.config.get('LEMUR_DEFAULT_ROLE')) v = role_service.get_by_name(current_app.config.get('LEMUR_DEFAULT_ROLE'))
@ -266,6 +281,7 @@ class Google(Resource):
user = user_service.get_by_email(profile['email']) user = user_service.get_by_email(profile['email'])
if user: if user:
metrics.send('successful_login', 'counter', 1)
return dict(token=create_token(user)) return dict(token=create_token(user))
@ -273,7 +289,7 @@ class Providers(Resource):
def get(self): def get(self):
active_providers = [] active_providers = []
for provider in current_app.config.get("ACTIVE_PROVIDERS"): for provider in current_app.config.get("ACTIVE_PROVIDERS", []):
provider = provider.lower() provider = provider.lower()
if provider == "google": if provider == "google":

View File

@ -6,53 +6,43 @@
: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 cryptography import x509
from cryptography.hazmat.backends import default_backend
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy import Column, Integer, String, Text, func, ForeignKey, DateTime, PassiveDefault, Boolean from sqlalchemy import Column, Integer, String, Text, func, ForeignKey, DateTime, PassiveDefault, Boolean
from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.dialects.postgresql import JSON
from lemur.database import db from lemur.database import db
from lemur.certificates.models import get_cn, get_not_after, get_not_before from lemur.plugins.base import plugins
from lemur.models import roles_authorities
class Authority(db.Model): class Authority(db.Model):
__tablename__ = 'authorities' __tablename__ = 'authorities'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
owner = Column(String(128)) owner = Column(String(128), nullable=False)
name = Column(String(128), unique=True) name = Column(String(128), unique=True)
body = Column(Text()) body = Column(Text())
chain = Column(Text()) chain = Column(Text())
bits = Column(Integer())
cn = Column(String(128))
not_before = Column(DateTime)
not_after = Column(DateTime)
active = Column(Boolean, default=True) active = Column(Boolean, default=True)
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
plugin_name = Column(String(64)) plugin_name = Column(String(64))
description = Column(Text) description = Column(Text)
options = Column(JSON) options = Column(JSON)
roles = relationship('Role', backref=db.backref('authority'), lazy='dynamic') date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
roles = relationship('Role', secondary=roles_authorities, passive_deletes=True, backref=db.backref('authority'), lazy='dynamic')
user_id = Column(Integer, ForeignKey('users.id')) user_id = Column(Integer, ForeignKey('users.id'))
certificates = relationship("Certificate", backref='authority') authority_certificate = relationship("Certificate", backref='root_authority', uselist=False, foreign_keys='Certificate.root_authority_id')
certificates = relationship("Certificate", backref='authority', foreign_keys='Certificate.authority_id')
def __init__(self, name, owner, plugin_name, body, roles=None, chain=None, description=None): def __init__(self, **kwargs):
self.name = name self.owner = kwargs['owner']
self.body = body self.roles = kwargs.get('roles', [])
self.chain = chain self.name = kwargs.get('name')
self.owner = owner self.description = kwargs.get('description')
self.plugin_name = plugin_name self.authority_certificate = kwargs['authority_certificate']
cert = x509.load_pem_x509_certificate(str(body), default_backend()) self.plugin_name = kwargs['plugin']['slug']
self.cn = get_cn(cert)
self.not_before = get_not_before(cert)
self.not_after = get_not_after(cert)
self.roles = roles
self.description = description
def as_dict(self): @property
return {c.name: getattr(self, c.name) for c in self.__table__.columns} def plugin(self):
return plugins.get(self.plugin_name)
def serialize(self): def __repr__(self):
blob = self.as_dict() return "Authority(name={name})".format(name=self.name)
return blob

View File

@ -0,0 +1,119 @@
"""
.. module: lemur.authorities.schemas
:platform: unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from flask import current_app
from marshmallow import fields, validates_schema, pre_load
from marshmallow import validate
from marshmallow.exceptions import ValidationError
from lemur.schemas import PluginInputSchema, PluginOutputSchema, ExtensionSchema, AssociatedAuthoritySchema, AssociatedRoleSchema
from lemur.users.schemas import UserNestedOutputSchema
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
from lemur.common import validators, missing
from lemur.common.fields import ArrowDateTime
class AuthorityInputSchema(LemurInputSchema):
name = fields.String(required=True)
owner = fields.Email(required=True)
description = fields.String()
common_name = fields.String(required=True, validate=validators.sensitive_domain)
validity_start = ArrowDateTime()
validity_end = ArrowDateTime()
validity_years = fields.Integer()
# certificate body fields
organizational_unit = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_ORGANIZATIONAL_UNIT'))
organization = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_ORGANIZATION'))
location = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_LOCATION'))
country = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_COUNTRY'))
state = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_STATE'))
plugin = fields.Nested(PluginInputSchema)
# signing related options
type = fields.String(validate=validate.OneOf(['root', 'subca']), missing='root')
parent = fields.Nested(AssociatedAuthoritySchema)
signing_algorithm = fields.String(validate=validate.OneOf(['sha256WithRSA', 'sha1WithRSA']), missing='sha256WithRSA')
key_type = fields.String(validate=validate.OneOf(['RSA2048', 'RSA4096']), missing='RSA2048')
key_name = fields.String()
sensitivity = fields.String(validate=validate.OneOf(['medium', 'high']), missing='medium')
serial_number = fields.Integer()
first_serial = fields.Integer(missing=1)
extensions = fields.Nested(ExtensionSchema)
roles = fields.Nested(AssociatedRoleSchema(many=True))
@validates_schema
def validate_dates(self, data):
validators.dates(data)
@validates_schema
def validate_subca(self, data):
if data['type'] == 'subca':
if not data.get('parent'):
raise ValidationError("If generating a subca parent 'authority' must be specified.")
@pre_load
def ensure_dates(self, data):
return missing.convert_validity_years(data)
class AuthorityUpdateSchema(LemurInputSchema):
owner = fields.Email(required=True)
description = fields.String()
active = fields.Boolean()
roles = fields.Nested(AssociatedRoleSchema(many=True))
class RootAuthorityCertificateOutputSchema(LemurOutputSchema):
__envelope__ = False
id = fields.Integer()
active = fields.Boolean()
bits = fields.Integer()
body = fields.String()
chain = fields.String()
description = fields.String()
name = fields.String()
cn = fields.String()
not_after = fields.DateTime()
not_before = fields.DateTime()
owner = fields.Email()
status = fields.Boolean()
user = fields.Nested(UserNestedOutputSchema)
class AuthorityOutputSchema(LemurOutputSchema):
id = fields.Integer()
description = fields.String()
name = fields.String()
owner = fields.Email()
plugin = fields.Nested(PluginOutputSchema)
active = fields.Boolean()
options = fields.Dict()
roles = fields.List(fields.Nested(AssociatedRoleSchema))
authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema)
class AuthorityNestedOutputSchema(LemurOutputSchema):
__envelope__ = False
id = fields.Integer()
description = fields.String()
name = fields.String()
owner = fields.Email()
plugin = fields.Nested(PluginOutputSchema)
active = fields.Boolean()
authority_update_schema = AuthorityUpdateSchema()
authority_input_schema = AuthorityInputSchema()
authority_output_schema = AuthorityOutputSchema()
authorities_output_schema = AuthorityOutputSchema(many=True)

View File

@ -9,17 +9,13 @@
""" """
from flask import g from flask import g
from flask import current_app
from lemur import database from lemur import database
from lemur.extensions import metrics
from lemur.authorities.models import Authority from lemur.authorities.models import Authority
from lemur.roles import service as role_service from lemur.roles import service as role_service
from lemur.notifications import service as notification_service
from lemur.roles.models import Role from lemur.certificates.service import upload
from lemur.certificates.models import Certificate
from lemur.plugins.base import plugins
def update(authority_id, description=None, owner=None, active=None, roles=None): def update(authority_id, description=None, owner=None, active=None, roles=None):
@ -31,8 +27,9 @@ def update(authority_id, description=None, owner=None, active=None, roles=None):
:return: :return:
""" """
authority = get(authority_id) authority = get(authority_id)
if roles: if roles:
authority = database.update_list(authority, 'roles', Role, roles) authority.roles = roles
if active: if active:
authority.active = active authority.active = active
@ -42,45 +39,39 @@ def update(authority_id, description=None, owner=None, active=None, roles=None):
return database.update(authority) return database.update(authority)
def create(kwargs): def mint(**kwargs):
""" """
Create a new authority. Creates the authority based on the plugin provided.
"""
issuer = kwargs['plugin']['plugin_object']
values = issuer.create_authority(kwargs)
# support older plugins
if len(values) == 3:
body, chain, roles = values
private_key = None
elif len(values) == 4:
body, private_key, chain, roles = values
roles = create_authority_roles(roles, kwargs['owner'], kwargs['plugin']['plugin_object'].title)
return body, private_key, chain, roles
def create_authority_roles(roles, owner, plugin_title):
"""
Creates all of the necessary authority roles.
:param roles:
:return: :return:
""" """
issuer = plugins.get(kwargs.get('pluginName'))
kwargs['creator'] = g.current_user.email
cert_body, intermediate, issuer_roles = issuer.create_authority(kwargs)
cert = Certificate(cert_body, chain=intermediate)
cert.owner = kwargs['ownerEmail']
if kwargs['caType'] == 'subca':
cert.description = "This is the ROOT certificate for the {0} sub certificate authority the parent \
authority is {1}.".format(kwargs.get('caName'), kwargs.get('caParent'))
else:
cert.description = "This is the ROOT certificate for the {0} certificate authority.".format(
kwargs.get('caName')
)
cert.user = g.current_user
cert.notifications = notification_service.create_default_expiration_notifications(
'DEFAULT_SECURITY',
current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL')
)
# we create and attach any roles that the issuer gives us
role_objs = [] role_objs = []
for r in issuer_roles: for r in roles:
role = role_service.get_by_name(r['name'])
role = role_service.create( if not role:
r['name'], role = role_service.create(
password=r['password'], r['name'],
description="{0} auto generated role".format(kwargs.get('pluginName')), password=r['password'],
username=r['username']) description="Auto generated role for {0}".format(plugin_title),
username=r['username'])
# the user creating the authority should be able to administer it # the user creating the authority should be able to administer it
if role.username == 'admin': if role.username == 'admin':
@ -88,25 +79,44 @@ def create(kwargs):
role_objs.append(role) role_objs.append(role)
authority = Authority( # create an role for the owner and assign it
kwargs.get('caName'), owner_role = role_service.get_by_name(owner)
kwargs['ownerEmail'], if not owner_role:
kwargs['pluginName'], owner_role = role_service.create(
cert_body, owner,
description=kwargs['caDescription'], description="Auto generated role based on owner: {0}".format(owner)
chain=intermediate, )
roles=role_objs
)
database.update(cert) role_objs.append(owner_role)
return role_objs
def create(**kwargs):
"""
Creates a new authority.
"""
kwargs['creator'] = g.user.email
body, private_key, chain, roles = mint(**kwargs)
g.user.roles = list(set(list(g.user.roles) + roles))
kwargs['body'] = body
kwargs['private_key'] = private_key
kwargs['chain'] = chain
if kwargs.get('roles'):
kwargs['roles'] += roles
else:
kwargs['roles'] = roles
cert = upload(**kwargs)
kwargs['authority_certificate'] = cert
authority = Authority(**kwargs)
authority = database.create(authority) authority = database.create(authority)
g.user.authorities.append(authority)
# the owning dl or role should have this authority associated with it metrics.send('authority_created', 'counter', 1, metric_tags=dict(owner=authority.owner))
owner_role = role_service.get_by_name(kwargs['ownerEmail'])
owner_role.authority = authority
g.current_user.authorities.append(authority)
return authority return authority
@ -149,14 +159,9 @@ def get_authority_role(ca_name):
:param ca_name: :param ca_name:
""" """
if g.current_user.is_admin: if g.current_user.is_admin:
authority = get_by_name(ca_name) return role_service.get_by_name("{0}_admin".format(ca_name))
# TODO we should pick admin ca roles for admin
return authority.roles[0]
else: else:
for role in g.current_user.roles: return role_service.get_by_name("{0}_operator".format(ca_name))
if role.authority:
if role.authority.name == ca_name:
return role
def render(args): def render(args):
@ -166,10 +171,6 @@ def render(args):
:return: :return:
""" """
query = database.session_query(Authority) query = database.session_query(Authority)
sort_by = args.pop('sort_by')
sort_dir = args.pop('sort_dir')
page = args.pop('page')
count = args.pop('count')
filt = args.pop('filter') filt = args.pop('filter')
if filt: if filt:
@ -182,14 +183,12 @@ def render(args):
# we make sure that a user can only use an authority they either own are are a member of - admins can see all # we make sure that a user can only use an authority they either own are are a member of - admins can see all
if not g.current_user.is_admin: if not g.current_user.is_admin:
authority_ids = [] authority_ids = []
for authority in g.current_user.authorities:
authority_ids.append(authority.id)
for role in g.current_user.roles: for role in g.current_user.roles:
if role.authority: for authority in role.authorities:
authority_ids.append(role.authority.id) authority_ids.append(authority.id)
query = query.filter(Authority.id.in_(authority_ids)) query = query.filter(Authority.id.in_(authority_ids))
query = database.find_all(query, Authority, args) return database.sort_and_page(query, Authority, args)
if sort_by and sort_dir:
query = database.sort(query, Authority, sort_by, sort_dir)
return database.paginate(query, page, count)

View File

@ -5,32 +5,19 @@
: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 Blueprint, g from flask import Blueprint
from flask.ext.restful import reqparse, fields, Api from flask.ext.restful import reqparse, Api
from lemur.authorities import service from lemur.common.utils import paginated_parser
from lemur.roles import service as role_service from lemur.common.schema import validate_schema
from lemur.certificates import service as certificate_service
from lemur.auth.service import AuthenticatedResource from lemur.auth.service import AuthenticatedResource
from lemur.auth.permissions import AuthorityPermission from lemur.auth.permissions import AuthorityPermission
from lemur.common.utils import paginated_parser, marshal_items from lemur.certificates import service as certificate_service
from lemur.authorities import service
from lemur.authorities.schemas import authority_input_schema, authority_output_schema, authorities_output_schema, authority_update_schema
FIELDS = {
'name': fields.String,
'owner': fields.String,
'description': fields.String,
'options': fields.Raw,
'pluginName': fields.String,
'body': fields.String,
'chain': fields.String,
'active': fields.Boolean,
'notBefore': fields.DateTime(dt_format='iso8601', attribute='not_before'),
'notAfter': fields.DateTime(dt_format='iso8601', attribute='not_after'),
'id': fields.Integer,
}
mod = Blueprint('authorities', __name__) mod = Blueprint('authorities', __name__)
api = Api(mod) api = Api(mod)
@ -42,7 +29,7 @@ class AuthoritiesList(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(AuthoritiesList, self).__init__() super(AuthoritiesList, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, authorities_output_schema)
def get(self): def get(self):
""" """
.. http:get:: /authorities .. http:get:: /authorities
@ -66,20 +53,44 @@ class AuthoritiesList(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"items": [ "items": [{
{ "name": "TestAuthority",
"id": 1, "roles": [{
"name": "authority1", "id": 123,
"description": "this is authority1", "name": "secure@example.com"
"pluginName": null, }, {
"chain": "-----Begin ...", "id": 564,
"body": "-----Begin ...", "name": "TestAuthority_admin"
"active": true, }, {
"notBefore": "2015-06-05T17:09:39", "id": 565,
"notAfter": "2015-06-10T17:09:39" "name": "TestAuthority_operator"
"options": null }],
} "options": null,
] "active": true,
"authorityCertificate": {
"body": "-----BEGIN CERTIFICATE-----IyMzU5MTVaMHk...",
"status": true,
"cn": "AcommonName",
"description": "This is the ROOT certificate for the TestAuthority certificate authority.",
"chain": "",
"notBefore": "2016-06-02T00:00:15+00:00",
"notAfter": "2023-06-02T23:59:15+00:00",
"owner": "secure@example.com",
"user": {
"username": "joe@example.com",
"active": true,
"email": "joe@example.com",
"id": 3
},
"active": true,
"bits": 2048,
"id": 2235,
"name": "TestAuthority"
},
"owner": "secure@example.com",
"id": 43,
"description": "This is the ROOT certificate for the TestAuthority certificate authority."
}
"total": 1 "total": 1
} }
@ -87,7 +98,7 @@ class AuthoritiesList(AuthenticatedResource):
:query sortDir: acs or desc :query sortDir: acs or desc
:query page: int default is 1 :query page: int default is 1
:query filter: key value pair. format is k;v :query filter: key value pair. format is k;v
:query limit: limit number default is 10 :query count: count number default is 10
: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
@ -98,8 +109,8 @@ class AuthoritiesList(AuthenticatedResource):
args = parser.parse_args() args = parser.parse_args()
return service.render(args) return service.render(args)
@marshal_items(FIELDS) @validate_schema(authority_input_schema, authority_output_schema)
def post(self): def post(self, data=None):
""" """
.. http:post:: /authorities .. http:post:: /authorities
@ -113,31 +124,30 @@ class AuthoritiesList(AuthenticatedResource):
Host: example.com Host: example.com
Accept: application/json, text/javascript Accept: application/json, text/javascript
{ {
"caDN": { "country": "US",
"country": "US", "state": "California",
"state": "CA", "location": "Los Gatos",
"location": "A Location", "organization": "Netflix",
"organization": "ExampleInc", "organizationalUnit": "Operations",
"organizationalUnit": "Operations", "type": "root",
"commonName": "a common name" "signingAlgorithm": "sha256WithRSA",
}, "sensitivity": "medium",
"caType": "root",
"caSigningAlgo": "sha256WithRSA",
"caSensitivity": "medium",
"keyType": "RSA2048", "keyType": "RSA2048",
"pluginName": "cloudca", "plugin": {
"validityStart": "2015-06-11T07:00:00.000Z", "slug": "cloudca-issuer",
"validityEnd": "2015-06-13T07:00:00.000Z",
"caName": "DoctestCA",
"ownerEmail": "jimbob@example.com",
"caDescription": "Example CA",
"extensions": {
"subAltNames": {
"names": []
}
}, },
} "name": "TimeTestAuthority5",
"owner": "secure@example.com",
"description": "test",
"commonName": "AcommonName",
"validityYears": "20",
"extensions": {
"subAltNames": {
"names": []
},
"custom": []
}
**Example response**: **Example response**:
@ -148,57 +158,67 @@ class AuthoritiesList(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"id": 1, "name": "TestAuthority",
"name": "authority1", "roles": [{
"description": "this is authority1", "id": 123,
"pluginName": null, "name": "secure@example.com"
"chain": "-----Begin ...", }, {
"body": "-----Begin ...", "id": 564,
"name": "TestAuthority_admin"
}, {
"id": 565,
"name": "TestAuthority_operator"
}],
"options": null,
"active": true, "active": true,
"notBefore": "2015-06-05T17:09:39", "authorityCertificate": {
"notAfter": "2015-06-10T17:09:39" "body": "-----BEGIN CERTIFICATE-----IyMzU5MTVaMHk...",
"options": null "status": true,
"cn": "AcommonName",
"description": "This is the ROOT certificate for the TestAuthority certificate authority.",
"chain": "",
"notBefore": "2016-06-02T00:00:15+00:00",
"notAfter": "2023-06-02T23:59:15+00:00",
"owner": "secure@example.com",
"user": {
"username": "joe@example.com",
"active": true,
"email": "joe@example.com",
"id": 3
},
"active": true,
"bits": 2048,
"id": 2235,
"name": "TestAuthority"
},
"owner": "secure@example.com",
"id": 43,
"description": "This is the ROOT certificate for the TestAuthority certificate authority."
} }
:arg caName: authority's name
:arg caDescription: a sensible description about what the CA with be used for :arg name: authority's name
:arg ownerEmail: the team or person who 'owns' this authority :arg description: a sensible description about what the CA with be used for
:arg owner: the team or person who 'owns' this authority
:arg validityStart: when this authority should start issuing certificates :arg validityStart: when this authority should start issuing certificates
:arg validityEnd: when this authority should stop issuing certificates :arg validityEnd: when this authority should stop issuing certificates
:arg validityYears: starting from `now` how many years into the future the authority should be valid
:arg extensions: certificate extensions :arg extensions: certificate extensions
:arg pluginName: name of the plugin to create the authority :arg plugin: name of the plugin to create the authority
:arg caType: the type of authority (root/subca) :arg type: the type of authority (root/subca)
:arg caParent: the parent authority if this is to be a subca :arg parent: the parent authority if this is to be a subca
:arg caSigningAlgo: algorithm used to sign the authority :arg signingAlgorithm: algorithm used to sign the authority
:arg keyType: key type :arg keyType: key type
:arg caSensitivity: the sensitivity of the root key, for CloudCA this determines if the root keys are stored :arg sensitivity: the sensitivity of the root key, for CloudCA this determines if the root keys are stored
in an HSM in an HSM
:arg caKeyName: name of the key to store in the HSM (CloudCA) :arg keyName: name of the key to store in the HSM (CloudCA)
:arg caSerialNumber: serial number of the authority :arg serialNumber: serial number of the authority
:arg caFirstSerial: specifies the starting serial number for certificates issued off of this authority :arg firstSerial: specifies the starting serial number for certificates issued off of this authority
: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
""" """
self.reqparse.add_argument('caName', type=str, location='json', required=True) return service.create(**data)
self.reqparse.add_argument('caDescription', type=str, location='json', required=False)
self.reqparse.add_argument('ownerEmail', type=str, location='json', required=True)
self.reqparse.add_argument('caDN', type=dict, location='json', required=False)
self.reqparse.add_argument('validityStart', type=str, location='json', required=False) # TODO validate
self.reqparse.add_argument('validityEnd', type=str, location='json', required=False) # TODO validate
self.reqparse.add_argument('extensions', type=dict, location='json', required=False)
self.reqparse.add_argument('pluginName', type=str, location='json', required=True)
self.reqparse.add_argument('caType', type=str, location='json', required=False)
self.reqparse.add_argument('caParent', type=str, location='json', required=False)
self.reqparse.add_argument('caSigningAlgo', type=str, location='json', required=False)
self.reqparse.add_argument('keyType', type=str, location='json', required=False)
self.reqparse.add_argument('caSensitivity', type=str, location='json', required=False)
self.reqparse.add_argument('caKeyName', type=str, location='json', required=False)
self.reqparse.add_argument('caSerialNumber', type=int, location='json', required=False)
self.reqparse.add_argument('caFirstSerial', type=int, location='json', required=False)
args = self.reqparse.parse_args()
return service.create(args)
class Authorities(AuthenticatedResource): class Authorities(AuthenticatedResource):
@ -206,7 +226,7 @@ class Authorities(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(Authorities, self).__init__() super(Authorities, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, authority_output_schema)
def get(self, authority_id): def get(self, authority_id):
""" """
.. http:get:: /authorities/1 .. http:get:: /authorities/1
@ -230,26 +250,36 @@ class Authorities(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"id": 1, "roles": [{
"name": "authority1", "id": 123,
"description": "this is authority1", "name": "secure@example.com"
"pluginName": null, }, {
"chain": "-----Begin ...", "id": 564,
"body": "-----Begin ...", "name": "TestAuthority_admin"
}, {
"id": 565,
"name": "TestAuthority_operator"
}],
"active": true, "active": true,
"notBefore": "2015-06-05T17:09:39", "owner": "secure@example.com",
"notAfter": "2015-06-10T17:09:39" "id": 43,
"options": null "description": "This is the ROOT certificate for the TestAuthority certificate authority."
} }
:arg description: a sensible description about what the CA with be used for
:arg owner: the team or person who 'owns' this authority
:arg active: set whether this authoritity is currently in use
:reqheader Authorization: OAuth token to authenticate
:statuscode 403: unauthenticated
:statuscode 200: no error
: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
""" """
return service.get(authority_id) return service.get(authority_id)
@marshal_items(FIELDS) @validate_schema(authority_update_schema, authority_output_schema)
def put(self, authority_id): def put(self, authority_id, data=None):
""" """
.. http:put:: /authorities/1 .. http:put:: /authorities/1
@ -264,11 +294,42 @@ class Authorities(AuthenticatedResource):
Accept: application/json, text/javascript Accept: application/json, text/javascript
{ {
"roles": [], "name": "TestAuthority5",
"active": false, "roles": [{
"owner": "bob@example.com", "id": 566,
"description": "this is authority1" "name": "TestAuthority5_admin"
} }, {
"id": 567,
"name": "TestAuthority5_operator"
}, {
"id": 123,
"name": "secure@example.com"
}],
"active": true,
"authorityCertificate": {
"body": "-----BEGIN CERTIFICATE-----",
"status": null,
"cn": "AcommonName",
"description": "This is the ROOT certificate for the TestAuthority5 certificate authority.",
"chain": "",
"notBefore": "2016-06-03T00:00:51+00:00",
"notAfter": "2036-06-03T23:59:51+00:00",
"owner": "secure@example.com",
"user": {
"username": "joe@example.com",
"active": true,
"email": "joe@example.com",
"id": 3
},
"active": true,
"bits": 2048,
"id": 2280,
"name": "TestAuthority5"
},
"owner": "secure@example.com",
"id": 44,
"description": "This is the ROOT certificate for the TestAuthority5 certificate authority."
}
**Example response**: **Example response**:
@ -279,64 +340,74 @@ class Authorities(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"id": 1, "name": "TestAuthority",
"name": "authority1", "roles": [{
"description": "this is authority1", "id": 123,
"pluginName": null, "name": "secure@example.com"
"chain": "-----begin ...", }, {
"body": "-----begin ...", "id": 564,
"active": false, "name": "TestAuthority_admin"
"notBefore": "2015-06-05t17:09:39", }, {
"notAfter": "2015-06-10t17:09:39" "id": 565,
"options": null "name": "TestAuthority_operator"
}],
"options": null,
"active": true,
"authorityCertificate": {
"body": "-----BEGIN CERTIFICATE-----IyMzU5MTVaMHk...",
"status": true,
"cn": "AcommonName",
"description": "This is the ROOT certificate for the TestAuthority certificate authority.",
"chain": "",
"notBefore": "2016-06-02T00:00:15+00:00",
"notAfter": "2023-06-02T23:59:15+00:00",
"owner": "secure@example.com",
"user": {
"username": "joe@example.com",
"active": true,
"email": "joe@example.com",
"id": 3
},
"active": true,
"bits": 2048,
"id": 2235,
"name": "TestAuthority"
},
"owner": "secure@example.com",
"id": 43,
"description": "This is the ROOT certificate for the TestAuthority certificate authority."
} }
: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
""" """
self.reqparse.add_argument('roles', type=list, default=[], location='json')
self.reqparse.add_argument('active', type=str, location='json', required=True)
self.reqparse.add_argument('owner', type=str, location='json', required=True)
self.reqparse.add_argument('description', type=str, location='json', required=True)
args = self.reqparse.parse_args()
authority = service.get(authority_id) authority = service.get(authority_id)
role = role_service.get_by_name(authority.owner)
if not authority:
return dict(message='Not Found'), 404
# all the authority role members should be allowed # all the authority role members should be allowed
roles = [x.name for x in authority.roles] roles = [x.name for x in authority.roles]
# allow "owner" roles by team DL
roles.append(role)
permission = AuthorityPermission(authority_id, roles) permission = AuthorityPermission(authority_id, roles)
# we want to make sure that we cannot add roles that we are not members of
if not g.current_user.is_admin:
role_ids = set([r['id'] for r in args['roles']])
user_role_ids = set([r.id for r in g.current_user.roles])
if not role_ids.issubset(user_role_ids):
return dict(message="You are not allowed to associate a role which you are not a member of"), 400
if permission.can(): if permission.can():
return service.update( return service.update(
authority_id, authority_id,
owner=args['owner'], owner=data['owner'],
description=args['description'], description=data['description'],
active=args['active'], active=data['active'],
roles=args['roles'] roles=data['roles']
) )
return dict(message="You are not authorized to update this authority"), 403 return dict(message="You are not authorized to update this authority."), 403
class CertificateAuthority(AuthenticatedResource): class CertificateAuthority(AuthenticatedResource):
def __init__(self): def __init__(self):
self.reqparse = reqparse.RequestParser()
super(CertificateAuthority, self).__init__() super(CertificateAuthority, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, authority_output_schema)
def get(self, certificate_id): def get(self, certificate_id):
""" """
.. http:get:: /certificates/1/authority .. http:get:: /certificates/1/authority
@ -360,16 +431,42 @@ class CertificateAuthority(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"id": 1, "name": "TestAuthority",
"name": "authority1", "roles": [{
"description": "this is authority1", "id": 123,
"pluginName": null, "name": "secure@example.com"
"chain": "-----Begin ...", }, {
"body": "-----Begin ...", "id": 564,
"name": "TestAuthority_admin"
}, {
"id": 565,
"name": "TestAuthority_operator"
}],
"options": null,
"active": true, "active": true,
"notBefore": "2015-06-05T17:09:39", "authorityCertificate": {
"notAfter": "2015-06-10T17:09:39" "body": "-----BEGIN CERTIFICATE-----IyMzU5MTVaMHk...",
"options": null "status": true,
"cn": "AcommonName",
"description": "This is the ROOT certificate for the TestAuthority certificate authority.",
"chain": "",
"notBefore": "2016-06-02T00:00:15+00:00",
"notAfter": "2023-06-02T23:59:15+00:00",
"owner": "secure@example.com",
"user": {
"username": "joe@example.com",
"active": true,
"email": "joe@example.com",
"id": 3
},
"active": true,
"bits": 2048,
"id": 2235,
"name": "TestAuthority"
},
"owner": "secure@example.com",
"id": 43,
"description": "This is the ROOT certificate for the TestAuthority certificate authority."
} }
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
@ -378,10 +475,35 @@ class CertificateAuthority(AuthenticatedResource):
""" """
cert = certificate_service.get(certificate_id) cert = certificate_service.get(certificate_id)
if not cert: if not cert:
return dict(message="Certificate not found"), 404 return dict(message="Certificate not found."), 404
return cert.authority return cert.authority
class AuthorityVisualizations(AuthenticatedResource):
def get(self, authority_id):
"""
{"name": "flare",
"children": [
{
"name": "analytics",
"children": [
{
"name": "cluster",
"children": [
{"name": "AgglomerativeCluster", "size": 3938},
{"name": "CommunityStructure", "size": 3812},
{"name": "HierarchicalCluster", "size": 6714},
{"name": "MergeEdge", "size": 743}
]
}
}
]}
"""
authority = service.get(authority_id)
return dict(name=authority.name, children=[{"name": c.name} for c in authority.certificates])
api.add_resource(AuthoritiesList, '/authorities', endpoint='authorities') api.add_resource(AuthoritiesList, '/authorities', endpoint='authorities')
api.add_resource(Authorities, '/authorities/<int:authority_id>', endpoint='authority') api.add_resource(Authorities, '/authorities/<int:authority_id>', endpoint='authority')
api.add_resource(AuthorityVisualizations, '/authorities/<int:authority_id>/visualize', endpoint='authority_visualizations')
api.add_resource(CertificateAuthority, '/certificates/<int:certificate_id>/authority', endpoint='certificateAuthority') api.add_resource(CertificateAuthority, '/certificates/<int:certificate_id>/authority', endpoint='certificateAuthority')

View File

@ -6,280 +6,147 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import datetime import datetime
import lemur.common.utils
from flask import current_app from flask import current_app
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql.expression import case
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import event, Integer, ForeignKey, String, DateTime, PassiveDefault, func, Column, Text, Boolean from sqlalchemy import event, Integer, ForeignKey, String, DateTime, PassiveDefault, func, Column, Text, Boolean
from lemur.utils import Vault
from lemur.database import db from lemur.database import db
from lemur.plugins.base import plugins
from lemur.domains.models import Domain
from lemur.constants import SAN_NAMING_TEMPLATE, DEFAULT_NAMING_TEMPLATE
from lemur.models import certificate_associations, certificate_source_associations, \ from lemur.models import certificate_associations, certificate_source_associations, \
certificate_destination_associations, certificate_notification_associations, \ certificate_destination_associations, certificate_notification_associations, \
certificate_replacement_associations certificate_replacement_associations, roles_certificates
from lemur.plugins.base import plugins
from lemur.utils import Vault
from lemur.common import defaults
from lemur.domains.models import Domain
def create_name(issuer, not_before, not_after, subject, san): def get_or_increase_name(name):
""" name = '-'.join(name.strip().split(' '))
Create a name for our certificate. A naming standard count = Certificate.query.filter(Certificate.name.ilike('{0}%'.format(name))).count()
is based on a series of templates. The name includes
useful information such as Common Name, Validation dates,
and Issuer.
:param san: if count >= 1:
:param subject: return name + '-' + str(count)
:param not_after:
:param issuer:
:param not_before:
:rtype : str
:return:
"""
if san:
t = SAN_NAMING_TEMPLATE
else:
t = DEFAULT_NAMING_TEMPLATE
temp = t.format( return name
subject=subject,
issuer=issuer,
not_before=not_before.strftime('%Y%m%d'),
not_after=not_after.strftime('%Y%m%d')
)
# NOTE we may want to give more control over naming
# aws doesn't allow special chars except '-'
disallowed_chars = ''.join(c for c in map(chr, range(256)) if not c.isalnum())
disallowed_chars = disallowed_chars.replace("-", "")
disallowed_chars = disallowed_chars.replace(".", "")
temp = temp.replace('*', "WILDCARD")
for c in disallowed_chars:
temp = temp.replace(c, "")
# white space is silly too
return temp.replace(" ", "-")
def get_signing_algorithm(cert):
return cert.signature_hash_algorithm.name
def get_cn(cert):
"""
Attempts to get a sane common name from a given certificate.
:param cert:
:return: Common name or None
"""
return cert.subject.get_attributes_for_oid(
x509.OID_COMMON_NAME
)[0].value.strip()
def get_domains(cert):
"""
Attempts to get an domains listed in a certificate.
If 'subjectAltName' extension is not available we simply
return the common name.
:param cert:
:return: List of domains
"""
domains = []
try:
ext = cert.extensions.get_extension_for_oid(x509.OID_SUBJECT_ALTERNATIVE_NAME)
entries = ext.value.get_values_for_type(x509.DNSName)
for entry in entries:
domains.append(entry)
except Exception as e:
current_app.logger.warning("Failed to get SubjectAltName: {0}".format(e))
return domains
def get_serial(cert):
"""
Fetch the serial number from the certificate.
:param cert:
:return: serial number
"""
return cert.serial
def is_san(cert):
"""
Determines if a given certificate is a SAN certificate.
SAN certificates are simply certificates that cover multiple domains.
:param cert:
:return: Bool
"""
if len(get_domains(cert)) > 1:
return True
def is_wildcard(cert):
"""
Determines if certificate is a wildcard certificate.
:param cert:
:return: Bool
"""
domains = get_domains(cert)
if len(domains) == 1 and domains[0][0:1] == "*":
return True
if cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME)[0].value[0:1] == "*":
return True
def get_bitstrength(cert):
"""
Calculates a certificates public key bit length.
:param cert:
:return: Integer
"""
return cert.public_key().key_size
def get_issuer(cert):
"""
Gets a sane issuer from a given certificate.
:param cert:
:return: Issuer
"""
delchars = ''.join(c for c in map(chr, range(256)) if not c.isalnum())
try:
issuer = str(cert.issuer.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)[0].value)
for c in delchars:
issuer = issuer.replace(c, "")
return issuer
except Exception as e:
current_app.logger.error("Unable to get issuer! {0}".format(e))
def get_not_before(cert):
"""
Gets the naive datetime of the certificates 'not_before' field.
This field denotes the first date in time which the given certificate
is valid.
:param cert:
:return: Datetime
"""
return cert.not_valid_before
def get_not_after(cert):
"""
Gets the naive datetime of the certificates 'not_after' field.
This field denotes the last date in time which the given certificate
is valid.
:param cert:
:return: Datetime
"""
return cert.not_valid_after
def get_name_from_arn(arn):
"""
Extract the certificate name from an arn.
:param arn: IAM SSL arn
:return: name of the certificate as uploaded to AWS
"""
return arn.split("/", 1)[1]
def get_account_number(arn):
"""
Extract the account number from an arn.
:param arn: IAM SSL arn
:return: account number associated with ARN
"""
return arn.split(":")[4]
class Certificate(db.Model): class Certificate(db.Model):
__tablename__ = 'certificates' __tablename__ = 'certificates'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
owner = Column(String(128)) owner = Column(String(128), nullable=False)
body = Column(Text()) name = Column(String(128), unique=True)
private_key = Column(Vault) description = Column(String(1024))
status = Column(String(128)) notify = Column(Boolean, default=True)
deleted = Column(Boolean, index=True)
name = Column(String(128)) body = Column(Text(), nullable=False)
chain = Column(Text()) chain = Column(Text())
bits = Column(Integer()) private_key = Column(Vault)
issuer = Column(String(128)) issuer = Column(String(128))
serial = Column(String(128)) serial = Column(String(128))
cn = Column(String(128)) cn = Column(String(128))
description = Column(String(1024)) deleted = Column(Boolean, index=True)
active = Column(Boolean, default=True)
san = Column(String(1024))
not_before = Column(DateTime) not_before = Column(DateTime)
not_after = Column(DateTime) not_after = Column(DateTime)
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False) date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
signing_algorithm = Column(String(128)) signing_algorithm = Column(String(128))
status = Column(String(128))
bits = Column(Integer())
san = Column(String(1024)) # TODO this should be migrated to boolean
user_id = Column(Integer, ForeignKey('users.id')) user_id = Column(Integer, ForeignKey('users.id'))
authority_id = Column(Integer, ForeignKey('authorities.id')) authority_id = Column(Integer, ForeignKey('authorities.id', ondelete="CASCADE"))
root_authority_id = Column(Integer, ForeignKey('authorities.id', ondelete="CASCADE"))
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')
sources = relationship("Source", secondary=certificate_source_associations, backref='certificate')
domains = relationship("Domain", secondary=certificate_associations, backref="certificate")
roles = relationship("Role", secondary=roles_certificates, backref="certificate")
replaces = relationship("Certificate", replaces = relationship("Certificate",
secondary=certificate_replacement_associations, secondary=certificate_replacement_associations,
primaryjoin=id == certificate_replacement_associations.c.certificate_id, # noqa primaryjoin=id == certificate_replacement_associations.c.certificate_id, # noqa
secondaryjoin=id == certificate_replacement_associations.c.replaced_certificate_id, # noqa secondaryjoin=id == certificate_replacement_associations.c.replaced_certificate_id, # noqa
backref='replaced') backref='replaced')
sources = relationship("Source", secondary=certificate_source_associations, backref='certificate')
domains = relationship("Domain", secondary=certificate_associations, backref="certificate")
def __init__(self, body, private_key=None, chain=None): endpoints = relationship("Endpoint", backref='certificate')
self.body = body
# We encrypt the private_key on creation
self.private_key = private_key
self.chain = chain
cert = x509.load_pem_x509_certificate(str(self.body), default_backend())
self.signing_algorithm = get_signing_algorithm(cert)
self.bits = get_bitstrength(cert)
self.issuer = get_issuer(cert)
self.serial = get_serial(cert)
self.cn = get_cn(cert)
self.san = is_san(cert)
self.not_before = get_not_before(cert)
self.not_after = get_not_after(cert)
self.name = create_name(self.issuer, self.not_before, self.not_after, self.cn, self.san)
for domain in get_domains(cert): def __init__(self, **kwargs):
cert = lemur.common.utils.parse_certificate(kwargs['body'])
self.issuer = defaults.issuer(cert)
self.cn = defaults.common_name(cert)
self.san = defaults.san(cert)
self.not_before = defaults.not_before(cert)
self.not_after = defaults.not_after(cert)
# when destinations are appended they require a valid name.
if kwargs.get('name'):
self.name = get_or_increase_name(kwargs['name'])
else:
self.name = get_or_increase_name(defaults.certificate_name(self.cn, self.issuer, self.not_before, self.not_after, self.san))
self.owner = kwargs['owner']
self.body = kwargs['body'].strip()
if kwargs.get('private_key'):
self.private_key = kwargs['private_key'].strip()
if kwargs.get('chain'):
self.chain = kwargs['chain'].strip()
self.notify = kwargs.get('notify', True)
self.destinations = kwargs.get('destinations', [])
self.notifications = kwargs.get('notifications', [])
self.description = kwargs.get('description')
self.roles = list(set(kwargs.get('roles', [])))
self.replaces = kwargs.get('replacements', [])
self.signing_algorithm = defaults.signing_algorithm(cert)
self.bits = defaults.bitstrength(cert)
self.serial = defaults.serial(cert)
for domain in defaults.domains(cert):
self.domains.append(Domain(name=domain)) self.domains.append(Domain(name=domain))
@property @property
def is_expired(self): def active(self):
if self.not_after < datetime.datetime.now(): return self.notify
@hybrid_property
def expired(self):
if self.not_after <= datetime.datetime.now():
return True return True
@property @expired.expression
def is_unused(self): def expired(cls):
if self.elb_listeners.count() == 0: return case(
[
(cls.now_after <= datetime.datetime.now(), True)
],
else_=False
)
@hybrid_property
def revoked(self):
if 'revoked' == self.status:
return True return True
@property @revoked.expression
def is_revoked(self): def revoked(cls):
# we might not yet know the condition of the cert return case(
if self.status: [
if 'revoked' in self.status: (cls.status == 'revoked', True)
return True ],
else_=False
)
def get_arn(self, account_number): def get_arn(self, account_number):
""" """
@ -291,6 +158,9 @@ class Certificate(db.Model):
""" """
return "arn:aws:iam::{}:server-certificate/{}".format(account_number, self.name) return "arn:aws:iam::{}:server-certificate/{}".format(account_number, self.name)
def __repr__(self):
return "Certificate(name={name})".format(name=self.name)
@event.listens_for(Certificate.destinations, 'append') @event.listens_for(Certificate.destinations, 'append')
def update_destinations(target, value, initiator): def update_destinations(target, value, initiator):
@ -303,32 +173,38 @@ def update_destinations(target, value, initiator):
:return: :return:
""" """
destination_plugin = plugins.get(value.plugin_name) destination_plugin = plugins.get(value.plugin_name)
destination_plugin.upload(target.name, target.body, target.private_key, target.chain, value.options)
try:
destination_plugin.upload(target.name, target.body, target.private_key, target.chain, value.options)
except Exception as e:
current_app.logger.exception(e)
@event.listens_for(Certificate.replaces, 'append') @event.listens_for(Certificate.replaces, 'append')
def update_replacement(target, value, initiator): def update_replacement(target, value, initiator):
""" """
When a certificate is marked as 'replaced' it is then marked as in-active When a certificate is marked as 'replaced' we should not notify.
:param target: :param target:
:param value: :param value:
:param initiator: :param initiator:
:return: :return:
""" """
value.active = False value.notify = False
@event.listens_for(Certificate, 'before_update') # @event.listens_for(Certificate, 'before_update')
def protect_active(mapper, connection, target): # def protect_active(mapper, connection, target):
""" # """
When a certificate has a replacement do not allow it to be marked as 'active' # When a certificate has a replacement do not allow it to be marked as 'active'
#
:param connection: # :param connection:
:param mapper: # :param mapper:
:param target: # :param target:
:return: # :return:
""" # """
if target.active: # if target.active:
if target.replaced: # if not target.notify:
raise Exception("Cannot mark certificate as active, certificate has been marked as replaced.") # raise Exception(
# "Cannot silence notification for a certificate Lemur has been found to be currently deployed onto endpoints"
# )

View File

@ -0,0 +1,186 @@
"""
.. module: lemur.certificates.schemas
:platform: unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from flask import current_app
from marshmallow import fields, validates_schema, post_load, pre_load
from marshmallow.exceptions import ValidationError
from lemur.schemas import AssociatedAuthoritySchema, AssociatedDestinationSchema, AssociatedCertificateSchema, \
AssociatedNotificationSchema, PluginInputSchema, ExtensionSchema, AssociatedRoleSchema, EndpointNestedOutputSchema
from lemur.authorities.schemas import AuthorityNestedOutputSchema
from lemur.destinations.schemas import DestinationNestedOutputSchema
from lemur.notifications.schemas import NotificationNestedOutputSchema
from lemur.roles.schemas import RoleNestedOutputSchema
from lemur.domains.schemas import DomainNestedOutputSchema
from lemur.users.schemas import UserNestedOutputSchema
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
from lemur.common import validators, missing
from lemur.notifications import service as notification_service
from lemur.common.fields import ArrowDateTime
class CertificateSchema(LemurInputSchema):
owner = fields.Email(required=True)
description = fields.String()
class CertificateCreationSchema(CertificateSchema):
@post_load
def default_notification(self, data):
if not data['notifications']:
notification_name = "DEFAULT_{0}".format(data['owner'].split('@')[0].upper())
data['notifications'] += notification_service.create_default_expiration_notifications(notification_name, [data['owner']])
notification_name = 'DEFAULT_SECURITY'
data['notifications'] += notification_service.create_default_expiration_notifications(notification_name, current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL'))
return data
class CertificateInputSchema(CertificateCreationSchema):
name = fields.String()
common_name = fields.String(required=True, validate=validators.sensitive_domain)
authority = fields.Nested(AssociatedAuthoritySchema, required=True)
validity_start = ArrowDateTime()
validity_end = ArrowDateTime()
validity_years = fields.Integer()
destinations = fields.Nested(AssociatedDestinationSchema, missing=[], many=True)
notifications = fields.Nested(AssociatedNotificationSchema, missing=[], many=True)
replacements = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)
roles = fields.Nested(AssociatedRoleSchema, missing=[], many=True)
csr = fields.String(validate=validators.csr)
notify = fields.Boolean(default=True)
# certificate body fields
organizational_unit = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_ORGANIZATIONAL_UNIT'))
organization = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_ORGANIZATION'))
location = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_LOCATION'))
country = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_COUNTRY'))
state = fields.String(missing=lambda: current_app.config.get('LEMUR_DEFAULT_STATE'))
extensions = fields.Nested(ExtensionSchema)
@validates_schema
def validate_dates(self, data):
validators.dates(data)
@pre_load
def ensure_dates(self, data):
return missing.convert_validity_years(data)
class CertificateEditInputSchema(CertificateSchema):
notify = fields.Boolean()
owner = fields.String()
destinations = fields.Nested(AssociatedDestinationSchema, missing=[], many=True)
notifications = fields.Nested(AssociatedNotificationSchema, missing=[], many=True)
replacements = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)
roles = fields.Nested(AssociatedRoleSchema, missing=[], many=True)
@post_load
def enforce_notifications(self, data):
"""
Ensures that when an owner changes, default notifications are added for the new owner.
Old owner notifications are retained unless explicitly removed.
:param data:
:return:
"""
if data['owner']:
notification_name = "DEFAULT_{0}".format(data['owner'].split('@')[0].upper())
data['notifications'] += notification_service.create_default_expiration_notifications(notification_name, [data['owner']])
return data
class CertificateNestedOutputSchema(LemurOutputSchema):
__envelope__ = False
id = fields.Integer()
active = fields.Boolean()
bits = fields.Integer()
body = fields.String()
chain = fields.String()
description = fields.String()
name = fields.String()
cn = fields.String()
not_after = fields.DateTime()
not_before = fields.DateTime()
owner = fields.Email()
status = fields.Boolean()
creator = fields.Nested(UserNestedOutputSchema)
issuer = fields.Nested(AuthorityNestedOutputSchema)
class CertificateCloneSchema(LemurOutputSchema):
__envelope__ = False
description = fields.String()
common_name = fields.String()
class CertificateOutputSchema(LemurOutputSchema):
id = fields.Integer()
active = fields.Boolean()
notify = fields.Boolean()
bits = fields.Integer()
body = fields.String()
chain = fields.String()
deleted = fields.Boolean(default=False)
description = fields.String()
issuer = fields.String()
name = fields.String()
cn = fields.String()
not_after = fields.DateTime()
not_before = fields.DateTime()
owner = fields.Email()
san = fields.Boolean()
serial = fields.String()
signing_algorithm = fields.String()
status = fields.Boolean()
user = fields.Nested(UserNestedOutputSchema)
domains = fields.Nested(DomainNestedOutputSchema, many=True)
destinations = fields.Nested(DestinationNestedOutputSchema, many=True)
notifications = fields.Nested(NotificationNestedOutputSchema, many=True)
replaces = fields.Nested(CertificateNestedOutputSchema, many=True)
authority = fields.Nested(AuthorityNestedOutputSchema)
roles = fields.Nested(RoleNestedOutputSchema, many=True)
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])
class CertificateUploadInputSchema(CertificateCreationSchema):
name = fields.String()
notify = fields.Boolean(missing=True)
private_key = fields.String(validate=validators.private_key)
body = fields.String(required=True, validate=validators.public_certificate)
chain = fields.String(validate=validators.public_certificate) # TODO this could be multiple certificates
destinations = fields.Nested(AssociatedDestinationSchema, missing=[], many=True)
notifications = fields.Nested(AssociatedNotificationSchema, missing=[], many=True)
replacements = fields.Nested(AssociatedCertificateSchema, missing=[], many=True)
roles = fields.Nested(AssociatedRoleSchema, missing=[], many=True)
@validates_schema
def keys(self, data):
if data.get('destinations'):
if not data.get('private_key'):
raise ValidationError('Destinations require private key.')
class CertificateExportInputSchema(LemurInputSchema):
plugin = fields.Nested(PluginInputSchema)
certificate_input_schema = CertificateInputSchema()
certificate_output_schema = CertificateOutputSchema()
certificates_output_schema = CertificateOutputSchema(many=True)
certificate_upload_input_schema = CertificateUploadInputSchema()
certificate_export_input_schema = CertificateExportInputSchema()
certificate_edit_input_schema = CertificateEditInputSchema()

View File

@ -11,6 +11,7 @@ from sqlalchemy import func, or_
from flask import g, current_app from flask import g, current_app
from lemur import database from lemur import database
from lemur.extensions import metrics
from lemur.plugins.base import plugins from lemur.plugins.base import plugins
from lemur.certificates.models import Certificate from lemur.certificates.models import Certificate
@ -20,6 +21,7 @@ from lemur.authorities.models import Authority
from lemur.domains.models import Domain from lemur.domains.models import Domain
from lemur.roles.models import Role from lemur.roles.models import Role
from lemur.roles import service as role_service
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
@ -65,16 +67,26 @@ def get_all_certs():
return Certificate.query.all() return Certificate.query.all()
def find_duplicates(cert_body): def get_by_source(source_label):
"""
Retrieves all certificates from a given source.
:param source_label:
:return:
"""
return Certificate.query.filter(Certificate.sources.any(label=source_label))
def find_duplicates(cert):
""" """
Finds certificates that already exist within Lemur. We do this by looking for Finds certificates that already exist within Lemur. We do this by looking for
certificate bodies that are the same. This is the most reliable way to determine certificate bodies that are the same. This is the most reliable way to determine
if a certificate is already being tracked by Lemur. if a certificate is already being tracked by Lemur.
:param cert_body: :param cert:
:return: :return:
""" """
return Certificate.query.filter_by(body=cert_body).all() return Certificate.query.filter_by(body=cert['body'].strip(), chain=cert['chain'].strip()).all()
def export(cert, export_plugin): def export(cert, export_plugin):
@ -87,75 +99,64 @@ def export(cert, export_plugin):
:return: :return:
""" """
plugin = plugins.get(export_plugin['slug']) plugin = plugins.get(export_plugin['slug'])
return plugin.export(cert.body, cert.chain, cert.private_key, export_plugin['pluginOptions']) return plugin.export(cert.body, cert.chain, cert.private_key, export_plugin['pluginOptions'])
def update(cert_id, owner, description, active, destinations, notifications, replaces): def update(cert_id, owner, description, notify, destinations, notifications, replaces, roles):
""" """
Updates a certificate Updates a certificate
:param cert_id: :param cert_id:
:param owner: :param owner:
:param description: :param description:
:param active: :param notify:
:param destinations: :param destinations:
:param notifications: :param notifications:
:param replaces: :param replaces:
:return: :return:
""" """
from lemur.notifications import service as notification_service
cert = get(cert_id) cert = get(cert_id)
cert.active = active cert.notify = notify
cert.description = description cert.description = description
cert.destinations = destinations
# we might have to create new notifications if the owner changes cert.notifications = notifications
new_notifications = [] cert.roles = roles
# get existing names to remove cert.replaces = replaces
notification_name = "DEFAULT_{0}".format(cert.owner.split('@')[0].upper())
for n in notifications:
if notification_name not in n.label:
new_notifications.append(n)
notification_name = "DEFAULT_{0}".format(owner.split('@')[0].upper())
new_notifications += notification_service.create_default_expiration_notifications(notification_name, owner)
cert.notifications = new_notifications
database.update_list(cert, 'destinations', Destination, destinations)
database.update_list(cert, 'replaces', Certificate, replaces)
cert.owner = owner cert.owner = owner
return database.update(cert) return database.update(cert)
def mint(issuer_options): def create_certificate_roles(**kwargs):
# create an role for the owner and assign it
owner_role = role_service.get_by_name(kwargs['owner'])
if not owner_role:
owner_role = role_service.create(
kwargs['owner'],
description="Auto generated role based on owner: {0}".format(kwargs['owner'])
)
return [owner_role]
def mint(**kwargs):
""" """
Minting is slightly different for each authority. Minting is slightly different for each authority.
Support for multiple authorities is handled by individual plugins. Support for multiple authorities is handled by individual plugins.
:param issuer_options:
""" """
authority = issuer_options['authority'] authority = kwargs['authority']
issuer = plugins.get(authority.plugin_name) issuer = plugins.get(authority.plugin_name)
# allow the CSR to be specified by the user # allow the CSR to be specified by the user
if not issuer_options.get('csr'): if not kwargs.get('csr'):
csr, private_key = create_csr(issuer_options) csr, private_key = create_csr(**kwargs)
else: else:
csr = issuer_options.get('csr') csr = str(kwargs.get('csr'))
private_key = None private_key = None
issuer_options['creator'] = g.user.email cert_body, cert_chain = issuer.create_certificate(csr, kwargs)
cert_body, cert_chain = issuer.create_certificate(csr, issuer_options) return cert_body, private_key, cert_chain,
cert = Certificate(cert_body, private_key, cert_chain)
cert.user = g.user
cert.authority = authority
database.update(cert)
return cert, private_key, cert_chain,
def import_certificate(**kwargs): def import_certificate(**kwargs):
@ -171,106 +172,64 @@ def import_certificate(**kwargs):
:param kwargs: :param kwargs:
""" """
from lemur.users import service as user_service if not kwargs.get('owner'):
from lemur.notifications import service as notification_service kwargs['owner'] = current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL')[0]
cert = Certificate(kwargs['public_certificate'], chain=kwargs['intermediate_certificate'])
# TODO future source plugins might have a better understanding of who the 'owner' is we should support this return upload(**kwargs)
cert.owner = kwargs.get('owner', current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL')[0])
cert.creator = kwargs.get('creator', user_service.get_by_email('lemur@nobody'))
# NOTE existing certs may not follow our naming standard we will
# overwrite the generated name with the actual cert name
if kwargs.get('name'):
cert.name = kwargs.get('name')
if kwargs.get('user'):
cert.user = kwargs.get('user')
notification_name = 'DEFAULT_SECURITY'
notifications = notification_service.create_default_expiration_notifications(notification_name, current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL'))
if kwargs.get('replacements'):
database.update_list(cert, 'replaces', Certificate, kwargs['replacements'])
cert.notifications = notifications
cert = database.create(cert)
return cert
def upload(**kwargs): def upload(**kwargs):
""" """
Allows for pre-made certificates to be imported into Lemur. Allows for pre-made certificates to be imported into Lemur.
""" """
from lemur.notifications import service as notification_service roles = create_certificate_roles(**kwargs)
cert = Certificate(
kwargs.get('public_cert'),
kwargs.get('private_key'),
kwargs.get('intermediate_cert'),
)
# we override the generated name if one is provided if kwargs.get('roles'):
if kwargs.get('name'): kwargs['roles'] += roles
cert.name = kwargs['name'] else:
kwargs['roles'] = roles
cert.description = kwargs.get('description') if kwargs.get('private_key'):
private_key = kwargs['private_key']
if not isinstance(private_key, bytes):
kwargs['private_key'] = private_key.encode('utf-8')
cert = Certificate(**kwargs)
cert.owner = kwargs['owner']
cert = database.create(cert) cert = database.create(cert)
g.user.certificates.append(cert) try:
g.user.certificates.append(cert)
except AttributeError:
current_app.logger.debug("No user to associate uploaded certificate to.")
database.update_list(cert, 'destinations', Destination, kwargs.get('destinations')) return database.update(cert)
database.update_list(cert, 'notifications', Notification, kwargs.get('notifications'))
database.update_list(cert, 'replaces', Certificate, kwargs['replacements'])
# create default notifications for this certificate if none are provided
notifications = []
if not kwargs.get('notifications'):
notification_name = "DEFAULT_{0}".format(cert.owner.split('@')[0].upper())
notifications += notification_service.create_default_expiration_notifications(notification_name, [cert.owner])
notification_name = 'DEFAULT_SECURITY'
notifications += notification_service.create_default_expiration_notifications(notification_name, current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL'))
cert.notifications = notifications
database.update(cert)
return cert
def create(**kwargs): def create(**kwargs):
""" """
Creates a new certificate. Creates a new certificate.
""" """
from lemur.notifications import service as notification_service kwargs['creator'] = g.user.email
cert, private_key, cert_chain = mint(kwargs) cert_body, private_key, cert_chain = mint(**kwargs)
kwargs['body'] = cert_body
kwargs['private_key'] = private_key
kwargs['chain'] = cert_chain
cert.owner = kwargs['owner'] roles = create_certificate_roles(**kwargs)
if kwargs.get('roles'):
kwargs['roles'] += roles
else:
kwargs['roles'] = roles
cert = Certificate(**kwargs)
database.create(cert)
cert.description = kwargs['description']
g.user.certificates.append(cert) g.user.certificates.append(cert)
database.update(g.user) cert.authority = kwargs['authority']
database.commit()
# do this after the certificate has already been created because if it fails to upload to the third party metrics.send('certificate_issued', 'counter', 1, metric_tags=dict(owner=cert.owner, issuer=cert.issuer))
# we do not want to lose the certificate information.
database.update_list(cert, 'destinations', Destination, kwargs.get('destinations'))
database.update_list(cert, 'replaces', Certificate, kwargs['replacements'])
database.update_list(cert, 'notifications', Notification, kwargs.get('notifications'))
# create default notifications for this certificate if none are provided
notifications = cert.notifications
if not kwargs.get('notifications'):
notification_name = "DEFAULT_{0}".format(cert.owner.split('@')[0].upper())
notifications += notification_service.create_default_expiration_notifications(notification_name, [cert.owner])
notification_name = 'DEFAULT_SECURITY'
notifications += notification_service.create_default_expiration_notifications(notification_name,
current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL'))
cert.notifications = notifications
database.update(cert)
return cert return cert
@ -311,7 +270,7 @@ def render(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]))
elif 'active' in filt: # this is really weird but strcmp seems to not work here?? elif 'active' in filt:
query = query.filter(Certificate.active == terms[1]) query = query.filter(Certificate.active == terms[1])
elif 'cn' in terms: elif 'cn' in terms:
query = query.filter( query = query.filter(
@ -346,7 +305,7 @@ def render(args):
return database.sort_and_page(query, Certificate, args) return database.sort_and_page(query, Certificate, args)
def create_csr(csr_config): def create_csr(**csr_config):
""" """
Given a list of domains create the appropriate csr Given a list of domains create the appropriate csr
for those domains for those domains
@ -362,12 +321,13 @@ def create_csr(csr_config):
# TODO When we figure out a better way to validate these options they should be parsed as str # TODO When we figure out a better way to validate these options they should be parsed as str
builder = x509.CertificateSigningRequestBuilder() builder = x509.CertificateSigningRequestBuilder()
builder = builder.subject_name(x509.Name([ builder = builder.subject_name(x509.Name([
x509.NameAttribute(x509.OID_COMMON_NAME, csr_config['commonName']), x509.NameAttribute(x509.OID_COMMON_NAME, csr_config['common_name']),
x509.NameAttribute(x509.OID_ORGANIZATION_NAME, csr_config['organization']), x509.NameAttribute(x509.OID_ORGANIZATION_NAME, csr_config['organization']),
x509.NameAttribute(x509.OID_ORGANIZATIONAL_UNIT_NAME, csr_config['organizationalUnit']), x509.NameAttribute(x509.OID_ORGANIZATIONAL_UNIT_NAME, csr_config['organizational_unit']),
x509.NameAttribute(x509.OID_COUNTRY_NAME, csr_config['country']), x509.NameAttribute(x509.OID_COUNTRY_NAME, csr_config['country']),
x509.NameAttribute(x509.OID_STATE_OR_PROVINCE_NAME, csr_config['state']), x509.NameAttribute(x509.OID_STATE_OR_PROVINCE_NAME, csr_config['state']),
x509.NameAttribute(x509.OID_LOCALITY_NAME, csr_config['location']), x509.NameAttribute(x509.OID_LOCALITY_NAME, csr_config['location']),
x509.NameAttribute(x509.OID_EMAIL_ADDRESS, csr_config['owner'])
])) ]))
builder = builder.add_extension( builder = builder.add_extension(
@ -376,11 +336,11 @@ def create_csr(csr_config):
if csr_config.get('extensions'): if csr_config.get('extensions'):
for k, v in csr_config.get('extensions', {}).items(): for k, v in csr_config.get('extensions', {}).items():
if k == 'subAltNames': if k == 'sub_alt_names':
# map types to their x509 objects # map types to their x509 objects
general_names = [] general_names = []
for name in v['names']: for name in v['names']:
if name['nameType'] == 'DNSName': if name['name_type'] == 'DNSName':
general_names.append(x509.DNSName(name['value'])) general_names.append(x509.DNSName(name['value']))
builder = builder.add_extension( builder = builder.add_extension(
@ -435,17 +395,20 @@ def create_csr(csr_config):
) )
# serialize our private key and CSR # serialize our private key and CSR
pem = private_key.private_bytes( private_key = private_key.private_bytes(
encoding=serialization.Encoding.PEM, encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL, # would like to use PKCS8 but AWS ELBs don't like it format=serialization.PrivateFormat.TraditionalOpenSSL, # would like to use PKCS8 but AWS ELBs don't like it
encryption_algorithm=serialization.NoEncryption() encryption_algorithm=serialization.NoEncryption()
) )
if isinstance(private_key, bytes):
private_key = private_key.decode('utf-8')
csr = request.public_bytes( csr = request.public_bytes(
encoding=serialization.Encoding.PEM encoding=serialization.Encoding.PEM
) )
return csr, pem return csr, private_key
def stats(**kwargs): def stats(**kwargs):
@ -476,3 +439,69 @@ def stats(**kwargs):
values.append(count) values.append(count)
return {'labels': keys, 'values': values} return {'labels': keys, 'values': values}
def get_account_number(arn):
"""
Extract the account number from an arn.
:param arn: IAM SSL arn
:return: account number associated with ARN
"""
return arn.split(":")[4]
def get_name_from_arn(arn):
"""
Extract the certificate name from an arn.
:param arn: IAM SSL arn
:return: name of the certificate as uploaded to AWS
"""
return arn.split("/", 1)[1]
def calculate_reissue_range(start, end):
"""
Determine what the new validity_start and validity_end dates should be.
:param start:
:param end:
:return:
"""
span = end - start
new_start = arrow.utcnow().date()
new_end = new_start + span
return new_start, new_end
# TODO pull the OU, O, CN, etc + other extensions.
def get_certificate_primitives(certificate):
"""
Retrieve key primitive from a certificate such that the certificate
could be recreated with new expiration or be used to build upon.
:param certificate:
:return: dict of certificate primitives, should be enough to effectively re-issue
certificate via `create`.
"""
start, end = calculate_reissue_range(certificate.not_before, certificate.not_after)
names = [{'name_type': 'DNSName', 'value': x.name} for x in certificate.domains]
extensions = {
'sub_alt_names': {
'names': names
}
}
return dict(
authority=certificate.authority,
common_name=certificate.cn,
description=certificate.description,
validity_start=start,
validity_end=end,
destinations=certificate.destinations,
roles=certificate.roles,
extensions=extensions,
owner=certificate.owner
)

View File

@ -9,129 +9,24 @@ import base64
from builtins import str from builtins import str
from flask import Blueprint, make_response, jsonify from flask import Blueprint, make_response, jsonify
from flask.ext.restful import reqparse, Api, fields from flask.ext.restful import reqparse, Api
from cryptography import x509 from lemur.common.schema import validate_schema
from cryptography.hazmat.backends import default_backend from lemur.common.utils import paginated_parser
from cryptography.hazmat.primitives import serialization
from lemur.plugins import plugins
from lemur.auth.service import AuthenticatedResource from lemur.auth.service import AuthenticatedResource
from lemur.auth.permissions import ViewKeyPermission from lemur.auth.permissions import AuthorityPermission, CertificatePermission
from lemur.auth.permissions import AuthorityPermission
from lemur.auth.permissions import UpdateCertificatePermission
from lemur.auth.permissions import SensitiveDomainPermission
from lemur.certificates import service from lemur.certificates import service
from lemur.authorities.models import Authority 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.domains import service as domain_service
from lemur.common.utils import marshal_items, paginated_parser
from lemur.notifications.views import notification_list
mod = Blueprint('certificates', __name__) mod = Blueprint('certificates', __name__)
api = Api(mod) api = Api(mod)
FIELDS = {
'name': fields.String,
'id': fields.Integer,
'bits': fields.Integer,
'deleted': fields.String,
'issuer': fields.String,
'serial': fields.String,
'owner': fields.String,
'chain': fields.String,
'san': fields.String,
'active': fields.Boolean,
'description': fields.String,
'notBefore': fields.DateTime(dt_format='iso8601', attribute='not_before'),
'notAfter': fields.DateTime(dt_format='iso8601', attribute='not_after'),
'cn': fields.String,
'signingAlgorithm': fields.String(attribute='signing_algorithm'),
'status': fields.String,
'body': fields.String
}
def valid_authority(authority_options):
"""
Defends against invalid authorities
:param authority_options:
:return: :raise ValueError:
"""
name = authority_options['name']
authority = Authority.query.filter(Authority.name == name).one()
if not authority:
raise ValueError("Unable to find authority specified")
if not authority.active:
raise ValueError("Selected authority [{0}] is not currently active".format(name))
return authority
def get_domains_from_options(options):
"""
Retrive all domains from certificate options
:param options:
:return:
"""
domains = [options['commonName']]
if options.get('extensions'):
if options['extensions'].get('subAltNames'):
for k, v in options['extensions']['subAltNames']['names']:
if k == 'DNSName':
domains.append(v)
return domains
def check_sensitive_domains(domains):
"""
Determines if any certificates in the given certificate
are marked as sensitive
:param domains:
:return:
"""
for domain in domains:
domain_objs = domain_service.get_by_name(domain)
for d in domain_objs:
if d.sensitive:
raise ValueError("The domain {0} has been marked as sensitive. Contact an administrator to "
"issue this certificate".format(d.name))
def pem_str(value, name):
"""
Used to validate that the given string is a PEM formatted string
:param value:
:param name:
:return: :raise ValueError:
"""
try:
x509.load_pem_x509_certificate(bytes(value), default_backend())
except Exception:
raise ValueError("The parameter '{0}' needs to be a valid PEM string".format(name))
return value
def private_key_str(value, name):
"""
User to validate that a given string is a RSA private key
:param value:
:param name:
:return: :raise ValueError:
"""
try:
serialization.load_pem_private_key(bytes(value), None, backend=default_backend())
except Exception:
raise ValueError("The parameter '{0}' needs to be a valid RSA private key".format(name))
return value
class CertificatesList(AuthenticatedResource): class CertificatesList(AuthenticatedResource):
""" Defines the 'certificates' endpoint """ """ Defines the 'certificates' endpoint """
@ -140,7 +35,7 @@ class CertificatesList(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(CertificatesList, self).__init__() super(CertificatesList, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, certificates_output_schema)
def get(self): def get(self):
""" """
.. http:get:: /certificates .. http:get:: /certificates
@ -164,26 +59,53 @@ class CertificatesList(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"items": [ "items": [{
{ "status": null,
"id": 1, "cn": "*.test.example.net",
"name": "cert1", "chain": "",
"description": "this is cert1", "authority": {
"bits": 2048, "active": true,
"deleted": false, "owner": "secure@example.com",
"issuer": "ExampeInc.", "id": 1,
"serial": "123450", "description": "verisign test authority",
"chain": "-----Begin ...", "name": "verisign"
"body": "-----Begin ...", },
"san": true, "owner": "joe@example.com",
"owner": 'bob@example.com", "serial": "82311058732025924142789179368889309156",
"active": true, "id": 2288,
"notBefore": "2015-06-05T17:09:39", "issuer": "SymantecCorporation",
"notAfter": "2015-06-10T17:09:39", "notBefore": "2016-06-03T00:00:00+00:00",
"cn": "example.com", "notAfter": "2018-01-12T23:59:59+00:00",
"status": "unknown" "destinations": [],
} "bits": 2048,
] "body": "-----BEGIN CERTIFICATE-----...",
"description": null,
"deleted": null,
"notifications": [{
"id": 1
}]
"signingAlgorithm": "sha256",
"user": {
"username": "jane",
"active": true,
"email": "jane@example.com",
"id": 2
},
"active": true,
"domains": [{
"sensitive": false,
"id": 1090,
"name": "*.test.example.net"
}],
"replaces": [],
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{
"id": 464,
"description": "This is a google group based role created by Lemur",
"name": "joe@example.com"
}],
"san": null
}],
"total": 1 "total": 1
} }
@ -191,10 +113,11 @@ class CertificatesList(AuthenticatedResource):
:query sortDir: acs or desc :query sortDir: acs or desc
:query page: int. default is 1 :query page: int. default is 1
:query filter: key value pair format is k;v :query filter: key value pair format is k;v
:query limit: limit number. default is 10 :query count: count number. default is 10
: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
""" """
parser = paginated_parser.copy() parser = paginated_parser.copy()
parser.add_argument('timeRange', type=int, dest='time_range', location='args') parser.add_argument('timeRange', type=int, dest='time_range', location='args')
@ -208,8 +131,8 @@ class CertificatesList(AuthenticatedResource):
args = parser.parse_args() args = parser.parse_args()
return service.render(args) return service.render(args)
@marshal_items(FIELDS) @validate_schema(certificate_input_schema, certificate_output_schema)
def post(self): def post(self, data=None):
""" """
.. http:post:: /certificates .. http:post:: /certificates
@ -224,90 +147,38 @@ class CertificatesList(AuthenticatedResource):
Accept: application/json, text/javascript Accept: application/json, text/javascript
{ {
"country": "US", "owner": "secure@example.net",
"state": "CA", "commonName": "test.example.net",
"location": "A Place", "country": "US",
"organization": "ExampleInc.", "extensions": {
"organizationalUnit": "Operations",
"owner": "bob@example.com",
"description": "test",
"selectedAuthority": "timetest2",
"csr",
"authority": {
"body": "-----BEGIN...",
"name": "timetest2",
"chain": "",
"notBefore": "2015-06-05T15:20:59",
"active": true,
"id": 50,
"notAfter": "2015-06-17T15:21:08",
"description": "dsfdsf"
},
"notifications": [
{
"description": "Default 30 day expiration notification",
"notificationOptions": [
{
"name": "interval",
"required": true,
"value": 30,
"helpMessage": "Number of days to be alert before expiration.",
"validation": "^\\d+$",
"type": "int"
},
{
"available": [
"days",
"weeks",
"months"
],
"name": "unit",
"required": true,
"value": "days",
"helpMessage": "Interval unit",
"validation": "",
"type": "select"
},
{
"name": "recipients",
"required": true,
"value": "bob@example.com",
"helpMessage": "Comma delimited list of email addresses",
"validation": "^([\\w+-.%]+@[\\w-.]+\\.[A-Za-z]{2,4},?)+$",
"type": "str"
}
],
"label": "DEFAULT_KGLISSON_30_DAY",
"pluginName": "email-notification",
"active": true,
"id": 7
}
],
"extensions": {
"basicConstraints": {},
"keyUsage": {
"isCritical": true,
"useKeyEncipherment": true,
"useDigitalSignature": true
},
"extendedKeyUsage": {
"isCritical": true,
"useServerAuthentication": true
},
"subjectKeyIdentifier": {
"includeSKI": true
},
"subAltNames": { "subAltNames": {
"names": [] "names": [
{
"nameType": "DNSName",
"value": "*.test.example.net"
},
{
"nameType": "DNSName",
"value": "www.test.example.net"
}
]
} }
}, },
"commonName": "test", "replacements": [{
"validityStart": "2015-06-05T07:00:00.000Z", "id": 1
"validityEnd": "2015-06-16T07:00:00.000Z", },
"replacements": [ "notify": true,
{'id': 123} "validityEnd": "2026-01-01T08:00:00.000Z",
] "authority": {
} "name": "verisign"
},
"organization": "Netflix, Inc.",
"location": "Los Gatos",
"state": "California",
"validityStart": "2016-11-11T04:19:48.000Z",
"organizationalUnit": "Operations"
}
**Example response**: **Example response**:
@ -318,24 +189,56 @@ class CertificatesList(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"id": 1, "status": null,
"name": "cert1", "cn": "*.test.example.net",
"description": "this is cert1", "chain": "",
"authority": {
"active": true,
"owner": "secure@example.com",
"id": 1,
"description": "verisign test authority",
"name": "verisign"
},
"owner": "joe@example.com",
"serial": "82311058732025924142789179368889309156",
"id": 2288,
"issuer": "SymantecCorporation",
"notBefore": "2016-06-03T00:00:00+00:00",
"notAfter": "2018-01-12T23:59:59+00:00",
"destinations": [],
"bits": 2048, "bits": 2048,
"deleted": false, "body": "-----BEGIN CERTIFICATE-----...",
"issuer": "ExampeInc.", "description": null,
"serial": "123450", "deleted": null,
"chain": "-----Begin ...", "notifications": [{
"body": "-----Begin ...", "id": 1
"san": true, }]
"owner": "jimbob@example.com", "signingAlgorithm": "sha256",
"active": false, "user": {
"notBefore": "2015-06-05T17:09:39", "username": "jane",
"notAfter": "2015-06-10T17:09:39", "active": true,
"cn": "example.com", "email": "jane@example.com",
"status": "unknown" "id": 2
},
"active": true,
"domains": [{
"sensitive": false,
"id": 1090,
"name": "*.test.example.net"
}],
"replaces": [{
"id": 1
}],
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{
"id": 464,
"description": "This is a google group based role created by Lemur",
"name": "joe@example.com"
}],
"san": null
} }
:arg extensions: extensions to be used in the certificate :arg extensions: extensions to be used in the certificate
:arg description: description for new certificate :arg description: description for new certificate
:arg owner: owner email :arg owner: owner email
@ -346,47 +249,25 @@ class CertificatesList(AuthenticatedResource):
:arg state: state for the CSR :arg state: state for the CSR
:arg location: location for the CSR :arg location: location for the CSR
:arg organization: organization for CSR :arg organization: organization for CSR
:arg commonName: certiifcate common name :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
""" """
self.reqparse.add_argument('extensions', type=dict, location='json') role = role_service.get_by_name(data['authority'].owner)
self.reqparse.add_argument('destinations', type=list, default=[], location='json')
self.reqparse.add_argument('notifications', type=list, default=[], location='json')
self.reqparse.add_argument('replacements', type=list, default=[], location='json')
self.reqparse.add_argument('validityStart', type=str, location='json') # TODO validate
self.reqparse.add_argument('validityEnd', type=str, location='json') # TODO validate
self.reqparse.add_argument('authority', type=valid_authority, location='json', required=True)
self.reqparse.add_argument('description', type=str, location='json')
self.reqparse.add_argument('country', type=str, location='json', required=True)
self.reqparse.add_argument('state', type=str, location='json', required=True)
self.reqparse.add_argument('location', type=str, location='json', required=True)
self.reqparse.add_argument('organization', type=str, location='json', required=True)
self.reqparse.add_argument('organizationalUnit', type=str, location='json', required=True)
self.reqparse.add_argument('owner', type=str, location='json', required=True)
self.reqparse.add_argument('commonName', type=str, location='json', required=True)
self.reqparse.add_argument('csr', type=str, location='json')
args = self.reqparse.parse_args()
authority = args['authority']
role = role_service.get_by_name(authority.owner)
# all the authority role members should be allowed # all the authority role members should be allowed
roles = [x.name for x in authority.roles] roles = [x.name for x in data['authority'].roles]
# allow "owner" roles by team DL # allow "owner" roles by team DL
roles.append(role) roles.append(role)
authority_permission = AuthorityPermission(authority.id, roles) authority_permission = AuthorityPermission(data['authority'].id, roles)
if authority_permission.can(): if authority_permission.can():
# if we are not admins lets make sure we aren't issuing anything sensitive return service.create(**data)
if not SensitiveDomainPermission().can():
check_sensitive_domains(get_domains_from_options(args))
return service.create(**args)
return dict(message="You are not authorized to use {0}".format(args['authority'].name)), 403 return dict(message="You are not authorized to use {0}".format(data['authority'].name)), 403
class CertificatesUpload(AuthenticatedResource): class CertificatesUpload(AuthenticatedResource):
@ -396,8 +277,8 @@ class CertificatesUpload(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(CertificatesUpload, self).__init__() super(CertificatesUpload, self).__init__()
@marshal_items(FIELDS) @validate_schema(certificate_upload_input_schema, certificate_output_schema)
def post(self): def post(self, data=None):
""" """
.. http:post:: /certificates/upload .. http:post:: /certificates/upload
@ -431,23 +312,51 @@ class CertificatesUpload(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"id": 1, "status": null,
"name": "cert1", "cn": "*.test.example.net",
"description": "this is cert1", "chain": "",
"bits": 2048, "authority": {
"deleted": false, "active": true,
"issuer": "ExampeInc.", "owner": "secure@example.com",
"serial": "123450", "id": 1,
"chain": "-----Begin ...", "description": "verisign test authority",
"body": "-----Begin ...", "name": "verisign"
"san": true, },
"owner": "joe@example.com", "owner": "joe@example.com",
"active": true, "serial": "82311058732025924142789179368889309156",
"notBefore": "2015-06-05T17:09:39", "id": 2288,
"notAfter": "2015-06-10T17:09:39", "issuer": "SymantecCorporation",
"signingAlgorithm": "sha2" "notBefore": "2016-06-03T00:00:00+00:00",
"cn": "example.com", "notAfter": "2018-01-12T23:59:59+00:00",
"status": "unknown" "destinations": [],
"bits": 2048,
"body": "-----BEGIN CERTIFICATE-----...",
"description": null,
"deleted": null,
"notifications": [{
"id": 1
}]
"signingAlgorithm": "sha256",
"user": {
"username": "jane",
"active": true,
"email": "jane@example.com",
"id": 2
},
"active": true,
"domains": [{
"sensitive": false,
"id": 1090,
"name": "*.test.example.net"
}],
"replaces": [],
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{
"id": 464,
"description": "This is a google group based role created by Lemur",
"name": "joe@example.com"
}],
"san": null
} }
:arg owner: owner email for certificate :arg owner: owner email for certificate
@ -458,24 +367,14 @@ class CertificatesUpload(AuthenticatedResource):
: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
"""
self.reqparse.add_argument('description', type=str, location='json')
self.reqparse.add_argument('owner', type=str, required=True, location='json')
self.reqparse.add_argument('name', type=str, location='json')
self.reqparse.add_argument('publicCert', type=pem_str, required=True, dest='public_cert', location='json')
self.reqparse.add_argument('destinations', type=list, default=[], location='json')
self.reqparse.add_argument('notifications', type=list, default=[], location='json')
self.reqparse.add_argument('replacements', type=list, default=[], location='json')
self.reqparse.add_argument('intermediateCert', type=pem_str, dest='intermediate_cert', location='json')
self.reqparse.add_argument('privateKey', type=private_key_str, dest='private_key', location='json')
args = self.reqparse.parse_args() """
if args.get('destinations'): if data.get('destinations'):
if args.get('private_key'): if data.get('private_key'):
return service.upload(**args) return service.upload(**data)
else: else:
raise Exception("Private key must be provided in order to upload certificate to AWS") raise Exception("Private key must be provided in order to upload certificate to AWS")
return service.upload(**args) return service.upload(**data)
class CertificatesStats(AuthenticatedResource): class CertificatesStats(AuthenticatedResource):
@ -535,9 +434,8 @@ class CertificatePrivateKey(AuthenticatedResource):
if not cert: if not cert:
return dict(message="Cannot find specified certificate"), 404 return dict(message="Cannot find specified certificate"), 404
role = role_service.get_by_name(cert.owner) owner_role = role_service.get_by_name(cert.owner)
permission = CertificatePermission(cert.id, owner_role, [x.name for x in cert.roles])
permission = ViewKeyPermission(certificate_id, getattr(role, 'name', None))
if permission.can(): if permission.can():
response = make_response(jsonify(key=cert.private_key), 200) response = make_response(jsonify(key=cert.private_key), 200)
@ -553,7 +451,7 @@ class Certificates(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(Certificates, self).__init__() super(Certificates, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, certificate_output_schema)
def get(self, certificate_id): def get(self, certificate_id):
""" """
.. http:get:: /certificates/1 .. http:get:: /certificates/1
@ -577,33 +475,62 @@ class Certificates(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"id": 1, "status": null,
"name": "cert1", "cn": "*.test.example.net",
"description": "this is cert1", "chain": "",
"authority": {
"active": true,
"owner": "secure@example.com",
"id": 1,
"description": "verisign test authority",
"name": "verisign"
},
"owner": "joe@example.com",
"serial": "82311058732025924142789179368889309156",
"id": 2288,
"issuer": "SymantecCorporation",
"notBefore": "2016-06-03T00:00:00+00:00",
"notAfter": "2018-01-12T23:59:59+00:00",
"destinations": [],
"bits": 2048, "bits": 2048,
"deleted": false, "body": "-----BEGIN CERTIFICATE-----...",
"issuer": "ExampeInc.", "description": null,
"serial": "123450", "deleted": null,
"chain": "-----Begin ...", "notifications": [{
"body": "-----Begin ...", "id": 1
"san": true, }]
"owner": "bob@example.com", "signingAlgorithm": "sha256",
"user": {
"username": "jane",
"active": true,
"email": "jane@example.com",
"id": 2
},
"active": true, "active": true,
"notBefore": "2015-06-05T17:09:39", "domains": [{
"notAfter": "2015-06-10T17:09:39", "sensitive": false,
"signingAlgorithm": "sha2", "id": 1090,
"cn": "example.com", "name": "*.test.example.net"
"status": "unknown" }],
"replaces": [],
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{
"id": 464,
"description": "This is a google group based role created by Lemur",
"name": "joe@example.com"
}],
"san": null
} }
: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
""" """
return service.get(certificate_id) return service.get(certificate_id)
@marshal_items(FIELDS) @validate_schema(certificate_edit_input_schema, certificate_output_schema)
def put(self, certificate_id): def put(self, certificate_id, data=None):
""" """
.. http:put:: /certificates/1 .. http:put:: /certificates/1
@ -634,50 +561,78 @@ class Certificates(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"id": 1, "status": null,
"name": "cert1", "cn": "*.test.example.net",
"description": "this is cert1", "chain": "",
"authority": {
"active": true,
"owner": "secure@example.com",
"id": 1,
"description": "verisign test authority",
"name": "verisign"
},
"owner": "joe@example.com",
"serial": "82311058732025924142789179368889309156",
"id": 2288,
"issuer": "SymantecCorporation",
"notBefore": "2016-06-03T00:00:00+00:00",
"notAfter": "2018-01-12T23:59:59+00:00",
"destinations": [],
"bits": 2048, "bits": 2048,
"deleted": false, "body": "-----BEGIN CERTIFICATE-----...",
"issuer": "ExampeInc.", "description": null,
"serial": "123450", "deleted": null,
"chain": "-----Begin ...", "notifications": [{
"body": "-----Begin ...", "id": 1
"san": true, }]
"owner": "jimbob@example.com", "signingAlgorithm": "sha256",
"active": false, "user": {
"notBefore": "2015-06-05T17:09:39", "username": "jane",
"notAfter": "2015-06-10T17:09:39", "active": true,
"cn": "example.com", "email": "jane@example.com",
"status": "unknown", "id": 2
},
"active": true,
"domains": [{
"sensitive": false,
"id": 1090,
"name": "*.test.example.net"
}],
"replaces": [],
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{
"id": 464,
"description": "This is a google group based role created by Lemur",
"name": "joe@example.com"
}],
"san": null
} }
: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
""" """
self.reqparse.add_argument('active', type=bool, location='json')
self.reqparse.add_argument('owner', type=str, location='json')
self.reqparse.add_argument('description', type=str, location='json')
self.reqparse.add_argument('destinations', type=list, default=[], location='json')
self.reqparse.add_argument('notifications', type=notification_list, default=[], location='json')
self.reqparse.add_argument('replacements', type=list, default=[], location='json')
args = self.reqparse.parse_args()
cert = service.get(certificate_id) cert = service.get(certificate_id)
role = role_service.get_by_name(cert.owner)
permission = UpdateCertificatePermission(certificate_id, getattr(role, 'name', None)) owner_role = role_service.get_by_name(cert.owner)
permission = CertificatePermission(cert.id, owner_role, [x.name for x in cert.roles])
if permission.can(): if permission.can():
for destination in data['destinations']:
if destination.plugin.requires_key:
if not cert.private_key:
return dict('Unable to add destination: {0}. Certificate does not have required private key.'.format(destination.label))
return service.update( return service.update(
certificate_id, certificate_id,
args['owner'], data['owner'],
args['description'], data['description'],
args['active'], data['notify'],
args['destinations'], data['destinations'],
args['notifications'], data['notifications'],
args['replacements'] data['replacements'],
data['roles']
) )
return dict(message='You are not authorized to update this certificate'), 403 return dict(message='You are not authorized to update this certificate'), 403
@ -690,7 +645,7 @@ class NotificationCertificatesList(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(NotificationCertificatesList, self).__init__() super(NotificationCertificatesList, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, certificates_output_schema)
def get(self, notification_id): def get(self, notification_id):
""" """
.. http:get:: /notifications/1/certificates .. http:get:: /notifications/1/certificates
@ -714,27 +669,53 @@ class NotificationCertificatesList(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"items": [ "items": [{
{ "status": null,
"id": 1, "cn": "*.test.example.net",
"name": "cert1", "chain": "",
"description": "this is cert1", "authority": {
"bits": 2048, "active": true,
"deleted": false, "owner": "secure@example.com",
"issuer": "ExampeInc.", "id": 1,
"serial": "123450", "description": "verisign test authority",
"chain": "-----Begin ...", "name": "verisign"
"body": "-----Begin ...", },
"san": true, "owner": "joe@example.com",
"owner": 'bob@example.com", "serial": "82311058732025924142789179368889309156",
"active": true, "id": 2288,
"notBefore": "2015-06-05T17:09:39", "issuer": "SymantecCorporation",
"notAfter": "2015-06-10T17:09:39", "notBefore": "2016-06-03T00:00:00+00:00",
"signingAlgorithm": "sha2", "notAfter": "2018-01-12T23:59:59+00:00",
"cn": "example.com", "destinations": [],
"status": "unknown" "bits": 2048,
} "body": "-----BEGIN CERTIFICATE-----...",
] "description": null,
"deleted": null,
"notifications": [{
"id": 1
}]
"signingAlgorithm": "sha256",
"user": {
"username": "jane",
"active": true,
"email": "jane@example.com",
"id": 2
},
"active": true,
"domains": [{
"sensitive": false,
"id": 1090,
"name": "*.test.example.net"
}],
"replaces": [],
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{
"id": 464,
"description": "This is a google group based role created by Lemur",
"name": "joe@example.com"
}],
"san": null
}],
"total": 1 "total": 1
} }
@ -742,10 +723,11 @@ class NotificationCertificatesList(AuthenticatedResource):
:query sortDir: acs or desc :query sortDir: acs or desc
:query page: int default is 1 :query page: int default is 1
:query filter: key value pair format is k;v :query filter: key value pair format is k;v
:query limit: limit number default is 10 :query count: count number default is 10
: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
""" """
parser = paginated_parser.copy() parser = paginated_parser.copy()
parser.add_argument('timeRange', type=int, dest='time_range', location='args') parser.add_argument('timeRange', type=int, dest='time_range', location='args')
@ -766,7 +748,7 @@ class CertificatesReplacementsList(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(CertificatesReplacementsList, self).__init__() super(CertificatesReplacementsList, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, certificates_output_schema)
def get(self, certificate_id): def get(self, certificate_id):
""" """
.. http:get:: /certificates/1/replacements .. http:get:: /certificates/1/replacements
@ -789,29 +771,61 @@ class CertificatesReplacementsList(AuthenticatedResource):
Vary: Accept Vary: Accept
Content-Type: text/javascript Content-Type: text/javascript
[{ {
"id": 1, "items": [{
"name": "cert1", "status": null,
"description": "this is cert1", "cn": "*.test.example.net",
"bits": 2048, "chain": "",
"deleted": false, "authority": {
"issuer": "ExampeInc.", "active": true,
"serial": "123450", "owner": "secure@example.com",
"chain": "-----Begin ...", "id": 1,
"body": "-----Begin ...", "description": "verisign test authority",
"san": true, "name": "verisign"
"owner": "bob@example.com", },
"active": true, "owner": "joe@example.com",
"notBefore": "2015-06-05T17:09:39", "serial": "82311058732025924142789179368889309156",
"notAfter": "2015-06-10T17:09:39", "id": 2288,
"signingAlgorithm": "sha2", "issuer": "SymantecCorporation",
"cn": "example.com", "notBefore": "2016-06-03T00:00:00+00:00",
"status": "unknown" "notAfter": "2018-01-12T23:59:59+00:00",
}] "destinations": [],
"bits": 2048,
"body": "-----BEGIN CERTIFICATE-----...",
"description": null,
"deleted": null,
"notifications": [{
"id": 1
}]
"signingAlgorithm": "sha256",
"user": {
"username": "jane",
"active": true,
"email": "jane@example.com",
"id": 2
},
"active": true,
"domains": [{
"sensitive": false,
"id": 1090,
"name": "*.test.example.net"
}],
"replaces": [],
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{
"id": 464,
"description": "This is a google group based role created by Lemur",
"name": "joe@example.com"
}],
"san": null
}],
"total": 1
}
: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
""" """
return service.get(certificate_id).replaces return service.get(certificate_id).replaces
@ -821,7 +835,8 @@ class CertificateExport(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(CertificateExport, self).__init__() super(CertificateExport, self).__init__()
def post(self, certificate_id): @validate_schema(certificate_export_input_schema, None)
def post(self, certificate_id, data=None):
""" """
.. http:post:: /certificates/1/export .. http:post:: /certificates/1/export
@ -885,26 +900,40 @@ class CertificateExport(AuthenticatedResource):
: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
""" """
self.reqparse.add_argument('export', type=dict, required=True, location='json')
args = self.reqparse.parse_args()
cert = service.get(certificate_id) cert = service.get(certificate_id)
role = role_service.get_by_name(cert.owner)
permission = UpdateCertificatePermission(certificate_id, getattr(role, 'name', None)) owner_role = role_service.get_by_name(cert.owner)
permission = CertificatePermission(cert.id, owner_role, [x.name for x in cert.roles])
options = data['plugin']['plugin_options']
plugin = data['plugin']['plugin_object']
plugin = plugins.get(args['export']['plugin']['slug'])
if plugin.requires_key: if plugin.requires_key:
if permission.can(): if cert.private_key:
extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, args['export']['plugin']['pluginOptions']) if permission.can():
extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, options)
else:
return dict(message='You are not authorized to export this certificate.'), 403
else: else:
return dict(message='You are not authorized to export this certificate'), 403 return dict(message='Unable to export certificate, plugin: {0} requires a private key but no key was found.'.format(plugin.slug))
else: else:
extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, args['export']['plugin']['pluginOptions']) extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, options)
# we take a hit in message size when b64 encoding # we take a hit in message size when b64 encoding
return dict(extension=extension, passphrase=passphrase, data=base64.b64encode(data)) return dict(extension=extension, passphrase=passphrase, data=base64.b64encode(data).decode('utf-8'))
class CertificateClone(AuthenticatedResource):
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(CertificateExport, self).__init__()
@validate_schema(None, certificate_output_schema)
def get(self, certificate_id):
pass
api.add_resource(CertificatesList, '/certificates', endpoint='certificates') api.add_resource(CertificatesList, '/certificates', endpoint='certificates')
@ -913,6 +942,7 @@ api.add_resource(CertificatesStats, '/certificates/stats', endpoint='certificate
api.add_resource(CertificatesUpload, '/certificates/upload', endpoint='certificateUpload') api.add_resource(CertificatesUpload, '/certificates/upload', endpoint='certificateUpload')
api.add_resource(CertificatePrivateKey, '/certificates/<int:certificate_id>/key', endpoint='privateKeyCertificates') api.add_resource(CertificatePrivateKey, '/certificates/<int:certificate_id>/key', endpoint='privateKeyCertificates')
api.add_resource(CertificateExport, '/certificates/<int:certificate_id>/export', endpoint='exportCertificate') api.add_resource(CertificateExport, '/certificates/<int:certificate_id>/export', endpoint='exportCertificate')
api.add_resource(CertificateClone, '/certificates/<int:certificate_id>/clone', endpoint='cloneCertificate')
api.add_resource(NotificationCertificatesList, '/notifications/<int:notification_id>/certificates', api.add_resource(NotificationCertificatesList, '/notifications/<int:notification_id>/certificates',
endpoint='notificationCertificates') endpoint='notificationCertificates')
api.add_resource(CertificatesReplacementsList, '/certificates/<int:certificate_id>/replacements', api.add_resource(CertificatesReplacementsList, '/certificates/<int:certificate_id>/replacements',

169
lemur/common/defaults.py Normal file
View File

@ -0,0 +1,169 @@
from cryptography import x509
from flask import current_app
from lemur.constants import SAN_NAMING_TEMPLATE, DEFAULT_NAMING_TEMPLATE
def certificate_name(common_name, issuer, not_before, not_after, san):
"""
Create a name for our certificate. A naming standard
is based on a series of templates. The name includes
useful information such as Common Name, Validation dates,
and Issuer.
:param san:
:param common_name:
:param not_after:
:param issuer:
:param not_before:
:rtype: str
:return:
"""
if san:
t = SAN_NAMING_TEMPLATE
else:
t = DEFAULT_NAMING_TEMPLATE
temp = t.format(
subject=common_name,
issuer=issuer,
not_before=not_before.strftime('%Y%m%d'),
not_after=not_after.strftime('%Y%m%d')
)
disallowed_chars = ''.join(c for c in map(chr, range(256)) if not c.isalnum())
disallowed_chars = disallowed_chars.replace("-", "")
disallowed_chars = disallowed_chars.replace(".", "")
temp = temp.replace('*', "WILDCARD")
for c in disallowed_chars:
temp = temp.replace(c, "")
# white space is silly too
return temp.replace(" ", "-")
def signing_algorithm(cert):
return cert.signature_hash_algorithm.name
def common_name(cert):
"""
Attempts to get a sane common name from a given certificate.
:param cert:
:return: Common name or None
"""
return cert.subject.get_attributes_for_oid(
x509.OID_COMMON_NAME
)[0].value.strip()
def domains(cert):
"""
Attempts to get an domains listed in a certificate.
If 'subjectAltName' extension is not available we simply
return the common name.
:param cert:
:return: List of domains
"""
domains = []
try:
ext = cert.extensions.get_extension_for_oid(x509.OID_SUBJECT_ALTERNATIVE_NAME)
entries = ext.value.get_values_for_type(x509.DNSName)
for entry in entries:
domains.append(entry)
except Exception as e:
current_app.logger.warning("Failed to get SubjectAltName: {0}".format(e))
return domains
def serial(cert):
"""
Fetch the serial number from the certificate.
:param cert:
:return: serial number
"""
return cert.serial
def san(cert):
"""
Determines if a given certificate is a SAN certificate.
SAN certificates are simply certificates that cover multiple domains.
:param cert:
:return: Bool
"""
if len(domains(cert)) > 1:
return True
def is_wildcard(cert):
"""
Determines if certificate is a wildcard certificate.
:param cert:
:return: Bool
"""
d = domains(cert)
if len(d) == 1 and d[0][0:1] == "*":
return True
if cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME)[0].value[0:1] == "*":
return True
def bitstrength(cert):
"""
Calculates a certificates public key bit length.
:param cert:
:return: Integer
"""
try:
return cert.public_key().key_size
except AttributeError:
current_app.logger.debug('Unable to get bitstrength.')
def issuer(cert):
"""
Gets a sane issuer from a given certificate.
:param cert:
:return: Issuer
"""
delchars = ''.join(c for c in map(chr, range(256)) if not c.isalnum())
try:
issuer = str(cert.issuer.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)[0].value)
for c in delchars:
issuer = issuer.replace(c, "")
return issuer
except Exception as e:
current_app.logger.error("Unable to get issuer! {0}".format(e))
def not_before(cert):
"""
Gets the naive datetime of the certificates 'not_before' field.
This field denotes the first date in time which the given certificate
is valid.
:param cert:
:return: Datetime
"""
return cert.not_valid_before
def not_after(cert):
"""
Gets the naive datetime of the certificates 'not_after' field.
This field denotes the last date in time which the given certificate
is valid.
:return: Datetime
"""
return cert.not_valid_after

93
lemur/common/fields.py Normal file
View File

@ -0,0 +1,93 @@
import arrow
import warnings
from datetime import datetime as dt
from marshmallow.fields import Field
from marshmallow import utils
class ArrowDateTime(Field):
"""A formatted datetime string in UTC.
Example: ``'2014-12-22T03:12:58.019077+00:00'``
Timezone-naive `datetime` objects are converted to
UTC (+00:00) by :meth:`Schema.dump <marshmallow.Schema.dump>`.
:meth:`Schema.load <marshmallow.Schema.load>` returns `datetime`
objects that are timezone-aware.
:param str format: Either ``"rfc"`` (for RFC822), ``"iso"`` (for ISO8601),
or a date format string. If `None`, defaults to "iso".
:param kwargs: The same keyword arguments that :class:`Field` receives.
"""
DATEFORMAT_SERIALIZATION_FUNCS = {
'iso': utils.isoformat,
'iso8601': utils.isoformat,
'rfc': utils.rfcformat,
'rfc822': utils.rfcformat,
}
DATEFORMAT_DESERIALIZATION_FUNCS = {
'iso': utils.from_iso,
'iso8601': utils.from_iso,
'rfc': utils.from_rfc,
'rfc822': utils.from_rfc,
}
DEFAULT_FORMAT = 'iso'
localtime = False
default_error_messages = {
'invalid': 'Not a valid datetime.',
'format': '"{input}" cannot be formatted as a datetime.',
}
def __init__(self, format=None, **kwargs):
super(ArrowDateTime, self).__init__(**kwargs)
# Allow this to be None. It may be set later in the ``_serialize``
# or ``_desrialize`` methods This allows a Schema to dynamically set the
# dateformat, e.g. from a Meta option
self.dateformat = format
def _add_to_schema(self, field_name, schema):
super(ArrowDateTime, self)._add_to_schema(field_name, schema)
self.dateformat = self.dateformat or schema.opts.dateformat
def _serialize(self, value, attr, obj):
if value is None:
return None
self.dateformat = self.dateformat or self.DEFAULT_FORMAT
format_func = self.DATEFORMAT_SERIALIZATION_FUNCS.get(self.dateformat, None)
if format_func:
try:
return format_func(value, localtime=self.localtime)
except (AttributeError, ValueError) as err:
self.fail('format', input=value)
else:
return value.strftime(self.dateformat)
def _deserialize(self, value, attr, data):
if not value: # Falsy values, e.g. '', None, [] are not valid
raise self.fail('invalid')
self.dateformat = self.dateformat or self.DEFAULT_FORMAT
func = self.DATEFORMAT_DESERIALIZATION_FUNCS.get(self.dateformat)
if func:
try:
return arrow.get(func(value))
except (TypeError, AttributeError, ValueError):
raise self.fail('invalid')
elif self.dateformat:
try:
return dt.datetime.strptime(value, self.dateformat)
except (TypeError, AttributeError, ValueError):
raise self.fail('invalid')
elif utils.dateutil_available:
try:
return arrow.get(utils.from_datestring(value))
except TypeError:
raise self.fail('invalid')
else:
warnings.warn('It is recommended that you install python-dateutil '
'for improved datetime deserialization.')
raise self.fail('invalid')

View File

@ -58,8 +58,8 @@ class InstanceManager(object):
results.append(cls()) results.append(cls())
else: else:
results.append(cls) results.append(cls)
except Exception: except Exception as e:
current_app.logger.exception('Unable to import %s', cls_path) current_app.logger.exception('Unable to import %s. Reason: %s', cls_path, e)
continue continue
self.cache = results self.cache = results

24
lemur/common/missing.py Normal file
View File

@ -0,0 +1,24 @@
import arrow
from flask import current_app
from lemur.common.utils import is_weekend
def convert_validity_years(data):
"""
Convert validity years to validity_start and validity_end
:param data:
:return:
"""
if data.get('validity_years'):
now = arrow.utcnow()
data['validity_start'] = now.isoformat()
end = now.replace(years=+int(data['validity_years']))
if not current_app.config.get('LEMUR_ALLOW_WEEKEND_EXPIRATION', True):
if is_weekend(end):
end = end.replace(days=-2)
data['validity_end'] = end.isoformat()
return data

153
lemur/common/schema.py Normal file
View File

@ -0,0 +1,153 @@
"""
.. module: lemur.common.schema
:platform: unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from functools import wraps
from flask import request, current_app
from sqlalchemy.orm.collections import InstrumentedList
from inflection import camelize, underscore
from marshmallow import Schema, post_dump, pre_load, pre_dump
class LemurSchema(Schema):
"""
Base schema from which all grouper schema's inherit
"""
__envelope__ = True
def under(self, data, many=None):
items = []
if many:
for i in data:
items.append(
{underscore(key): value for key, value in i.items()}
)
return items
return {
underscore(key): value
for key, value in data.items()
}
def camel(self, data, many=None):
items = []
if many:
for i in data:
items.append(
{camelize(key, uppercase_first_letter=False): value for key, value in i.items()}
)
return items
return {
camelize(key, uppercase_first_letter=False): value
for key, value in data.items()
}
def wrap_with_envelope(self, data, many):
if many:
if 'total' in self.context.keys():
return dict(total=self.context['total'], items=data)
return data
class LemurInputSchema(LemurSchema):
@pre_load(pass_many=True)
def preprocess(self, data, many):
return self.under(data, many=many)
class LemurOutputSchema(LemurSchema):
@pre_load(pass_many=True)
def preprocess(self, data, many):
if many:
data = self.unwrap_envelope(data, many)
return self.under(data, many=many)
@pre_dump(pass_many=True)
def unwrap_envelope(self, data, many):
if many:
if data:
if isinstance(data, InstrumentedList) or isinstance(data, list):
self.context['total'] = len(data)
return data
else:
self.context['total'] = data['total']
else:
self.context['total'] = 0
data = {'items': []}
return data['items']
return data
@post_dump(pass_many=True)
def post_process(self, data, many):
if data:
data = self.camel(data, many=many)
if self.__envelope__:
return self.wrap_with_envelope(data, many=many)
else:
return data
def format_errors(messages):
errors = {}
for k, v in messages.items():
key = camelize(k, uppercase_first_letter=False)
if isinstance(v, dict):
errors[key] = format_errors(v)
elif isinstance(v, list):
errors[key] = v[0]
return errors
def wrap_errors(messages):
errors = dict(message='Validation Error.')
if messages.get('_schema'):
errors['reasons'] = {'Schema': {'rule': messages['_schema']}}
else:
errors['reasons'] = format_errors(messages)
return errors
def validate_schema(input_schema, output_schema):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if input_schema:
if request.get_json():
request_data = request.get_json()
else:
request_data = request.args
data, errors = input_schema.load(request_data)
if errors:
return wrap_errors(errors), 400
kwargs['data'] = data
try:
resp = f(*args, **kwargs)
except Exception as e:
current_app.logger.exception(e)
return dict(message=str(e)), 500
if isinstance(resp, tuple):
return resp[0], resp[1]
if not resp:
return dict(message="No data found"), 404
if output_schema:
data = output_schema.dump(resp)
return data.data, 200
return resp, 200
return decorated_function
return decorator

View File

@ -6,15 +6,23 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import six
import sys
import string import string
import random import random
from functools import wraps
from flask import current_app from cryptography import x509
from cryptography.hazmat.backends import default_backend
from flask.ext.restful import marshal
from flask.ext.restful.reqparse import RequestParser from flask.ext.restful.reqparse import RequestParser
from flask.ext.sqlalchemy import Pagination
paginated_parser = RequestParser()
paginated_parser.add_argument('count', type=int, default=10, location='args')
paginated_parser.add_argument('page', type=int, default=1, location='args')
paginated_parser.add_argument('sortDir', type=str, dest='sort_dir', location='args')
paginated_parser.add_argument('sortBy', type=str, dest='sort_by', location='args')
paginated_parser.add_argument('filter', type=str, location='args')
def get_psuedo_random_string(): def get_psuedo_random_string():
@ -28,51 +36,22 @@ def get_psuedo_random_string():
return challenge return challenge
class marshal_items(object): def parse_certificate(body):
def __init__(self, fields, envelope=None): if sys.version_info[0] <= 2:
self.fields = fields return x509.load_pem_x509_certificate(bytes(body), default_backend())
self.envelop = envelope
def __call__(self, f): if isinstance(body, six.string_types):
def _filter_items(items): body = body.encode('utf-8')
filtered_items = []
for item in items:
filtered_items.append(marshal(item, self.fields))
return filtered_items
@wraps(f) return x509.load_pem_x509_certificate(body, default_backend())
def wrapper(*args, **kwargs):
try:
resp = f(*args, **kwargs)
# this is a bit weird way to handle non standard error codes returned from the marshaled function
if isinstance(resp, tuple):
return resp[0], resp[1]
if isinstance(resp, Pagination):
return {'items': _filter_items(resp.items), 'total': resp.total}
if isinstance(resp, list):
return {'items': _filter_items(resp), 'total': len(resp)}
return marshal(resp, self.fields)
except Exception as e:
current_app.logger.exception(e)
# this is a little weird hack to respect flask restful parsing errors on marshaled functions
if hasattr(e, 'code'):
if hasattr(e, 'data'):
return {'message': e.data['message']}, 400
else:
return {'message': {'exception': 'unknown'}}, 400
else:
return {'message': {'exception': str(e)}}, 400
return wrapper
paginated_parser = RequestParser() def is_weekend(date):
"""
Determines if a given date is on a weekend.
paginated_parser.add_argument('count', type=int, default=10, location='args') :param date:
paginated_parser.add_argument('page', type=int, default=1, location='args') :return:
paginated_parser.add_argument('sortDir', type=str, dest='sort_dir', location='args') """
paginated_parser.add_argument('sortBy', type=str, dest='sort_by', location='args') if date.weekday() > 5:
paginated_parser.add_argument('filter', type=str, location='args') return True

118
lemur/common/validators.py Normal file
View File

@ -0,0 +1,118 @@
import re
from flask import current_app
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from marshmallow.exceptions import ValidationError
from lemur.auth.permissions import SensitiveDomainPermission
from lemur.common.utils import parse_certificate, is_weekend
from lemur.domains import service as domain_service
def public_certificate(body):
"""
Determines if specified string is valid public certificate.
:param body:
:return:
"""
try:
parse_certificate(body)
except Exception:
raise ValidationError('Public certificate presented is not valid.')
def private_key(key):
"""
User to validate that a given string is a RSA private key
:param key:
:return: :raise ValueError:
"""
try:
if isinstance(key, bytes):
serialization.load_pem_private_key(key, None, backend=default_backend())
else:
serialization.load_pem_private_key(key.encode('utf-8'), None, backend=default_backend())
except Exception:
raise ValidationError('Private key presented is not valid.')
def sensitive_domain(domain):
"""
Determines if domain has been marked as sensitive.
:param domain:
:return:
"""
restricted_domains = current_app.config.get('LEMUR_RESTRICTED_DOMAINS', [])
if restricted_domains:
domains = domain_service.get_by_name(domain)
for domain in domains:
# we only care about non-admins
if not SensitiveDomainPermission().can():
if domain.sensitive or any([re.match(pattern, domain.name) for pattern in restricted_domains]):
raise ValidationError(
'Domain {0} has been marked as sensitive, contact and administrator \
to issue the certificate.'.format(domain))
def encoding(oid_encoding):
"""
Determines if the specified oid type is valid.
:param oid_encoding:
:return:
"""
valid_types = ['b64asn1', 'string', 'ia5string']
if oid_encoding.lower() not in [o_type.lower() for o_type in valid_types]:
raise ValidationError('Invalid Oid Encoding: {0} choose from {1}'.format(oid_encoding, ",".join(valid_types)))
def sub_alt_type(alt_type):
"""
Determines if the specified subject alternate type is valid.
:param alt_type:
:return:
"""
valid_types = ['DNSName', 'IPAddress', 'uniFormResourceIdentifier', 'directoryName', 'rfc822Name', 'registrationID',
'otherName', 'x400Address', 'EDIPartyName']
if alt_type.lower() not in [a_type.lower() for a_type in valid_types]:
raise ValidationError('Invalid SubAltName Type: {0} choose from {1}'.format(type, ",".join(valid_types)))
def csr(data):
"""
Determines if the CSR is valid.
:param data:
:return:
"""
try:
x509.load_pem_x509_csr(bytes(data), default_backend())
except Exception:
raise ValidationError('CSR presented is not valid.')
def dates(data):
if not data.get('validity_start') and data.get('validity_end'):
raise ValidationError('If validity start is specified so must validity end.')
if not data.get('validity_end') and data.get('validity_start'):
raise ValidationError('If validity end is specified so must validity start.')
if data.get('validity_start') and data.get('validity_end'):
if not current_app.config.get('LEMUR_ALLOW_WEEKEND_EXPIRATION', True):
if is_weekend(data.get('validity_end')):
raise ValidationError('Validity end must not land on a weekend.')
if not data['validity_start'] < data['validity_end']:
raise ValidationError('Validity start must be before validity end.')
if data.get('authority'):
if data.get('validity_start').date() < data['authority'].authority_certificate.not_before.date():
raise ValidationError('Validity start must not be before {0}'.format(data['authority'].authority_certificate.not_before))
if data.get('validity_end').date() > data['authority'].authority_certificate.not_after.date():
raise ValidationError('Validity end must not be after {0}'.format(data['authority'].authority_certificate.not_after))
return data

View File

@ -12,8 +12,6 @@
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
from sqlalchemy.orm.exc import NoResultFound
from lemur.extensions import db from lemur.extensions import db
from lemur.exceptions import AttrNotFound, DuplicateError from lemur.exceptions import AttrNotFound, DuplicateError
@ -125,10 +123,7 @@ def get(model, value, field="id"):
:return: :return:
""" """
query = session_query(model) query = session_query(model)
try: return query.filter(getattr(model, field) == value).scalar()
return query.filter(getattr(model, field) == value).one()
except NoResultFound as e:
return
def get_all(model, value, field="id"): def get_all(model, value, field="id"):
@ -239,9 +234,6 @@ def update_list(model, model_attr, item_model, items):
""" """
ids = [] ids = []
for i in items:
ids.append(i['id'])
for i in getattr(model, model_attr): for i in getattr(model, model_attr):
if i.id not in ids: if i.id not in ids:
getattr(model, model_attr).remove(i) getattr(model, model_attr).remove(i)
@ -287,4 +279,9 @@ def sort_and_page(query, model, args):
if sort_by and sort_dir: if sort_by and sort_dir:
query = sort(query, model, sort_by, sort_dir) query = sort(query, model, sort_by, sort_dir)
return paginate(query, page, count) total = query.count()
# offset calculated at zero
page -= 1
items = query.offset(count * page).limit(count).all()
return dict(items=items, total=total)

View File

@ -3,8 +3,6 @@
import os import os
_basedir = os.path.abspath(os.path.dirname(__file__)) _basedir = os.path.abspath(os.path.dirname(__file__))
ADMINS = frozenset([''])
THREADS_PER_PAGE = 8 THREADS_PER_PAGE = 8
# General # General

View File

@ -57,7 +57,8 @@ class LemurDefaults(AuthenticatedResource):
state=current_app.config.get('LEMUR_DEFAULT_STATE'), state=current_app.config.get('LEMUR_DEFAULT_STATE'),
location=current_app.config.get('LEMUR_DEFAULT_LOCATION'), location=current_app.config.get('LEMUR_DEFAULT_LOCATION'),
organization=current_app.config.get('LEMUR_DEFAULT_ORGANIZATION'), organization=current_app.config.get('LEMUR_DEFAULT_ORGANIZATION'),
organizationalUnit=current_app.config.get('LEMUR_DEFAULT_ORGANIZATIONAL_UNIT') organizationalUnit=current_app.config.get('LEMUR_DEFAULT_ORGANIZATIONAL_UNIT'),
issuerPlugin=current_app.config.get('LEMUR_DEFAULT_ISSUER_PLUGIN')
) )
api.add_resource(LemurDefaults, '/defaults', endpoint='default') api.add_resource(LemurDefaults, '/defaults', endpoint='default')

View File

@ -5,7 +5,6 @@
: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>
""" """
import copy
from sqlalchemy import Column, Integer, String, Text from sqlalchemy import Column, Integer, String, Text
from sqlalchemy_utils import JSONType from sqlalchemy_utils import JSONType
from lemur.database import db from lemur.database import db
@ -23,7 +22,7 @@ class Destination(db.Model):
@property @property
def plugin(self): def plugin(self):
p = plugins.get(self.plugin_name) return plugins.get(self.plugin_name)
c = copy.deepcopy(p)
c.options = self.options def __repr__(self):
return c return "Destination(label={label})".format(label=self.label)

View File

@ -0,0 +1,42 @@
"""
.. module: lemur.destinations.schemas
:platform: unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from marshmallow import fields, post_dump
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
from lemur.schemas import PluginInputSchema, PluginOutputSchema
class DestinationInputSchema(LemurInputSchema):
id = fields.Integer()
label = fields.String(required=True)
description = fields.String(required=True)
active = fields.Boolean()
plugin = fields.Nested(PluginInputSchema, required=True)
class DestinationOutputSchema(LemurOutputSchema):
id = fields.Integer()
label = fields.String()
description = fields.String()
active = fields.Boolean()
plugin = fields.Nested(PluginOutputSchema)
options = fields.List(fields.Dict())
@post_dump
def fill_object(self, data):
data['plugin']['pluginOptions'] = data['options']
return data
class DestinationNestedOutputSchema(DestinationOutputSchema):
__envelope__ = False
destination_input_schema = DestinationInputSchema()
destinations_output_schema = DestinationOutputSchema(many=True)
destination_output_schema = DestinationOutputSchema()

View File

@ -86,10 +86,6 @@ def get_all():
def render(args): def render(args):
sort_by = args.pop('sort_by')
sort_dir = args.pop('sort_dir')
page = args.pop('page')
count = args.pop('count')
filt = args.pop('filter') filt = args.pop('filter')
certificate_id = args.pop('certificate_id', None) certificate_id = args.pop('certificate_id', None)
@ -103,12 +99,7 @@ def render(args):
terms = filt.split(';') terms = filt.split(';')
query = database.filter(query, Destination, terms) query = database.filter(query, Destination, terms)
query = database.find_all(query, Destination, args) return database.sort_and_page(query, Destination, args)
if sort_by and sort_dir:
query = database.sort(query, Destination, sort_by, sort_dir)
return database.paginate(query, page, count)
def stats(**kwargs): def stats(**kwargs):

View File

@ -7,34 +7,28 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
from flask import Blueprint from flask import Blueprint
from flask.ext.restful import Api, reqparse, fields from flask.ext.restful import Api, reqparse
from lemur.destinations import service from lemur.destinations import service
from lemur.auth.service import AuthenticatedResource from lemur.auth.service import AuthenticatedResource
from lemur.auth.permissions import admin_permission from lemur.auth.permissions import admin_permission
from lemur.common.utils import paginated_parser, marshal_items from lemur.common.utils import paginated_parser
from lemur.common.schema import validate_schema
from lemur.destinations.schemas import destinations_output_schema, destination_input_schema, destination_output_schema
mod = Blueprint('destinations', __name__) mod = Blueprint('destinations', __name__)
api = Api(mod) api = Api(mod)
FIELDS = {
'description': fields.String,
'destinationOptions': fields.Raw(attribute='options'),
'pluginName': fields.String(attribute='plugin_name'),
'label': fields.String,
'id': fields.Integer,
}
class DestinationsList(AuthenticatedResource): class DestinationsList(AuthenticatedResource):
""" Defines the 'destinations' endpoint """ """ Defines the 'destinations' endpoint """
def __init__(self): def __init__(self):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(DestinationsList, self).__init__() super(DestinationsList, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, destinations_output_schema)
def get(self): def get(self):
""" """
.. http:get:: /destinations .. http:get:: /destinations
@ -58,24 +52,32 @@ class DestinationsList(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"items": [ "items": [{
{ "description": "test",
"destinationOptions": [ "options": [{
{ "name": "accountNumber",
"name": "accountNumber", "required": true,
"required": true, "value": "111111111111111",
"value": 111111111112, "helpMessage": "Must be a valid AWS account number!",
"helpMessage": "Must be a valid AWS account number!", "validation": "/^[0-9]{12,12}$/",
"validation": "/^[0-9]{12,12}$/", "type": "str"
"type": "int" }],
} "id": 4,
], "plugin": {
"pluginName": "aws-destination", "pluginOptions": [{
"id": 3, "name": "accountNumber",
"description": "test", "required": true,
"label": "test" "value": "111111111111111",
} "helpMessage": "Must be a valid AWS account number!",
], "validation": "/^[0-9]{12,12}$/",
"type": "str"
}],
"description": "Allow the uploading of certificates to AWS IAM",
"slug": "aws-destination",
"title": "AWS"
},
"label": "test546"
}
"total": 1 "total": 1
} }
@ -83,7 +85,7 @@ class DestinationsList(AuthenticatedResource):
:query sortDir: acs or desc :query sortDir: acs or desc
:query page: int. default is 1 :query page: int. default is 1
:query filter: key value pair format is k;v :query filter: key value pair format is k;v
:query limit: limit number default is 10 :query count: count number default is 10
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error :statuscode 200: no error
""" """
@ -92,8 +94,8 @@ class DestinationsList(AuthenticatedResource):
return service.render(args) return service.render(args)
@admin_permission.require(http_exception=403) @admin_permission.require(http_exception=403)
@marshal_items(FIELDS) @validate_schema(destination_input_schema, destination_output_schema)
def post(self): def post(self, data=None):
""" """
.. http:post:: /destinations .. http:post:: /destinations
@ -108,20 +110,30 @@ class DestinationsList(AuthenticatedResource):
Accept: application/json, text/javascript Accept: application/json, text/javascript
{ {
"destinationOptions": [ "description": "test33",
{ "options": [{
"name": "accountNumber",
"required": true,
"value": "34324324",
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "str"
}],
"id": 4,
"plugin": {
"pluginOptions": [{
"name": "accountNumber", "name": "accountNumber",
"required": true, "required": true,
"value": 111111111112, "value": "34324324",
"helpMessage": "Must be a valid AWS account number!", "helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/", "validation": "/^[0-9]{12,12}$/",
"type": "int" "type": "str"
} }],
], "description": "Allow the uploading of certificates to AWS IAM",
"pluginName": "aws-destination", "slug": "aws-destination",
"id": 3, "title": "AWS"
"description": "test", },
"label": "test" "label": "test546"
} }
**Example response**: **Example response**:
@ -133,20 +145,30 @@ class DestinationsList(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"destinationOptions": [ "description": "test33",
{ "options": [{
"name": "accountNumber",
"required": true,
"value": "34324324",
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "str"
}],
"id": 4,
"plugin": {
"pluginOptions": [{
"name": "accountNumber", "name": "accountNumber",
"required": true, "required": true,
"value": 111111111112, "value": "111111111111111",
"helpMessage": "Must be a valid AWS account number!", "helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/", "validation": "/^[0-9]{12,12}$/",
"type": "int" "type": "str"
} }],
], "description": "Allow the uploading of certificates to AWS IAM",
"pluginName": "aws-destination", "slug": "aws-destination",
"id": 3, "title": "AWS"
"description": "test", },
"label": "test" "label": "test546"
} }
:arg label: human readable account label :arg label: human readable account label
@ -154,12 +176,7 @@ class DestinationsList(AuthenticatedResource):
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error :statuscode 200: no error
""" """
self.reqparse.add_argument('label', type=str, location='json', required=True) return service.create(data['label'], data['plugin']['slug'], data['plugin']['plugin_options'], data['description'])
self.reqparse.add_argument('plugin', type=dict, location='json', required=True)
self.reqparse.add_argument('description', type=str, location='json')
args = self.reqparse.parse_args()
return service.create(args['label'], args['plugin']['slug'], args['plugin']['pluginOptions'], args['description'])
class Destinations(AuthenticatedResource): class Destinations(AuthenticatedResource):
@ -167,7 +184,7 @@ class Destinations(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(Destinations, self).__init__() super(Destinations, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, destination_output_schema)
def get(self, destination_id): def get(self, destination_id):
""" """
.. http:get:: /destinations/1 .. http:get:: /destinations/1
@ -191,20 +208,30 @@ class Destinations(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"destinationOptions": [ "description": "test",
{ "options": [{
"name": "accountNumber",
"required": true,
"value": "111111111111111",
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "str"
}],
"id": 4,
"plugin": {
"pluginOptions": [{
"name": "accountNumber", "name": "accountNumber",
"required": true, "required": true,
"value": 111111111112, "value": "111111111111111",
"helpMessage": "Must be a valid AWS account number!", "helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/", "validation": "/^[0-9]{12,12}$/",
"type": "int" "type": "str"
} }],
], "description": "Allow the uploading of certificates to AWS IAM",
"pluginName": "aws-destination", "slug": "aws-destination",
"id": 3, "title": "AWS"
"description": "test", },
"label": "test" "label": "test546"
} }
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
@ -213,8 +240,8 @@ class Destinations(AuthenticatedResource):
return service.get(destination_id) return service.get(destination_id)
@admin_permission.require(http_exception=403) @admin_permission.require(http_exception=403)
@marshal_items(FIELDS) @validate_schema(destination_input_schema, destination_output_schema)
def put(self, destination_id): def put(self, destination_id, data=None):
""" """
.. http:put:: /destinations/1 .. http:put:: /destinations/1
@ -228,23 +255,35 @@ class Destinations(AuthenticatedResource):
Host: example.com Host: example.com
Accept: application/json, text/javascript Accept: application/json, text/javascript
{ {
"destinationOptions": [ "description": "test33",
{ "options": [{
"name": "accountNumber",
"required": true,
"value": "34324324",
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "str"
}],
"id": 4,
"plugin": {
"pluginOptions": [{
"name": "accountNumber", "name": "accountNumber",
"required": true, "required": true,
"value": 111111111112, "value": "34324324",
"helpMessage": "Must be a valid AWS account number!", "helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/", "validation": "/^[0-9]{12,12}$/",
"type": "int" "type": "str"
} }],
], "description": "Allow the uploading of certificates to AWS IAM",
"pluginName": "aws-destination", "slug": "aws-destination",
"id": 3, "title": "AWS"
"description": "test", },
"label": "test" "label": "test546"
} }
**Example response**: **Example response**:
.. sourcecode:: http .. sourcecode:: http
@ -254,20 +293,30 @@ class Destinations(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"destinationOptions": [ "description": "test",
{ "options": [{
"name": "accountNumber",
"required": true,
"value": "111111111111111",
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "str"
}],
"id": 4,
"plugin": {
"pluginOptions": [{
"name": "accountNumber", "name": "accountNumber",
"required": true, "required": true,
"value": 111111111112, "value": "111111111111111",
"helpMessage": "Must be a valid AWS account number!", "helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/", "validation": "/^[0-9]{12,12}$/",
"type": "int" "type": "str"
} }],
], "description": "Allow the uploading of certificates to AWS IAM",
"pluginName": "aws-destination", "slug": "aws-destination",
"id": 3, "title": "AWS"
"description": "test", },
"label": "test" "label": "test546"
} }
:arg accountNumber: aws account number :arg accountNumber: aws account number
@ -276,12 +325,7 @@ class Destinations(AuthenticatedResource):
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error :statuscode 200: no error
""" """
self.reqparse.add_argument('label', type=str, location='json', required=True) return service.update(destination_id, data['label'], data['plugin']['plugin_options'], data['description'])
self.reqparse.add_argument('plugin', type=dict, location='json', required=True)
self.reqparse.add_argument('description', type=str, location='json')
args = self.reqparse.parse_args()
return service.update(destination_id, args['label'], args['plugin']['pluginOptions'], args['description'])
@admin_permission.require(http_exception=403) @admin_permission.require(http_exception=403)
def delete(self, destination_id): def delete(self, destination_id):
@ -294,7 +338,7 @@ class CertificateDestinations(AuthenticatedResource):
def __init__(self): def __init__(self):
super(CertificateDestinations, self).__init__() super(CertificateDestinations, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, destination_output_schema)
def get(self, certificate_id): def get(self, certificate_id):
""" """
.. http:get:: /certificates/1/destinations .. http:get:: /certificates/1/destinations
@ -318,24 +362,32 @@ class CertificateDestinations(AuthenticatedResource):
Content-Type: text/javascript Content-Type: text/javascript
{ {
"items": [ "items": [{
{ "description": "test",
"destinationOptions": [ "options": [{
{ "name": "accountNumber",
"name": "accountNumber", "required": true,
"required": true, "value": "111111111111111",
"value": 111111111112, "helpMessage": "Must be a valid AWS account number!",
"helpMessage": "Must be a valid AWS account number!", "validation": "/^[0-9]{12,12}$/",
"validation": "/^[0-9]{12,12}$/", "type": "str"
"type": "int" }],
} "id": 4,
], "plugin": {
"pluginName": "aws-destination", "pluginOptions": [{
"id": 3, "name": "accountNumber",
"description": "test", "required": true,
"label": "test" "value": "111111111111111",
} "helpMessage": "Must be a valid AWS account number!",
], "validation": "/^[0-9]{12,12}$/",
"type": "str"
}],
"description": "Allow the uploading of certificates to AWS IAM",
"slug": "aws-destination",
"title": "AWS"
},
"label": "test546"
}
"total": 1 "total": 1
} }
@ -343,7 +395,7 @@ class CertificateDestinations(AuthenticatedResource):
:query sortDir: acs or desc :query sortDir: acs or desc
:query page: int default is 1 :query page: int default is 1
:query filter: key value pair format is k;v :query filter: key value pair format is k;v
:query limit: limit number default is 10 :query count: count number default is 10
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error :statuscode 200: no error
""" """

View File

@ -17,3 +17,6 @@ class Domain(db.Model):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
name = Column(String(256)) name = Column(String(256))
sensitive = Column(Boolean, default=False) sensitive = Column(Boolean, default=False)
def __repr__(self):
return "Domain(name={name})".format(name=self.name)

35
lemur/domains/schemas.py Normal file
View File

@ -0,0 +1,35 @@
"""
.. module: lemur.domains.schemas
:platform: unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from marshmallow import fields
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
from lemur.schemas import AssociatedCertificateSchema
# from lemur.certificates.schemas import CertificateNestedOutputSchema
class DomainInputSchema(LemurInputSchema):
id = fields.Integer()
name = fields.String(required=True)
sensitive = fields.Boolean()
certificates = fields.Nested(AssociatedCertificateSchema, many=True, missing=[])
class DomainOutputSchema(LemurOutputSchema):
id = fields.Integer()
name = fields.String()
sensitive = fields.Boolean()
# certificates = fields.Nested(CertificateNestedOutputSchema, many=True, missing=[])
class DomainNestedOutputSchema(DomainOutputSchema):
__envelope__ = False
domain_input_schema = DomainInputSchema()
domain_output_schema = DomainOutputSchema()
domains_output_schema = DomainOutputSchema(many=True)

View File

@ -77,11 +77,6 @@ def render(args):
:return: :return:
""" """
query = database.session_query(Domain).join(Certificate, Domain.certificate) query = database.session_query(Domain).join(Certificate, Domain.certificate)
sort_by = args.pop('sort_by')
sort_dir = args.pop('sort_dir')
page = args.pop('page')
count = args.pop('count')
filt = args.pop('filter') filt = args.pop('filter')
certificate_id = args.pop('certificate_id', None) certificate_id = args.pop('certificate_id', None)
@ -92,9 +87,4 @@ def render(args):
if certificate_id: if certificate_id:
query = query.filter(Certificate.id == certificate_id) query = query.filter(Certificate.id == certificate_id)
query = database.find_all(query, Domain, args) return database.sort_and_page(query, Domain, args)
if sort_by and sort_dir:
query = database.sort(query, Domain, sort_by, sort_dir)
return database.paginate(query, page, count)

View File

@ -8,19 +8,16 @@
""" """
from flask import Blueprint from flask import Blueprint
from flask.ext.restful import reqparse, Api, fields from flask.ext.restful import reqparse, Api
from lemur.domains import service from lemur.domains import service
from lemur.auth.service import AuthenticatedResource from lemur.auth.service import AuthenticatedResource
from lemur.auth.permissions import SensitiveDomainPermission from lemur.auth.permissions import SensitiveDomainPermission
from lemur.common.utils import paginated_parser, marshal_items from lemur.common.schema import validate_schema
from lemur.common.utils import paginated_parser
FIELDS = { from lemur.domains.schemas import domain_input_schema, domain_output_schema, domains_output_schema
'id': fields.Integer,
'name': fields.String,
'sensitive': fields.Boolean
}
mod = Blueprint('domains', __name__) mod = Blueprint('domains', __name__)
api = Api(mod) api = Api(mod)
@ -31,7 +28,7 @@ class DomainsList(AuthenticatedResource):
def __init__(self): def __init__(self):
super(DomainsList, self).__init__() super(DomainsList, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, domains_output_schema)
def get(self): def get(self):
""" """
.. http:get:: /domains .. http:get:: /domains
@ -74,7 +71,7 @@ class DomainsList(AuthenticatedResource):
:query sortDir: acs or desc :query sortDir: acs or desc
:query page: int default is 1 :query page: int default is 1
:query filter: key value pair format is k;v :query filter: key value pair format is k;v
:query limit: limit number. default is 10 :query count: count number. default is 10
: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
@ -83,8 +80,8 @@ class DomainsList(AuthenticatedResource):
args = parser.parse_args() args = parser.parse_args()
return service.render(args) return service.render(args)
@marshal_items(FIELDS) @validate_schema(domain_input_schema, domain_output_schema)
def post(self): def post(self, data=None):
""" """
.. http:post:: /domains .. http:post:: /domains
@ -121,15 +118,12 @@ class DomainsList(AuthenticatedResource):
:query sortDir: acs or desc :query sortDir: acs or desc
:query page: int default is 1 :query page: int default is 1
:query filter: key value pair format is k;v :query filter: key value pair format is k;v
:query limit: limit number default is 10 :query count: count number default is 10
: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
""" """
self.reqparse.add_argument('name', type=str, location='json') return service.create(data['name'], data['sensitive'])
self.reqparse.add_argument('sensitive', type=bool, default=False, location='json')
args = self.reqparse.parse_args()
return service.create(args['name'], args['sensitive'])
class Domains(AuthenticatedResource): class Domains(AuthenticatedResource):
@ -137,7 +131,7 @@ class Domains(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(Domains, self).__init__() super(Domains, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, domain_output_schema)
def get(self, domain_id): def get(self, domain_id):
""" """
.. http:get:: /domains/1 .. http:get:: /domains/1
@ -172,8 +166,8 @@ class Domains(AuthenticatedResource):
""" """
return service.get(domain_id) return service.get(domain_id)
@marshal_items(FIELDS) @validate_schema(domain_input_schema, domain_output_schema)
def put(self, domain_id): def put(self, domain_id, data=None):
""" """
.. http:get:: /domains/1 .. http:get:: /domains/1
@ -210,12 +204,8 @@ class Domains(AuthenticatedResource):
:statuscode 200: no error :statuscode 200: no error
:statuscode 403: unauthenticated :statuscode 403: unauthenticated
""" """
self.reqparse.add_argument('name', type=str, location='json')
self.reqparse.add_argument('sensitive', type=bool, default=False, location='json')
args = self.reqparse.parse_args()
if SensitiveDomainPermission().can(): if SensitiveDomainPermission().can():
return service.update(domain_id, args['name'], args['sensitive']) return service.update(domain_id, data['name'], data['sensitive'])
return dict(message='You are not authorized to modify this domain'), 403 return dict(message='You are not authorized to modify this domain'), 403
@ -225,7 +215,7 @@ class CertificateDomains(AuthenticatedResource):
def __init__(self): def __init__(self):
super(CertificateDomains, self).__init__() super(CertificateDomains, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, domains_output_schema)
def get(self, certificate_id): def get(self, certificate_id):
""" """
.. http:get:: /certificates/1/domains .. http:get:: /certificates/1/domains
@ -268,7 +258,7 @@ class CertificateDomains(AuthenticatedResource):
:query sortDir: acs or desc :query sortDir: acs or desc
:query page: int default is 1 :query page: int default is 1
:query filter: key value pair format is k;v :query filter: key value pair format is k;v
:query limit: limit number default is 10 :query count: count number default is 10
: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

View File

86
lemur/endpoints/models.py Normal file
View File

@ -0,0 +1,86 @@
"""
.. module: lemur.endpoints.models
:platform: unix
:synopsis: This module contains all of the models need to create a authority within Lemur.
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from sqlalchemy.orm import relationship
from sqlalchemy import Column, Integer, String, func, DateTime, PassiveDefault, Boolean, ForeignKey
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.sql.expression import case
from lemur.database import db
from lemur.models import policies_ciphers
BAD_CIPHERS = [
'Protocol-SSLv3',
'Protocol-SSLv2',
'Protocol-TLSv1'
]
class Cipher(db.Model):
__tablename__ = 'ciphers'
id = Column(Integer, primary_key=True)
name = Column(String(128), nullable=False)
@hybrid_property
def deprecated(self):
return self.name in BAD_CIPHERS
@deprecated.expression
def deprecated(cls):
return case(
[
(cls.name in BAD_CIPHERS, True)
],
else_=False
)
class Policy(db.Model):
___tablename__ = 'policies'
id = Column(Integer, primary_key=True)
name = Column(String(128), nullable=True)
ciphers = relationship('Cipher', secondary=policies_ciphers, backref='policy')
class Endpoint(db.Model):
__tablename__ = 'endpoints'
id = Column(Integer, primary_key=True)
owner = Column(String(128))
name = Column(String(128))
dnsname = Column(String(256))
type = Column(String(128))
active = Column(Boolean, default=True)
port = Column(Integer)
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
policy_id = Column(Integer, ForeignKey('policy.id'))
policy = relationship('Policy', backref='endpoint')
certificate_id = Column(Integer, ForeignKey('certificates.id'))
source_id = Column(Integer, ForeignKey('sources.id'))
sensitive = Column(Boolean, default=False)
source = relationship('Source', back_populates='endpoints')
@property
def issues(self):
issues = []
for cipher in self.policy.ciphers:
if cipher.deprecated:
issues.append({'name': 'deprecated cipher', 'value': '{0} has been deprecated consider removing it.'.format(cipher.name)})
if self.certificate.expired:
issues.append({'name': 'expired certificate', 'value': 'There is an expired certificate attached to this endpoint consider replacing it.'})
if self.certificate.revoked:
issues.append({'name': 'revoked', 'value': 'There is a revoked certificate attached to this endpoint consider replacing it.'})
return issues
def __repr__(self):
return "Endpoint(name={name})".format(name=self.name)

View File

@ -0,0 +1,43 @@
"""
.. module: lemur.endpoints.schemas
:platform: unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from marshmallow import fields
from lemur.common.schema import LemurOutputSchema
from lemur.certificates.schemas import CertificateNestedOutputSchema
class CipherNestedOutputSchema(LemurOutputSchema):
__envelope__ = False
id = fields.Integer()
deprecated = fields.Boolean()
name = fields.String()
class PolicyNestedOutputSchema(LemurOutputSchema):
__envelope__ = False
id = fields.Integer()
name = fields.String()
ciphers = fields.Nested(CipherNestedOutputSchema, many=True)
class EndpointOutputSchema(LemurOutputSchema):
id = fields.Integer()
description = fields.String()
name = fields.String()
dnsname = fields.String()
owner = fields.Email()
type = fields.String()
port = fields.Integer()
active = fields.Boolean()
certificate = fields.Nested(CertificateNestedOutputSchema)
policy = fields.Nested(PolicyNestedOutputSchema)
issues = fields.List(fields.Dict())
endpoint_output_schema = EndpointOutputSchema()
endpoints_output_schema = EndpointOutputSchema(many=True)

143
lemur/endpoints/service.py Normal file
View File

@ -0,0 +1,143 @@
"""
.. module: lemur.endpoints.service
:platform: Unix
:synopsis: This module contains all of the services level functions used to
administer endpoints in Lemur
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from lemur import database
from lemur.extensions import metrics
from lemur.endpoints.models import Endpoint, Policy, Cipher
from sqlalchemy import func
def get_all():
"""
Get all endpoints that are currently in Lemur.
:rtype : List
:return:
"""
query = database.session_query(Endpoint)
return database.find_all(query, Endpoint, {}).all()
def get(endpoint_id):
"""
Retrieves an endpoint given it's ID
:param endpoint_id:
:return:
"""
return database.get(Endpoint, endpoint_id)
def get_by_dnsname(endpoint_dnsname):
"""
Retrieves an endpoint given it's name.
:param endpoint_dnsname:
:return:
"""
return database.get(Endpoint, endpoint_dnsname, field='dnsname')
def get_by_source(source_label):
"""
Retrieves all endpoints for a given source.
:param source_label:
:return:
"""
return Endpoint.query.filter(Endpoint.source.label == source_label).all() # noqa
def create(**kwargs):
"""
Creates a new endpoint.
:param kwargs:
:return:
"""
endpoint = Endpoint(**kwargs)
database.create(endpoint)
metrics.send('endpoint_added', 'counter', 1)
return endpoint
def get_or_create_policy(**kwargs):
policy = database.get(Policy, kwargs['name'], field='name')
if not policy:
policy = Policy(**kwargs)
database.create(policy)
return policy
def get_or_create_cipher(**kwargs):
cipher = database.get(Cipher, kwargs['name'], field='name')
if not cipher:
cipher = Cipher(**kwargs)
database.create(cipher)
return cipher
def update(endpoint_id, **kwargs):
endpoint = database.get(Endpoint, endpoint_id)
endpoint.policy = kwargs['policy']
endpoint.certificate = kwargs['certificate']
database.update(endpoint)
return endpoint
def render(args):
"""
Helper that helps us render the REST Api responses.
:param args:
:return:
"""
query = database.session_query(Endpoint)
filt = args.pop('filter')
if filt:
terms = filt.split(';')
if 'active' in filt: # this is really weird but strcmp seems to not work here??
query = query.filter(Endpoint.active == terms[1])
elif 'port' in filt:
if terms[1] != 'null': # ng-table adds 'null' if a number is removed
query = query.filter(Endpoint.port == terms[1])
elif 'ciphers' in filt:
query = query.filter(
Cipher.name == terms[1]
)
else:
query = database.filter(query, Endpoint, terms)
return database.sort_and_page(query, Endpoint, args)
def stats(**kwargs):
"""
Helper that defines some useful statistics about endpoints.
:param kwargs:
:return:
"""
attr = getattr(Endpoint, kwargs.get('metric'))
query = database.db.session.query(attr, func.count(attr))
items = query.group_by(attr).all()
keys = []
values = []
for key, count in items:
keys.append(key)
values.append(count)
return {'labels': keys, 'values': values}

106
lemur/endpoints/views.py Normal file
View File

@ -0,0 +1,106 @@
"""
.. module: lemur.endpoints.views
:platform: Unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from flask import Blueprint
from flask.ext.restful import reqparse, Api
from lemur.common.utils import paginated_parser
from lemur.common.schema import validate_schema
from lemur.auth.service import AuthenticatedResource
from lemur.endpoints import service
from lemur.endpoints.schemas import endpoint_output_schema, endpoints_output_schema
mod = Blueprint('endpoints', __name__)
api = Api(mod)
class EndpointsList(AuthenticatedResource):
""" Defines the 'endpoints' endpoint """
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(EndpointsList, self).__init__()
@validate_schema(None, endpoints_output_schema)
def get(self):
"""
.. http:get:: /endpoints
The current list of endpoints
**Example request**:
.. sourcecode:: http
GET /endpoints 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
:query sortBy: field to sort on
:query sortDir: acs or desc
:query page: int default is 1
:query filter: key value pair. format is k;v
:query limit: limit number default is 10
:reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error
:statuscode 403: unauthenticated
:note: this will only show certificates that the current user is authorized to use
"""
parser = paginated_parser.copy()
args = parser.parse_args()
return service.render(args)
class Endpoints(AuthenticatedResource):
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(Endpoints, self).__init__()
@validate_schema(None, endpoint_output_schema)
def get(self, endpoint_id):
"""
.. http:get:: /endpoints/1
One endpoint
**Example request**:
.. sourcecode:: http
GET /endpoints/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
:reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error
:statuscode 403: unauthenticated
"""
return service.get(endpoint_id)
api.add_resource(EndpointsList, '/endpoints', endpoint='endpoints')
api.add_resource(Endpoints, '/endpoints/<int:endpoint_id>', endpoint='endpoint')

View File

@ -36,6 +36,14 @@ class IntegrityError(LemurException):
return repr(self.message) return repr(self.message)
class AssociatedObjectNotFound(LemurException):
def __init__(self, message):
self.message = message
def __str__(self):
return repr(self.message)
class InvalidListener(LemurException): class InvalidListener(LemurException):
def __str__(self): def __str__(self):
return repr("Invalid listener, ensure you select a certificate if you are using a secure protocol") return repr("Invalid listener, ensure you select a certificate if you are using a secure protocol")

View File

@ -3,17 +3,20 @@
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details. :license: Apache, see LICENSE for more details.
""" """
from flask.ext.sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy() db = SQLAlchemy()
from flask.ext.migrate import Migrate from flask_migrate import Migrate
migrate = Migrate() migrate = Migrate()
from flask.ext.bcrypt import Bcrypt from flask_bcrypt import Bcrypt
bcrypt = Bcrypt() bcrypt = Bcrypt()
from flask.ext.principal import Principal from flask_principal import Principal
principal = Principal() principal = Principal()
from flask_mail import Mail from flask_mail import Mail
smtp_mail = Mail() smtp_mail = Mail()
from lemur.metrics import Metrics
metrics = Metrics()

View File

@ -14,12 +14,12 @@ import imp
import errno import errno
import pkg_resources import pkg_resources
from logging import Formatter 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.common.health import mod as health from lemur.common.health import mod as health
from lemur.extensions import db, migrate, principal, smtp_mail from lemur.extensions import db, migrate, principal, smtp_mail, metrics
DEFAULT_BLUEPRINTS = ( DEFAULT_BLUEPRINTS = (
@ -90,12 +90,15 @@ def configure_app(app, config=None):
:param config: :param config:
:return: :return:
""" """
# respect the config first
if config and config != 'None':
app.config.from_object(from_file(config))
try: try:
app.config.from_envvar("LEMUR_CONF") app.config.from_envvar("LEMUR_CONF")
except RuntimeError: except RuntimeError:
if config and config != 'None': # look in default paths
app.config.from_object(from_file(config)) if os.path.isfile(os.path.expanduser("~/.lemur/lemur.conf.py")):
elif os.path.isfile(os.path.expanduser("~/.lemur/lemur.conf.py")):
app.config.from_object(from_file(os.path.expanduser("~/.lemur/lemur.conf.py"))) app.config.from_object(from_file(os.path.expanduser("~/.lemur/lemur.conf.py")))
else: else:
app.config.from_object(from_file(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'default.conf.py'))) app.config.from_object(from_file(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'default.conf.py')))
@ -112,6 +115,7 @@ def configure_extensions(app):
migrate.init_app(app, db) migrate.init_app(app, db)
principal.init_app(app) principal.init_app(app)
smtp_mail.init_app(app) smtp_mail.init_app(app)
metrics.init_app(app)
def configure_blueprints(app, blueprints): def configure_blueprints(app, blueprints):
@ -143,14 +147,19 @@ def configure_logging(app):
app.logger.setLevel(app.config.get('LOG_LEVEL', 'DEBUG')) app.logger.setLevel(app.config.get('LOG_LEVEL', 'DEBUG'))
app.logger.addHandler(handler) app.logger.addHandler(handler)
stream_handler = StreamHandler()
stream_handler.setLevel(app.config.get('LOG_LEVEL'))
app.logger.addHandler(stream_handler)
def install_plugins(app): def install_plugins(app):
""" """
Installs new issuers that are not currently bundled with Lemur. Installs new issuers that are not currently bundled with Lemur.
:param settings: :param app:
:return: :return:
""" """
from lemur.plugins import plugins
from lemur.plugins.base import register from lemur.plugins.base import register
# entry_points={ # entry_points={
# 'lemur.plugins': [ # 'lemur.plugins': [
@ -165,3 +174,11 @@ def install_plugins(app):
app.logger.error("Failed to load plugin %r:\n%s\n" % (ep.name, traceback.format_exc())) app.logger.error("Failed to load plugin %r:\n%s\n" % (ep.name, traceback.format_exc()))
else: else:
register(plugin) register(plugin)
# ensure that we have some way to notify
with app.app_context():
try:
slug = app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification")
plugins.get(slug)
except KeyError:
raise Exception("Unable to location notification plugin: {slug}. Ensure that LEMUR_DEFAULT_NOTIFICATION_PLUGIN is set to a valid and installed notification plugin.".format(slug=slug))

View File

@ -1,36 +1,39 @@
from __future__ import unicode_literals # at top of module from __future__ import unicode_literals # at top of module
from datetime import datetime, timedelta
from collections import Counter
import os import os
import sys import sys
import base64 import base64
import time import time
import arrow
import requests import requests
import json import json
from tabulate import tabulate
from gunicorn.config import make_settings from gunicorn.config import make_settings
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from lockfile import LockFile, LockTimeout
from flask import current_app from flask import current_app
from flask.ext.script import Manager, Command, Option, prompt_pass from flask.ext.script import Manager, Command, Option, prompt_pass
from flask.ext.migrate import Migrate, MigrateCommand, stamp from flask.ext.migrate import Migrate, MigrateCommand, stamp
from flask_script.commands import ShowUrls, Clean, Server from flask_script.commands import ShowUrls, Clean, Server
from lemur import database from lemur import database
from lemur.extensions import metrics
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.certificates import service as cert_service from lemur.certificates import service as cert_service
from lemur.sources import service as source_service from lemur.authorities import service as authority_service
from lemur.notifications import service as notification_service from lemur.notifications import service as notification_service
from lemur.certificates.models import get_name_from_arn from lemur.certificates.service import get_name_from_arn
from lemur.certificates.verify import verify_string from lemur.certificates.verify import verify_string
from lemur.plugins.lemur_aws import elb from lemur.plugins.lemur_aws import elb
from lemur.sources.service import sync from lemur.sources import service as source_service
from lemur import create_app from lemur import create_app
@ -62,8 +65,6 @@ CONFIG_TEMPLATE = """
import os import os
_basedir = os.path.abspath(os.path.dirname(__file__)) _basedir = os.path.abspath(os.path.dirname(__file__))
ADMINS = frozenset([''])
THREADS_PER_PAGE = 8 THREADS_PER_PAGE = 8
# General # General
@ -188,58 +189,6 @@ def generate_settings():
return output return output
@manager.option('-s', '--sources', dest='labels')
def sync_sources(labels):
"""
Attempts to run several methods Certificate discovery. This is
run on a periodic basis and updates the Lemur datastore with the
information it discovers.
"""
if not labels:
sys.stdout.write("Active\tLabel\tDescription\n")
for source in source_service.get_all():
sys.stdout.write(
"{active}\t{label}\t{description}!\n".format(
label=source.label,
description=source.description,
active=source.active
)
)
else:
start_time = time.time()
lock_file = "/tmp/.lemur_lock"
sync_lock = LockFile(lock_file)
while not sync_lock.i_am_locking():
try:
sync_lock.acquire(timeout=10) # wait up to 10 seconds
sys.stdout.write("[+] Staring to sync sources: {labels}!\n".format(labels=labels))
labels = labels.split(",")
if labels[0] == 'all':
sync()
else:
sync(labels=labels)
sys.stdout.write(
"[+] Finished syncing sources. Run Time: {time}\n".format(
time=(time.time() - start_time)
)
)
except LockTimeout:
sys.stderr.write(
"[!] Unable to acquire file lock on {file}, is there another sync running?\n".format(
file=lock_file
)
)
sync_lock.break_lock()
sync_lock.acquire()
sync_lock.release()
sync_lock.release()
@manager.command @manager.command
def notify(): def notify():
""" """
@ -317,7 +266,7 @@ class InitializeApp(Command):
class CreateUser(Command): class CreateUser(Command):
""" """
This command allows for the creation of a new user within Lemur This command allows for the creation of a new user within Lemur.
""" """
option_list = ( option_list = (
Option('-u', '--username', dest='username', required=True), Option('-u', '--username', dest='username', required=True),
@ -333,18 +282,46 @@ class CreateUser(Command):
if role_obj: if role_obj:
role_objs.append(role_obj) role_objs.append(role_obj)
else: else:
sys.stderr.write("[!] Cannot find role {0}".format(r)) sys.stderr.write("[!] Cannot find role {0}\n".format(r))
sys.exit(1) sys.exit(1)
password1 = prompt_pass("Password") password1 = prompt_pass("Password")
password2 = prompt_pass("Confirm Password") password2 = prompt_pass("Confirm Password")
if password1 != password2: if password1 != password2:
sys.stderr.write("[!] Passwords do not match") sys.stderr.write("[!] Passwords do not match!\n")
sys.exit(1) sys.exit(1)
user_service.create(username, password1, email, active, None, role_objs) user_service.create(username, password1, email, active, None, role_objs)
sys.stdout.write("[+] Created new user: {0}".format(username)) sys.stdout.write("[+] Created new user: {0}\n".format(username))
class ResetPassword(Command):
"""
This command allows you to reset a user's password.
"""
option_list = (
Option('-u', '--username', dest='username', required=True),
)
def run(self, username):
user = user_service.get_by_username(username)
if not user:
sys.stderr.write("[!] No user found for username: {0}\n".format(username))
sys.exit(1)
sys.stderr.write("[+] Resetting password for {0}\n".format(username))
password1 = prompt_pass("Password")
password2 = prompt_pass("Confirm Password")
if password1 != password2:
sys.stderr.write("[!] Passwords do not match\n")
sys.exit(1)
user.password = password1
user.hash_password()
database.commit()
class CreateRole(Command): class CreateRole(Command):
@ -391,7 +368,7 @@ class LemurServer(Command):
settings = make_settings() settings = make_settings()
options = ( options = (
Option(*klass.cli, action=klass.action) Option(*klass.cli, action=klass.action)
for setting, klass in settings.iteritems() if klass.cli for setting, klass in settings.items() if klass.cli
) )
return options return options
@ -791,60 +768,180 @@ def publish_verisign_units():
requests.post('http://localhost:8078/metrics', data=json.dumps(metric)) requests.post('http://localhost:8078/metrics', data=json.dumps(metric))
class Rolling(Command): @manager.command
def publish_unapproved_verisign_certificates():
""" """
Rotates existing certificates to a new one on an ELB Query the Verisign for any certificates that need to be approved.
:return:
"""
from lemur.plugins import plugins
from lemur.extensions import metrics
v = plugins.get('verisign-issuer')
certs = v.get_pending_certificates()
metrics.send('pending_certificates', 'gauge', certs)
class Report(Command):
"""
Defines a set of reports to be run periodically against Lemur.
""" """
option_list = ( option_list = (
Option('-w', '--window', dest='window', default=24), Option('-n', '--name', dest='name', default=None, help='Name of the report to run.'),
Option('-d', '--duration', dest='duration', default=356, help='Number of days to run the report'),
) )
def run(self, window): def run(self, name, duration):
end = datetime.utcnow()
start = end - timedelta(days=duration)
self.certificates_issued(name, start, end)
@staticmethod
def certificates_issued(name=None, start=None, end=None):
""" """
Simple function that queries verisign for API units and posts the mertics to Generates simple report of number of certificates issued by the authority, if no authority
Atlas API for other teams to consume. is specified report on total number of certificates.
:param name:
:param start:
:param end:
:return: :return:
""" """
end = arrow.utcnow()
start = end.replace(hours=-window)
items = Certificate.query.filter(Certificate.not_before <= end.format('YYYY-MM-DD')) \
.filter(Certificate.not_before >= start.format('YYYY-MM-DD')).all()
metrics = {} def _calculate_row(authority):
for i in items: day_cnt = Counter()
name = "{0},{1}".format(i.owner, i.issuer) month_cnt = Counter()
if metrics.get(name): year_cnt = Counter()
metrics[name] += 1
else:
metrics[name] = 1
for name, value in metrics.iteritems(): for cert in authority.certificates:
owner, issuer = name.split(",") date = cert.date_created.date()
metric = [ day_cnt[date.day] += 1
{ month_cnt[date.month] += 1
"timestamp": 1321351651, year_cnt[date.year] += 1
"type": "GAUGE",
"name": "Issued Certificates",
"tags": {"owner": owner, "issuer": issuer, "window": window},
"value": value
}
]
requests.post('http://localhost:8078/metrics', data=json.dumps(metric)) try:
day_avg = int(sum(day_cnt.values()) / len(day_cnt.keys()))
except ZeroDivisionError:
day_avg = 0
try:
month_avg = int(sum(month_cnt.values()) / len(month_cnt.keys()))
except ZeroDivisionError:
month_avg = 0
try:
year_avg = int(sum(year_cnt.values()) / len(year_cnt.keys()))
except ZeroDivisionError:
year_avg = 0
return [authority.name, authority.description, day_avg, month_avg, year_avg]
rows = []
if not name:
for authority in authority_service.get_all():
rows.append(_calculate_row(authority))
else:
authority = authority_service.get_by_name(name)
if not authority:
sys.stderr.write('[!] Authority {0} was not found.'.format(name))
sys.exit(1)
rows.append(_calculate_row(authority))
sys.stdout.write(tabulate(rows, headers=["Authority Name", "Description", "Daily Average", "Monthy Average", "Yearly Average"]) + "\n")
class Sources(Command):
"""
Defines a set of actions to take against Lemur's sources.
"""
option_list = (
Option('-s', '--sources', dest='source_strings', action='append', help='Sources to operate on.'),
Option('-a', '--action', choices=['sync', 'clean'], dest='action', help='Action to take on source.')
)
def run(self, source_strings, action):
sources = []
if not source_strings:
table = []
for source in source_service.get_all():
table.append([source.label, source.active, source.description])
sys.stdout.write(tabulate(table, headers=['Label', 'Active', 'Description']))
sys.exit(1)
elif 'all' in source_strings:
sources = source_service.get_all()
else:
for source_str in source_strings:
source = source_service.get_by_label(source_str)
if not source:
sys.stderr.write("Unable to find specified source with label: {0}".format(source_str))
sources.append(source)
for source in sources:
if action == 'sync':
self.sync(source)
if action == 'clean':
self.clean(source)
@staticmethod
def sync(source):
start_time = time.time()
sys.stdout.write("[+] Staring to sync source: {label}!\n".format(label=source.label))
user = user_service.get_by_username('lemur')
try:
source_service.sync(source, user)
sys.stdout.write(
"[+] Finished syncing source: {label}. Run Time: {time}\n".format(
label=source.label,
time=(time.time() - start_time)
)
)
except Exception as e:
current_app.logger.exception(e)
sys.stdout.write(
"[X] Failed syncing source {label}!\n".format(label=source.label)
)
metrics.send('sync_failed', 'counter', 1, metric_tags={'source': source.label})
@staticmethod
def clean(source):
start_time = time.time()
sys.stdout.write("[+] Staring to clean source: {label}!\n".format(label=source.label))
source_service.clean(source)
sys.stdout.write(
"[+] Finished cleaning source: {label}. Run Time: {time}\n".format(
label=source.label,
time=(time.time() - start_time)
)
)
def main(): def main():
manager.add_command("start", LemurServer()) manager.add_command("start", LemurServer())
manager.add_command("runserver", Server(host='127.0.0.1')) manager.add_command("runserver", Server(host='127.0.0.1', threaded=True))
manager.add_command("clean", Clean()) manager.add_command("clean", Clean())
manager.add_command("show_urls", ShowUrls()) manager.add_command("show_urls", ShowUrls())
manager.add_command("db", MigrateCommand) manager.add_command("db", MigrateCommand)
manager.add_command("init", InitializeApp()) manager.add_command("init", InitializeApp())
manager.add_command("create_user", CreateUser()) manager.add_command("create_user", CreateUser())
manager.add_command("reset_password", ResetPassword())
manager.add_command("create_role", CreateRole()) manager.add_command("create_role", CreateRole())
manager.add_command("provision_elb", ProvisionELB()) manager.add_command("provision_elb", ProvisionELB())
manager.add_command("rotate_elbs", RotateELBs()) manager.add_command("rotate_elbs", RotateELBs())
manager.add_command("rolling", Rolling()) manager.add_command("sources", Sources())
manager.add_command("report", Report())
manager.run() manager.run()
if __name__ == "__main__": if __name__ == "__main__":

32
lemur/metrics.py Normal file
View File

@ -0,0 +1,32 @@
"""
.. module: lemur.metrics
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
"""
from flask import current_app
from lemur.plugins.base import plugins
class Metrics(object):
"""
:param app: The Flask application object. Defaults to None.
"""
_providers = []
def __init__(self, app=None):
if app is not None:
self.init_app(app)
def init_app(self, app):
"""Initializes the application with the extension.
:param app: The Flask application object.
"""
self._providers = app.config.get('METRIC_PROVIDERS', [])
def send(self, metric_name, metric_type, metric_value, *args, **kwargs):
for provider in self._providers:
current_app.logger.debug(
"Sending metric '{metric}' to the {provider} provider.".format(metric=metric_name, provider=provider))
p = plugins.get(provider)
p.submit(metric_name, metric_type, metric_value, *args, **kwargs)

View File

@ -0,0 +1,62 @@
"""Adding endpoint tables
Revision ID: 29d8c8455c86
Revises: 3307381f3b88
Create Date: 2016-06-28 16:05:25.720213
"""
# revision identifiers, used by Alembic.
revision = '29d8c8455c86'
down_revision = '3307381f3b88'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('ciphers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('policy',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('policies_ciphers',
sa.Column('cipher_id', sa.Integer(), nullable=True),
sa.Column('policy_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['cipher_id'], ['ciphers.id'], ),
sa.ForeignKeyConstraint(['policy_id'], ['policy.id'], )
)
op.create_index('policies_ciphers_ix', 'policies_ciphers', ['cipher_id', 'policy_id'], unique=False)
op.create_table('endpoints',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('owner', sa.String(length=128), nullable=True),
sa.Column('name', sa.String(length=128), nullable=True),
sa.Column('dnsname', sa.String(length=256), nullable=True),
sa.Column('type', sa.String(length=128), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
sa.Column('port', sa.Integer(), nullable=True),
sa.Column('date_created', sa.DateTime(), server_default=sa.text(u'now()'), nullable=False),
sa.Column('policy_id', sa.Integer(), nullable=True),
sa.Column('certificate_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ),
sa.ForeignKeyConstraint(['policy_id'], ['policy.id'], ),
sa.PrimaryKeyConstraint('id')
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('endpoints')
op.drop_index('policies_ciphers_ix', table_name='policies_ciphers')
op.drop_table('policies_ciphers')
op.drop_table('policy')
op.drop_table('ciphers')
### end Alembic commands ###

View File

@ -0,0 +1,131 @@
"""
Refactor authority columns and associates an authorities root certificate with a certificate stored in the
certificate tables.
Migrates existing authority owners to associated roles.
Migrates existing certificate owners to associated role.
Revision ID: 3307381f3b88
Revises: 412b22cb656a
Create Date: 2016-05-20 17:33:04.360687
"""
# revision identifiers, used by Alembic.
revision = '3307381f3b88'
down_revision = '412b22cb656a'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import text
from sqlalchemy.dialects import postgresql
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.alter_column('authorities', 'owner',
existing_type=sa.VARCHAR(length=128),
nullable=True)
op.drop_column('authorities', 'not_after')
op.drop_column('authorities', 'bits')
op.drop_column('authorities', 'cn')
op.drop_column('authorities', 'not_before')
op.add_column('certificates', sa.Column('root_authority_id', sa.Integer(), nullable=True))
op.alter_column('certificates', 'body',
existing_type=sa.TEXT(),
nullable=False)
op.alter_column('certificates', 'owner',
existing_type=sa.VARCHAR(length=128),
nullable=True)
op.drop_constraint(u'certificates_authority_id_fkey', 'certificates', type_='foreignkey')
op.create_foreign_key(None, 'certificates', 'authorities', ['authority_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(None, 'certificates', 'authorities', ['root_authority_id'], ['id'], ondelete='CASCADE')
### end Alembic commands ###
# link existing certificate to their authority certificates
conn = op.get_bind()
for id, body, owner in conn.execute(text('select id, body, owner from authorities')):
if not owner:
owner = "lemur@nobody"
# look up certificate by body, if duplications are found, pick one
stmt = text('select id from certificates where body=:body')
stmt = stmt.bindparams(body=body)
root_certificate = conn.execute(stmt).fetchone()
if root_certificate:
stmt = text('update certificates set root_authority_id=:root_authority_id where id=:id')
stmt = stmt.bindparams(root_authority_id=id, id=root_certificate[0])
op.execute(stmt)
# link owner roles to their authorities
stmt = text('select id from roles where name=:name')
stmt = stmt.bindparams(name=owner)
owner_role = conn.execute(stmt).fetchone()
if not owner_role:
stmt = text('insert into roles (name, description) values (:name, :description)')
stmt = stmt.bindparams(name=owner, description='Lemur generated role or existing owner.')
op.execute(stmt)
stmt = text('select id from roles where name=:name')
stmt = stmt.bindparams(name=owner)
owner_role = conn.execute(stmt).fetchone()
stmt = text('select * from roles_authorities where role_id=:role_id and authority_id=:authority_id')
stmt = stmt.bindparams(role_id=owner_role[0], authority_id=id)
exists = conn.execute(stmt).fetchone()
if not exists:
stmt = text('insert into roles_authorities (role_id, authority_id) values (:role_id, :authority_id)')
stmt = stmt.bindparams(role_id=owner_role[0], authority_id=id)
op.execute(stmt)
# link owner roles to their certificates
for id, owner in conn.execute(text('select id, owner from certificates')):
if not owner:
owner = "lemur@nobody"
stmt = text('select id from roles where name=:name')
stmt = stmt.bindparams(name=owner)
owner_role = conn.execute(stmt).fetchone()
if not owner_role:
stmt = text('insert into roles (name, description) values (:name, :description)')
stmt = stmt.bindparams(name=owner, description='Lemur generated role or existing owner.')
op.execute(stmt)
# link owner roles to their authorities
stmt = text('select id from roles where name=:name')
stmt = stmt.bindparams(name=owner)
owner_role = conn.execute(stmt).fetchone()
stmt = text('select * from roles_certificates where role_id=:role_id and certificate_id=:certificate_id')
stmt = stmt.bindparams(role_id=owner_role[0], certificate_id=id)
exists = conn.execute(stmt).fetchone()
if not exists:
stmt = text('insert into roles_certificates (role_id, certificate_id) values (:role_id, :certificate_id)')
stmt = stmt.bindparams(role_id=owner_role[0], certificate_id=id)
op.execute(stmt)
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'certificates', type_='foreignkey')
op.drop_constraint(None, 'certificates', type_='foreignkey')
op.create_foreign_key(u'certificates_authority_id_fkey', 'certificates', 'authorities', ['authority_id'], ['id'])
op.alter_column('certificates', 'owner',
existing_type=sa.VARCHAR(length=128),
nullable=True)
op.alter_column('certificates', 'body',
existing_type=sa.TEXT(),
nullable=True)
op.drop_column('certificates', 'root_authority_id')
op.add_column('authorities', sa.Column('not_before', postgresql.TIMESTAMP(), autoincrement=False, nullable=True))
op.add_column('authorities', sa.Column('cn', sa.VARCHAR(length=128), autoincrement=False, nullable=True))
op.add_column('authorities', sa.Column('bits', sa.INTEGER(), autoincrement=False, nullable=True))
op.add_column('authorities', sa.Column('not_after', postgresql.TIMESTAMP(), autoincrement=False, nullable=True))
op.alter_column('authorities', 'owner',
existing_type=sa.VARCHAR(length=128),
nullable=True)
### end Alembic commands ###

View File

@ -0,0 +1,63 @@
"""
Revision ID: 412b22cb656a
Revises: 4c50b903d1ae
Create Date: 2016-05-17 17:37:41.210232
"""
# revision identifiers, used by Alembic.
revision = '412b22cb656a'
down_revision = '4c50b903d1ae'
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('roles_authorities',
sa.Column('authority_id', sa.Integer(), nullable=True),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['authority_id'], ['authorities.id'], ),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], )
)
op.create_index('roles_authorities_ix', 'roles_authorities', ['authority_id', 'role_id'], unique=True)
op.create_table('roles_certificates',
sa.Column('certificate_id', sa.Integer(), nullable=True),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], )
)
op.create_index('roles_certificates_ix', 'roles_certificates', ['certificate_id', 'role_id'], unique=True)
op.create_index('certificate_associations_ix', 'certificate_associations', ['domain_id', 'certificate_id'], unique=True)
op.create_index('certificate_destination_associations_ix', 'certificate_destination_associations', ['destination_id', 'certificate_id'], unique=True)
op.create_index('certificate_notification_associations_ix', 'certificate_notification_associations', ['notification_id', 'certificate_id'], unique=True)
op.create_index('certificate_replacement_associations_ix', 'certificate_replacement_associations', ['certificate_id', 'certificate_id'], unique=True)
op.create_index('certificate_source_associations_ix', 'certificate_source_associations', ['source_id', 'certificate_id'], unique=True)
op.create_index('roles_users_ix', 'roles_users', ['user_id', 'role_id'], unique=True)
### end Alembic commands ###
# migrate existing authority_id relationship to many_to_many
conn = op.get_bind()
for id, authority_id in conn.execute(text('select id, authority_id from roles where authority_id is not null')):
stmt = text('insert into roles_authoritties (role_id, authority_id) values (:role_id, :authority_id)')
stmt = stmt.bindparams(role_id=id, authority_id=authority_id)
op.execute(stmt)
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_index('roles_users_ix', table_name='roles_users')
op.drop_index('certificate_source_associations_ix', table_name='certificate_source_associations')
op.drop_index('certificate_replacement_associations_ix', table_name='certificate_replacement_associations')
op.drop_index('certificate_notification_associations_ix', table_name='certificate_notification_associations')
op.drop_index('certificate_destination_associations_ix', table_name='certificate_destination_associations')
op.drop_index('certificate_associations_ix', table_name='certificate_associations')
op.drop_index('roles_certificates_ix', table_name='roles_certificates')
op.drop_table('roles_certificates')
op.drop_index('roles_authorities_ix', table_name='roles_authorities')
op.drop_table('roles_authorities')
### end Alembic commands ###

View File

@ -0,0 +1,35 @@
"""Ensures that certificate name is unique.
If duplicates are found, we follow the standard naming convention of appending '-X'
with x being the number of duplicates starting at 1.
Revision ID: 7f71c0cea31a
Revises: 29d8c8455c86
Create Date: 2016-07-28 09:39:12.736506
"""
# revision identifiers, used by Alembic.
revision = '7f71c0cea31a'
down_revision = '29d8c8455c86'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import text
def upgrade():
conn = op.get_bind()
for name in conn.execute(text('select name from certificates group by name having count(*) > 1')):
for idx, id in enumerate(conn.execute(text("select id from certificates where certificates.name like :name order by id ASC").bindparams(name=name[0]))):
if not idx:
continue
new_name = name[0] + '-' + str(idx)
stmt = text('update certificates set name=:name where id=:id')
stmt = stmt.bindparams(name=new_name, id=id[0])
op.execute(stmt)
op.create_unique_constraint(None, 'certificates', ['name'])
def downgrade():
op.drop_constraint(None, 'certificates', type_='unique')

View File

@ -0,0 +1,21 @@
"""Changing the column name to the more accurately named 'notify'.
Revision ID: 932525b82f1a
Revises: 7f71c0cea31a
Create Date: 2016-10-13 20:14:33.928029
"""
# revision identifiers, used by Alembic.
revision = '932525b82f1a'
down_revision = '7f71c0cea31a'
from alembic import op
def upgrade():
op.alter_column('certificates', 'active', new_column_name='notify')
def downgrade():
op.alter_column('certificates', 'notify', new_column_name='active')

View File

@ -8,7 +8,8 @@
: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 sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey, Index
from lemur.database import db from lemur.database import db
certificate_associations = db.Table('certificate_associations', certificate_associations = db.Table('certificate_associations',
@ -16,6 +17,8 @@ certificate_associations = db.Table('certificate_associations',
Column('certificate_id', Integer, ForeignKey('certificates.id')) Column('certificate_id', Integer, ForeignKey('certificates.id'))
) )
Index('certificate_associations_ix', certificate_associations.c.domain_id, certificate_associations.c.certificate_id)
certificate_destination_associations = db.Table('certificate_destination_associations', certificate_destination_associations = db.Table('certificate_destination_associations',
Column('destination_id', Integer, Column('destination_id', Integer,
ForeignKey('destinations.id', ondelete='cascade')), ForeignKey('destinations.id', ondelete='cascade')),
@ -23,6 +26,8 @@ certificate_destination_associations = db.Table('certificate_destination_associa
ForeignKey('certificates.id', ondelete='cascade')) ForeignKey('certificates.id', ondelete='cascade'))
) )
Index('certificate_destination_associations_ix', certificate_destination_associations.c.destination_id, certificate_destination_associations.c.certificate_id)
certificate_source_associations = db.Table('certificate_source_associations', certificate_source_associations = db.Table('certificate_source_associations',
Column('source_id', Integer, Column('source_id', Integer,
ForeignKey('sources.id', ondelete='cascade')), ForeignKey('sources.id', ondelete='cascade')),
@ -30,6 +35,8 @@ certificate_source_associations = db.Table('certificate_source_associations',
ForeignKey('certificates.id', ondelete='cascade')) ForeignKey('certificates.id', ondelete='cascade'))
) )
Index('certificate_source_associations_ix', certificate_source_associations.c.source_id, certificate_source_associations.c.certificate_id)
certificate_notification_associations = db.Table('certificate_notification_associations', certificate_notification_associations = db.Table('certificate_notification_associations',
Column('notification_id', Integer, Column('notification_id', Integer,
ForeignKey('notifications.id', ondelete='cascade')), ForeignKey('notifications.id', ondelete='cascade')),
@ -37,6 +44,8 @@ certificate_notification_associations = db.Table('certificate_notification_assoc
ForeignKey('certificates.id', ondelete='cascade')) ForeignKey('certificates.id', ondelete='cascade'))
) )
Index('certificate_notification_associations_ix', certificate_notification_associations.c.notification_id, certificate_notification_associations.c.certificate_id)
certificate_replacement_associations = db.Table('certificate_replacement_associations', certificate_replacement_associations = db.Table('certificate_replacement_associations',
Column('replaced_certificate_id', Integer, Column('replaced_certificate_id', Integer,
ForeignKey('certificates.id', ondelete='cascade')), ForeignKey('certificates.id', ondelete='cascade')),
@ -44,7 +53,33 @@ 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)
roles_authorities = db.Table('roles_authorities',
Column('authority_id', Integer, ForeignKey('authorities.id')),
Column('role_id', Integer, ForeignKey('roles.id'))
)
Index('roles_authorities_ix', roles_authorities.c.authority_id, roles_authorities.c.role_id)
roles_certificates = db.Table('roles_certificates',
Column('certificate_id', Integer, ForeignKey('certificates.id')),
Column('role_id', Integer, ForeignKey('roles.id'))
)
Index('roles_certificates_ix', roles_certificates.c.certificate_id, roles_certificates.c.role_id)
roles_users = db.Table('roles_users', roles_users = db.Table('roles_users',
Column('user_id', Integer, ForeignKey('users.id')), Column('user_id', Integer, ForeignKey('users.id')),
Column('role_id', Integer, ForeignKey('roles.id')) Column('role_id', Integer, ForeignKey('roles.id'))
) )
Index('roles_users_ix', roles_users.c.user_id, roles_users.c.role_id)
policies_ciphers = db.Table('policies_ciphers',
Column('cipher_id', Integer, ForeignKey('ciphers.id')),
Column('policy_id', Integer, ForeignKey('policy.id')))
Index('policies_ciphers_ix', policies_ciphers.c.cipher_id, policies_ciphers.c.policy_id)

View File

@ -33,3 +33,6 @@ class Notification(db.Model):
@property @property
def plugin(self): def plugin(self):
return plugins.get(self.plugin_name) return plugins.get(self.plugin_name)
def __repr__(self):
return "Notification(label={label})".format(label=self.label)

View File

@ -0,0 +1,49 @@
"""
.. module: lemur.notifications.schemas
:platform: unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from marshmallow import fields, post_dump
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
from lemur.schemas import PluginInputSchema, PluginOutputSchema, AssociatedCertificateSchema
class NotificationInputSchema(LemurInputSchema):
id = fields.Integer()
label = fields.String(required=True)
description = fields.String()
active = fields.Boolean()
plugin = fields.Nested(PluginInputSchema, required=True)
certificates = fields.Nested(AssociatedCertificateSchema, many=True, missing=[])
class NotificationOutputSchema(LemurOutputSchema):
id = fields.Integer()
label = fields.String()
description = fields.String()
active = fields.Boolean()
options = fields.List(fields.Dict())
plugin = fields.Nested(PluginOutputSchema)
certificates = fields.Nested(AssociatedCertificateSchema, many=True, missing=[])
@post_dump
def fill_object(self, data):
data['plugin']['pluginOptions'] = data['options']
return data
class NotificationNestedOutputSchema(LemurOutputSchema):
__envelope__ = False
id = fields.Integer()
label = fields.String()
description = fields.String()
active = fields.Boolean()
options = fields.List(fields.Dict())
plugin = fields.Nested(PluginOutputSchema)
notification_input_schema = NotificationInputSchema()
notification_output_schema = NotificationOutputSchema()
notifications_output_schema = NotificationOutputSchema(many=True)

View File

@ -45,6 +45,7 @@ def _get_message_data(cert):
cert_dict['owner'] = cert.owner cert_dict['owner'] = cert.owner
cert_dict['name'] = cert.name cert_dict['name'] = cert.name
cert_dict['body'] = cert.body cert_dict['body'] = cert.body
cert_dict['endpoints'] = [{'name': x.name, 'dnsname': x.dnsname} for x in cert.endpoints]
return cert_dict return cert_dict
@ -159,6 +160,9 @@ def _is_eligible_for_notifications(cert):
:param cert: :param cert:
:return: :return:
""" """
if not cert.notify:
return
now = arrow.utcnow() now = arrow.utcnow()
days = (cert.not_after - now.naive).days days = (cert.not_after - now.naive).days
@ -231,7 +235,7 @@ def create_default_expiration_notifications(name, recipients):
inter.extend(options) inter.extend(options)
n = create( n = create(
label="{name}_{interval}_DAY".format(name=name, interval=i), label="{name}_{interval}_DAY".format(name=name, interval=i),
plugin_name="email-notification", plugin_name=current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification"),
options=list(inter), options=list(inter),
description="Default {interval} day expiration notification".format(interval=i), description="Default {interval} day expiration notification".format(interval=i),
certificates=[] certificates=[]
@ -243,27 +247,31 @@ def create_default_expiration_notifications(name, recipients):
def create(label, plugin_name, options, description, certificates): def create(label, plugin_name, options, description, certificates):
""" """
Creates a new destination, that can then be used as a destination for certificates. Creates a new notification.
:param label: Notification common name :param label: Notification label
:param plugin_name: :param plugin_name:
:param options: :param options:
:param description: :param description:
:param certificates:
:rtype : Notification :rtype : Notification
:return: :return:
""" """
notification = Notification(label=label, options=options, plugin_name=plugin_name, description=description) notification = Notification(label=label, options=options, plugin_name=plugin_name, description=description)
notification = database.update_list(notification, 'certificates', Certificate, certificates) notification.certificates = certificates
return database.create(notification) return database.create(notification)
def update(notification_id, label, options, description, active, certificates): def update(notification_id, label, options, description, active, certificates):
""" """
Updates an existing destination. Updates an existing notification.
:param label: Notification common name :param notification_id:
:param label: Notification label
:param options: :param options:
:param description: :param description:
:param active:
:param certificates:
:rtype : Notification :rtype : Notification
:return: :return:
""" """
@ -273,7 +281,7 @@ def update(notification_id, label, options, description, active, certificates):
notification.options = options notification.options = options
notification.description = description notification.description = description
notification.active = active notification.active = active
notification = database.update_list(notification, 'certificates', Certificate, certificates) notification.certificates = certificates
return database.update(notification) return database.update(notification)
@ -319,10 +327,6 @@ def get_all():
def render(args): def render(args):
sort_by = args.pop('sort_by')
sort_dir = args.pop('sort_dir')
page = args.pop('page')
count = args.pop('count')
filt = args.pop('filter') filt = args.pop('filter')
certificate_id = args.pop('certificate_id', None) certificate_id = args.pop('certificate_id', None)
@ -341,9 +345,4 @@ def render(args):
else: else:
query = database.filter(query, Notification, terms) query = database.filter(query, Notification, terms)
query = database.find_all(query, Notification, args) return database.sort_and_page(query, Notification, args)
if sort_by and sort_dir:
query = database.sort(query, Notification, sort_by, sort_dir)
return database.paginate(query, page, count)

View File

@ -7,64 +7,27 @@
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
from flask import Blueprint from flask import Blueprint
from flask.ext.restful import Api, reqparse, fields from flask.ext.restful import Api, reqparse
from lemur.notifications import service from lemur.notifications import service
from lemur.notifications.schemas import notification_input_schema, notification_output_schema, notifications_output_schema
from lemur.auth.service import AuthenticatedResource from lemur.auth.service import AuthenticatedResource
from lemur.common.utils import paginated_parser, marshal_items from lemur.common.utils import paginated_parser
from lemur.common.schema import validate_schema
mod = Blueprint('notifications', __name__) mod = Blueprint('notifications', __name__)
api = Api(mod) api = Api(mod)
FIELDS = {
'description': fields.String,
'notificationOptions': fields.Raw(attribute='options'),
'pluginName': fields.String(attribute='plugin_name'),
'label': fields.String,
'active': fields.Boolean,
'id': fields.Integer,
}
def notification(value, name):
"""
Validates a given notification exits
:param value:
:param name:
:return:
"""
n = service.get(value)
if not n:
raise ValueError("Unable to find notification specified")
return n
def notification_list(value, name):
"""
Validates a given notification exists and returns a list
:param value:
:param name:
:return:
"""
notifications = []
for v in value:
try:
notifications.append(notification(v['id'], 'id'))
except ValueError:
pass
return notifications
class NotificationsList(AuthenticatedResource): class NotificationsList(AuthenticatedResource):
""" Defines the 'notifications' endpoint """ """ Defines the 'notifications' endpoint """
def __init__(self): def __init__(self):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(NotificationsList, self).__init__() super(NotificationsList, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, notifications_output_schema)
def get(self): def get(self):
""" """
.. http:get:: /notifications .. http:get:: /notifications
@ -91,7 +54,7 @@ class NotificationsList(AuthenticatedResource):
"items": [ "items": [
{ {
"description": "An example", "description": "An example",
"notificationOptions": [ "options": [
{ {
"name": "interval", "name": "interval",
"required": true, "required": true,
@ -135,7 +98,7 @@ class NotificationsList(AuthenticatedResource):
:query sortDir: acs or desc :query sortDir: acs or desc
:query page: int default is 1 :query page: int default is 1
:query filter: key value pair format is k;v :query filter: key value pair format is k;v
:query limit: limit number default is 10 :query count: count number default is 10
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error :statuscode 200: no error
""" """
@ -144,8 +107,8 @@ class NotificationsList(AuthenticatedResource):
args = parser.parse_args() args = parser.parse_args()
return service.render(args) return service.render(args)
@marshal_items(FIELDS) @validate_schema(notification_input_schema, notification_output_schema)
def post(self): def post(self, data=None):
""" """
.. http:post:: /notifications .. http:post:: /notifications
@ -161,7 +124,7 @@ class NotificationsList(AuthenticatedResource):
{ {
"description": "a test", "description": "a test",
"notificationOptions": [ "options": [
{ {
"name": "interval", "name": "interval",
"required": true, "required": true,
@ -208,7 +171,7 @@ class NotificationsList(AuthenticatedResource):
{ {
"description": "a test", "description": "a test",
"notificationOptions": [ "options": [
{ {
"name": "interval", "name": "interval",
"required": true, "required": true,
@ -251,18 +214,12 @@ class NotificationsList(AuthenticatedResource):
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error :statuscode 200: no error
""" """
self.reqparse.add_argument('label', type=str, location='json', required=True)
self.reqparse.add_argument('plugin', type=dict, location='json', required=True)
self.reqparse.add_argument('description', type=str, location='json')
self.reqparse.add_argument('certificates', type=list, default=[], location='json')
args = self.reqparse.parse_args()
return service.create( return service.create(
args['label'], data['label'],
args['plugin']['slug'], data['plugin']['slug'],
args['plugin']['pluginOptions'], data['plugin']['plugin_options'],
args['description'], data['description'],
args['certificates'] data['certificates']
) )
@ -271,7 +228,7 @@ class Notifications(AuthenticatedResource):
self.reqparse = reqparse.RequestParser() self.reqparse = reqparse.RequestParser()
super(Notifications, self).__init__() super(Notifications, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, notification_output_schema)
def get(self, notification_id): def get(self, notification_id):
""" """
.. http:get:: /notifications/1 .. http:get:: /notifications/1
@ -296,7 +253,7 @@ class Notifications(AuthenticatedResource):
{ {
"description": "a test", "description": "a test",
"notificationOptions": [ "options": [
{ {
"name": "interval", "name": "interval",
"required": true, "required": true,
@ -338,8 +295,8 @@ class Notifications(AuthenticatedResource):
""" """
return service.get(notification_id) return service.get(notification_id)
@marshal_items(FIELDS) @validate_schema(notification_input_schema, notification_output_schema)
def put(self, notification_id): def put(self, notification_id, data=None):
""" """
.. http:put:: /notifications/1 .. http:put:: /notifications/1
@ -375,20 +332,13 @@ class Notifications(AuthenticatedResource):
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error :statuscode 200: no error
""" """
self.reqparse.add_argument('label', type=str, location='json', required=True)
self.reqparse.add_argument('plugin', type=dict, location='json', required=True)
self.reqparse.add_argument('active', type=bool, location='json')
self.reqparse.add_argument('certificates', type=list, default=[], location='json')
self.reqparse.add_argument('description', type=str, location='json')
args = self.reqparse.parse_args()
return service.update( return service.update(
notification_id, notification_id,
args['label'], data['label'],
args['plugin']['pluginOptions'], data['plugin']['plugin_options'],
args['description'], data['description'],
args['active'], data['active'],
args['certificates'] data['certificates']
) )
def delete(self, notification_id): def delete(self, notification_id):
@ -401,7 +351,7 @@ class CertificateNotifications(AuthenticatedResource):
def __init__(self): def __init__(self):
super(CertificateNotifications, self).__init__() super(CertificateNotifications, self).__init__()
@marshal_items(FIELDS) @validate_schema(None, notifications_output_schema)
def get(self, certificate_id): def get(self, certificate_id):
""" """
.. http:get:: /certificates/1/notifications .. http:get:: /certificates/1/notifications
@ -428,7 +378,7 @@ class CertificateNotifications(AuthenticatedResource):
"items": [ "items": [
{ {
"description": "An example", "description": "An example",
"notificationOptions": [ "options": [
{ {
"name": "interval", "name": "interval",
"required": true, "required": true,
@ -472,15 +422,11 @@ class CertificateNotifications(AuthenticatedResource):
:query sortDir: acs or desc :query sortDir: acs or desc
:query page: int default is 1 :query page: int default is 1
:query filter: key value pair format is k;v :query filter: key value pair format is k;v
:query limit: limit number default is 10 :query count: count number default is 10
:reqheader Authorization: OAuth token to authenticate :reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error :statuscode 200: no error
""" """
parser = paginated_parser.copy() return service.render({'certificate_id': certificate_id})
parser.add_argument('active', type=bool, location='args')
args = parser.parse_args()
args['certificate_id'] = certificate_id
return service.render(args)
api.add_resource(NotificationsList, '/notifications', endpoint='notifications') api.add_resource(NotificationsList, '/notifications', endpoint='notifications')

View File

@ -112,7 +112,7 @@ class IPlugin(local):
def get_option(name, options): def get_option(name, options):
for o in options: for o in options:
if o.get('name') == name: if o.get('name') == name:
return o.get('value') return o.get('value', o.get('default'))
class Plugin(IPlugin): class Plugin(IPlugin):

View File

@ -11,6 +11,7 @@ from lemur.plugins.base import Plugin
class DestinationPlugin(Plugin): class DestinationPlugin(Plugin):
type = 'destination' type = 'destination'
requires_key = True
def upload(self): def upload(self):
raise NotImplemented raise NotImplemented

View File

@ -0,0 +1,16 @@
"""
.. module: lemur.bases.metric
:platform: Unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from lemur.plugins.base import Plugin
class MetricPlugin(Plugin):
type = 'metric'
def submit(self, *args, **kwargs):
raise NotImplemented

View File

@ -25,6 +25,12 @@ class SourcePlugin(Plugin):
def get_certificates(self): def get_certificates(self):
raise NotImplemented raise NotImplemented
def get_endpoints(self):
raise NotImplemented
def clean(self):
raise NotImplemented
@property @property
def options(self): def options(self):
return list(self.default_options) + self.additional_options return list(self.default_options) + self.additional_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,210 @@
"""
.. module: lemur.plugins.lemur_acme.acme
:platform: Unix
:synopsis: This module is responsible for communicating with a ACME CA.
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
Snippets from https://raw.githubusercontent.com/alex/letsencrypt-aws/master/letsencrypt-aws.py
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
"""
from flask import current_app
from acme.client import Client
from acme import jose
from acme import messages
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import OpenSSL.crypto
from lemur.plugins.bases import IssuerPlugin
from lemur.plugins import lemur_acme as acme
from .route53 import delete_txt_record, create_txt_record, wait_for_change
def find_dns_challenge(authz):
for combo in authz.body.resolved_combinations:
if (
len(combo) == 1 and
isinstance(combo[0].chall, acme.challenges.DNS01)
):
yield combo[0]
class AuthorizationRecord(object):
def __init__(self, host, authz, dns_challenge, change_id):
self.host = host
self.authz = authz
self.dns_challenge = dns_challenge
self.change_id = change_id
def start_dns_challenge(acme_client, host):
authz = acme_client.request_domain_challenges(
host, acme_client.directory.new_authz
)
[dns_challenge] = find_dns_challenge(authz)
change_id = create_txt_record(
dns_challenge.validation_domain_name(host),
dns_challenge.validation(acme_client.key),
)
return AuthorizationRecord(
host,
authz,
dns_challenge,
change_id,
)
def complete_dns_challenge(acme_client, authz_record):
wait_for_change(authz_record.change_id)
response = authz_record.dns_challenge.response(acme_client.key)
verified = response.simple_verify(
authz_record.dns_challenge.chall,
authz_record.host,
acme_client.key.public_key()
)
if not verified:
raise ValueError("Failed verification")
acme_client.answer_challenge(authz_record.dns_challenge, response)
def request_certificate(acme_client, authorizations, csr):
cert_response, _ = acme_client.poll_and_request_issuance(
jose.util.ComparableX509(
OpenSSL.crypto.load_certificate_request(
OpenSSL.crypto.FILETYPE_ASN1,
csr.public_bytes(serialization.Encoding.DER),
)
),
authzrs=[authz_record.authz for authz_record in authorizations],
)
pem_certificate = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, cert_response.body
)
pem_certificate_chain = "\n".join(
OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
for cert in acme_client.fetch_chain(cert_response)
)
return pem_certificate, pem_certificate_chain
def generate_rsa_private_key():
return rsa.generate_private_key(
public_exponent=65537, key_size=2048, backend=default_backend()
)
def setup_acme_client():
key = current_app.config.get('ACME_PRIVATE_KEY').strip()
acme_email = current_app.config.get('ACME_EMAIL')
acme_tel = current_app.config.get('ACME_TEL')
acme_directory_url = current_app.config.get('ACME_DIRECTORY_URL'),
contact = ('mailto:{}'.format(acme_email), 'tel:{}'.format(acme_tel))
key = serialization.load_pem_private_key(
key, password=None, backend=default_backend()
)
return acme_client_for_private_key(acme_directory_url, key)
def acme_client_for_private_key(acme_directory_url, private_key):
return Client(
acme_directory_url, key=jose.JWKRSA(key=private_key)
)
def register(email):
private_key = generate_rsa_private_key()
acme_client = acme_client_for_private_key(current_app.config('ACME_DIRECTORY_URL'), private_key)
registration = acme_client.register(
messages.NewRegistration.from_data(email=email)
)
acme_client.agree_to_tos(registration)
return private_key
def get_domains(options):
"""
Fetches all domains currently requested
:param options:
:return:
"""
domains = [options['common_name']]
for name in options['extensions']['sub_alt_name']['names']:
domains.append(name)
return domains
def get_authorizations(acme_client, domains):
authorizations = []
try:
for domain in domains:
authz_record = start_dns_challenge(acme_client, domain)
authorizations.append(authz_record)
for authz_record in authorizations:
complete_dns_challenge(acme_client, authz_record)
finally:
for authz_record in authorizations:
dns_challenge = authz_record.dns_challenge
delete_txt_record(
authz_record.change_id,
dns_challenge.validation_domain_name(authz_record.host),
dns_challenge.validation(acme_client.key),
)
return authorizations
class ACMEIssuerPlugin(IssuerPlugin):
title = 'Acme'
slug = 'acme-issuer'
description = 'Enables the creation of certificates via ACME CAs (including Let\'s Encrypt)'
version = acme.VERSION
author = 'Kevin Glisson'
author_url = 'https://github.com/netflix/lemur.git'
def __init__(self, *args, **kwargs):
super(ACMEIssuerPlugin, self).__init__(*args, **kwargs)
def create_certificate(self, csr, issuer_options):
"""
Creates a ACME certificate.
:param csr:
:param issuer_options:
:return: :raise Exception:
"""
current_app.logger.debug("Requesting a new acme certificate: {0}".format(issuer_options))
acme_client = setup_acme_client()
domains = get_domains(issuer_options)
authorizations = get_authorizations(acme_client, domains)
pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, csr)
return pem_certificate, pem_certificate_chain
@staticmethod
def create_authority(options):
"""
Creates an authority, this authority is then used by Lemur to allow a user
to specify which Certificate Authority they want to sign their certificate.
:param options:
:return:
"""
role = {'username': '', 'password': '', 'name': 'acme'}
return current_app.config.get('ACME_ROOT'), "", [role]

View File

@ -0,0 +1,86 @@
import time
from lemur.plugins.lemur_aws.sts import sts_client
@sts_client('route53')
def wait_for_r53_change(change_id, client=None):
_, change_id = change_id
while True:
response = client.get_change(Id=change_id)
if response["ChangeInfo"]["Status"] == "INSYNC":
return
time.sleep(5)
@sts_client('route53')
def find_zone_id(domain, client=None):
paginator = client.get_paginator("list_hosted_zones")
zones = []
for page in paginator.paginate():
for zone in page["HostedZones"]:
if domain.endswith(zone["Name"]) or (domain + ".").endswith(zone["Name"]):
if not zone["Config"]["PrivateZone"]:
zones.append((zone["Name"], zone["Id"]))
if not zones:
raise ValueError(
"Unable to find a Route53 hosted zone for {}".format(domain)
)
@sts_client('route53')
def change_txt_record(action, zone_id, domain, value, client=None):
response = client.change_resource_record_sets(
HostedZoneId=zone_id,
ChangeBatch={
"Changes": [
{
"Action": action,
"ResourceRecordSet": {
"Name": domain,
"Type": "TXT",
"TTL": 300,
"ResourceRecords": [
# For some reason TXT records need to be
# manually quoted.
{"Value": '"{}"'.format(value)}
],
}
}
]
}
)
return response["ChangeInfo"]["Id"]
def create_txt_record(host, value):
zone_id = find_zone_id(host)
change_id = change_txt_record(
"CREATE",
zone_id,
host,
value,
)
return zone_id, change_id
def delete_txt_record(change_id, host, value):
zone_id, _ = change_id
change_txt_record(
"DELETE",
zone_id,
host,
value
)
@sts_client('route53')
def wait_for_change(change_id, client=None):
_, change_id = change_id
while True:
response = client.get_change(Id=change_id)
if response["ChangeInfo"]["Status"] == "INSYNC":
return
time.sleep(5)

View File

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

View File

@ -0,0 +1,4 @@
def test_get_certificates(app):
from lemur.plugins.base import plugins
p = plugins.get('acme-issuer')

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,107 @@
"""
.. module: lemur.plugins.lemur_atlas.plugin
:platform: Unix
:copyright: (c) 2016 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
import json
import requests
from requests.exceptions import ConnectionError
from datetime import datetime
from flask import current_app
from lemur.plugins import lemur_atlas as atlas
from lemur.plugins.bases.metric import MetricPlugin
def millis_since_epoch():
"""
current time since epoch in milliseconds
"""
epoch = datetime.utcfromtimestamp(0)
delta = datetime.now() - epoch
return int(delta.total_seconds() * 1000.0)
class AtlasMetricPlugin(MetricPlugin):
title = 'Atlas'
slug = 'atlas-metric'
description = 'Adds support for sending key metrics to Atlas'
version = atlas.VERSION
author = 'Kevin Glisson'
author_url = 'https://github.com/netflix/lemur'
options = [
{
'name': 'sidecar_host',
'type': 'str',
'required': False,
'help_message': 'If no host is provided localhost is assumed',
'default': 'localhost'
},
{
'name': 'sidecar_port',
'type': 'int',
'required': False,
'default': 8078
}
]
metric_data = {}
sidecar_host = None
sidecar_port = None
def submit(self, metric_name, metric_type, metric_value, metric_tags=None, options=None):
if not options:
options = self.options
# TODO marshmallow schema?
valid_types = ['COUNTER', 'GAUGE', 'TIMER']
if metric_type.upper() not in valid_types:
raise Exception(
"Invalid Metric Type for Atlas: '{metric}' choose from: {options}".format(
metric=metric_type, options=','.join(valid_types)
)
)
if metric_tags:
if not isinstance(metric_tags, dict):
raise Exception(
"Invalid Metric Tags for Atlas: Tags must be in dict format"
)
if metric_value == "NaN" or isinstance(metric_value, int) or isinstance(metric_value, float):
self.metric_data['value'] = metric_value
else:
raise Exception(
"Invalid Metric Value for Atlas: Metric must be a number"
)
self.metric_data['type'] = metric_type.upper()
self.metric_data['name'] = str(metric_name)
self.metric_data['tags'] = metric_tags
self.metric_data['timestamp'] = millis_since_epoch()
self.sidecar_host = self.get_option('sidecar_host', options)
self.sidecar_port = self.get_option('sidecar_port', options)
try:
res = requests.post(
'http://{host}:{port}/metrics'.format(
host=self.sidecar_host,
port=self.sidecar_port),
data=json.dumps([self.metric_data])
)
if res.status_code != 200:
current_app.logger.warning("Failed to publish altas metric. {0}".format(res.content))
except ConnectionError:
current_app.logger.warning(
"AtlasMetrics: could not connect to sidecar at {host}:{port}".format(
host=self.sidecar_host, port=self.sidecar_port
)
)

View File

@ -0,0 +1,23 @@
"""
.. module: lemur.plugins.lemur_aws.elb
:synopsis: Module contains some often used and helpful classes that
are used to deal with ELBs
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from lemur.plugins.lemur_aws.sts import sts_client
@sts_client('ec2')
def get_regions(**kwargs):
regions = kwargs['client'].describe_regions()
return [x['RegionName'] for x in regions['Regions']]
@sts_client('ec2')
def get_all_instances(**kwargs):
"""
Fetches all instance objects for a given account and region.
"""
paginator = kwargs['client'].get_paginator('describe_instances')
return paginator.paginate()

View File

@ -1,16 +1,29 @@
""" """
.. module: elb .. module: lemur.plugins.lemur_aws.elb
:synopsis: Module contains some often used and helpful classes that :synopsis: Module contains some often used and helpful classes that
are used to deal with ELBs are used to deal with ELBs
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
""" """
import boto.ec2 import botocore
from flask import current_app from flask import current_app
from retrying import retry
from lemur.exceptions import InvalidListener from lemur.exceptions import InvalidListener
from lemur.plugins.lemur_aws.sts import assume_service from lemur.plugins.lemur_aws.sts import sts_client, assume_service
def retry_throttled(exception):
"""
Determiens if this exception is due to throttling
:param exception:
:return:
"""
if isinstance(exception, botocore.exceptions.ClientError):
if 'Throttling' in exception.message:
return True
return False
def is_valid(listener_tuple): def is_valid(listener_tuple):
@ -28,8 +41,6 @@ def is_valid(listener_tuple):
:param listener_tuple: :param listener_tuple:
""" """
current_app.logger.debug(listener_tuple)
lb_port, i_port, lb_protocol, arn = listener_tuple lb_port, i_port, lb_protocol, arn = listener_tuple
current_app.logger.debug(lb_protocol) current_app.logger.debug(lb_protocol)
if lb_protocol.lower() in ['ssl', 'https']: if lb_protocol.lower() in ['ssl', 'https']:
@ -39,41 +50,57 @@ def is_valid(listener_tuple):
return listener_tuple return listener_tuple
def get_all_regions(): @sts_client('elb')
@retry(retry_on_exception=retry_throttled, stop_max_attempt_number=7, wait_exponential_multiplier=1000)
def get_elbs(**kwargs):
""" """
Retrieves all current EC2 regions. Fetches one page elb objects for a given account and region.
"""
client = kwargs.pop('client')
return client.describe_load_balancers(**kwargs)
def get_all_elbs(**kwargs):
"""
Fetches all elbs for a given account/region
:param kwargs:
:return: :return:
""" """
regions = []
for r in boto.ec2.regions():
regions.append(r.name)
return regions
def get_all_elbs(account_number, region):
"""
Fetches all elb objects for a given account and region.
:param account_number:
:param region:
"""
marker = None
elbs = [] elbs = []
return assume_service(account_number, 'elb', region).get_all_load_balancers()
# TODO create pull request for boto to include elb marker support while True:
# while True: response = get_elbs(**kwargs)
# app.logger.debug(response.__dict__)
# raise Exception elbs += response['LoadBalancerDescriptions']
# result = response['list_server_certificates_response']['list_server_certificates_result']
# if not response.get('IsTruncated'):
# for elb in result['server_certificate_metadata_list']: return elbs
# elbs.append(elb)
# if response['NextMarker']:
# if result['is_truncated'] == 'true': kwargs.update(dict(marker=response['NextMarker']))
# marker = result['marker']
# else:
# return elbs @sts_client('elb')
def describe_load_balancer_policies(load_balancer_name, policy_names, **kwargs):
"""
Fetching all policies currently associated with an ELB.
:param load_balancer_name:
:return:
"""
return kwargs['client'].describe_load_balancer_policies(LoadBalancerName=load_balancer_name, PolicyNames=policy_names)
@sts_client('elb')
def describe_load_balancer_types(policies, **kwargs):
"""
Describe the policies with policy details.
:param policies:
:return:
"""
return kwargs['client'].describe_load_balancer_policy_types(PolicyTypeNames=policies)
def attach_certificate(account_number, region, name, port, certificate_id): def attach_certificate(account_number, region, name, port, certificate_id):
@ -90,67 +117,67 @@ def attach_certificate(account_number, region, name, port, certificate_id):
return assume_service(account_number, 'elb', region).set_lb_listener_SSL_certificate(name, port, certificate_id) return assume_service(account_number, 'elb', region).set_lb_listener_SSL_certificate(name, port, certificate_id)
def create_new_listeners(account_number, region, name, listeners=None): # def create_new_listeners(account_number, region, name, listeners=None):
""" # """
Creates a new listener and attaches it to the ELB. # Creates a new listener and attaches it to the ELB.
#
:param account_number: # :param account_number:
:param region: # :param region:
:param name: # :param name:
:param listeners: # :param listeners:
:return: # :return:
""" # """
listeners = [is_valid(x) for x in listeners] # listeners = [is_valid(x) for x in listeners]
return assume_service(account_number, 'elb', region).create_load_balancer_listeners(name, listeners=listeners) # return assume_service(account_number, 'elb', region).create_load_balancer_listeners(name, listeners=listeners)
#
#
def update_listeners(account_number, region, name, listeners, ports): # def update_listeners(account_number, region, name, listeners, ports):
""" # """
We assume that a listener with a specified port already exists. We can then # We assume that a listener with a specified port already exists. We can then
delete the old listener on the port and create a new one in it's place. # delete the old listener on the port and create a new one in it's place.
#
If however we are replacing a listener e.g. changing a port from 80 to 443 we need # If however we are replacing a listener e.g. changing a port from 80 to 443 we need
to make sure we kept track of which ports we needed to delete so that we don't create # to make sure we kept track of which ports we needed to delete so that we don't create
two listeners (one 80 and one 443) # two listeners (one 80 and one 443)
#
:param account_number: # :param account_number:
:param region: # :param region:
:param name: # :param name:
:param listeners: # :param listeners:
:param ports: # :param ports:
""" # """
# you cannot update a listeners port/protocol instead we remove the only one and # # you cannot update a listeners port/protocol instead we remove the only one and
# create a new one in it's place # # create a new one in it's place
listeners = [is_valid(x) for x in listeners] # listeners = [is_valid(x) for x in listeners]
#
assume_service(account_number, 'elb', region).delete_load_balancer_listeners(name, ports) # assume_service(account_number, 'elb', region).delete_load_balancer_listeners(name, ports)
return create_new_listeners(account_number, region, name, listeners=listeners) # return create_new_listeners(account_number, region, name, listeners=listeners)
#
#
def delete_listeners(account_number, region, name, ports): # def delete_listeners(account_number, region, name, ports):
""" # """
Deletes a listener from an ELB. # Deletes a listener from an ELB.
#
:param account_number: # :param account_number:
:param region: # :param region:
:param name: # :param name:
:param ports: # :param ports:
:return: # :return:
""" # """
return assume_service(account_number, 'elb', region).delete_load_balancer_listeners(name, ports) # return assume_service(account_number, 'elb', region).delete_load_balancer_listeners(name, ports)
#
#
def get_listeners(account_number, region, name): # def get_listeners(account_number, region, name):
""" # """
Gets the listeners configured on an elb and returns a array of tuples # Gets the listeners configured on an elb and returns a array of tuples
#
:param account_number: # :param account_number:
:param region: # :param region:
:param name: # :param name:
:return: list of tuples # :return: list of tuples
""" # """
#
conn = assume_service(account_number, 'elb', region) # conn = assume_service(account_number, 'elb', region)
elbs = conn.get_all_load_balancers(load_balancer_names=[name]) # elbs = conn.get_all_load_balancers(load_balancer_names=[name])
if elbs: # if elbs:
return elbs[0].listeners # return elbs[0].listeners

View File

@ -33,15 +33,15 @@ def upload_cert(account_number, name, body, private_key, cert_chain=None):
cert_chain=str(cert_chain)) cert_chain=str(cert_chain))
def delete_cert(account_number, cert): def delete_cert(account_number, cert_name):
""" """
Delete a certificate from AWS Delete a certificate from AWS
:param account_number: :param account_number:
:param cert: :param cert_name:
:return: :return:
""" """
return assume_service(account_number, 'iam').delete_server_cert(cert.name) return assume_service(account_number, 'iam').delete_server_cert(cert_name)
def get_all_server_certs(account_number): def get_all_server_certs(account_number):

View File

@ -4,20 +4,44 @@
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details. :license: Apache, see LICENSE for more details.
Terraform example to setup the destination bucket:
resource "aws_s3_bucket" "certs_log_bucket" {
bucket = "certs-log-access-bucket"
acl = "log-delivery-write"
}
resource "aws_s3_bucket" "certs_lemur" {
bucket = "certs-lemur"
acl = "private"
logging {
target_bucket = "${aws_s3_bucket.certs_log_bucket.id}"
target_prefix = "log/lemur"
}
}
The IAM role Lemur is running as should have the following actions on the destination bucket:
"S3:PutObject",
"S3:PutObjectAcl"
The reader should have the following actions:
"s3:GetObject"
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com> .. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
.. moduleauthor:: Harm Weites <harm@weites.com>
""" """
from flask import current_app
from boto.exception import BotoServerError from boto.exception import BotoServerError
from lemur.plugins.bases import DestinationPlugin, SourcePlugin from lemur.plugins.bases import DestinationPlugin, SourcePlugin
from lemur.plugins.lemur_aws import iam, elb from lemur.plugins.lemur_aws.ec2 import get_regions
from lemur.plugins.lemur_aws.elb import get_all_elbs, describe_load_balancer_policies, attach_certificate
from lemur.plugins.lemur_aws import iam, s3
from lemur.plugins import lemur_aws as aws from lemur.plugins import lemur_aws as aws
def find_value(name, options):
for o in options:
if o['name'] == name:
return o['value']
class AWSDestinationPlugin(DestinationPlugin): class AWSDestinationPlugin(DestinationPlugin):
title = 'AWS' title = 'AWS'
slug = 'aws-destination' slug = 'aws-destination'
@ -36,6 +60,7 @@ class AWSDestinationPlugin(DestinationPlugin):
'helpMessage': 'Must be a valid AWS account number!', 'helpMessage': 'Must be a valid AWS account number!',
} }
] ]
# 'elb': { # 'elb': {
# 'name': {'type': 'name'}, # 'name': {'type': 'name'},
# 'region': {'type': 'str'}, # 'region': {'type': 'str'},
@ -43,24 +68,22 @@ 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):
if private_key: try:
try: iam.upload_cert(self.get_option('accountNumber', options), name, body, private_key,
iam.upload_cert(find_value('accountNumber', options), name, body, private_key, cert_chain=cert_chain) cert_chain=cert_chain)
except BotoServerError as e: except BotoServerError as e:
if e.error_code != 'EntityAlreadyExists': if e.error_code != 'EntityAlreadyExists':
raise Exception(e) raise Exception(e)
e = find_value('elb', options) e = self.get_option('elb', options)
if e: if e:
elb.attach_certificate(kwargs['accountNumber'], ['region'], e['name'], e['port'], e['certificateId']) attach_certificate(kwargs['accountNumber'], ['region'], e['name'], e['port'], e['certificateId'])
else:
raise Exception("Unable to upload to AWS, private key is required")
class AWSSourcePlugin(SourcePlugin): class AWSSourcePlugin(SourcePlugin):
title = 'AWS' title = 'AWS'
slug = 'aws-source' slug = 'aws-source'
description = 'Discovers all SSL certificates in an AWS account' description = 'Discovers all SSL certificates and ELB endpoints in an AWS account'
version = aws.VERSION version = aws.VERSION
author = 'Kevin Glisson' author = 'Kevin Glisson'
@ -74,18 +97,177 @@ class AWSSourcePlugin(SourcePlugin):
'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': 'regions',
'type': 'str',
'helpMessage': 'Comma separated list of regions to search in, if no region is specified we look in all regions.'
},
] ]
def get_certificates(self, options, **kwargs): def get_certificates(self, options, **kwargs):
certs = [] certs = []
arns = iam.get_all_server_certs(find_value('accountNumber', options)) arns = iam.get_all_server_certs(self.get_option('accountNumber', options))
for arn in arns: for arn in arns:
cert_body, cert_chain = iam.get_cert_from_arn(arn) cert_body, cert_chain = iam.get_cert_from_arn(arn)
cert_name = iam.get_name_from_arn(arn) cert_name = iam.get_name_from_arn(arn)
cert = dict( cert = dict(
public_certificate=cert_body, body=cert_body,
intermediate_certificate=cert_chain, chain=cert_chain,
name=cert_name name=cert_name
) )
certs.append(cert) certs.append(cert)
return certs return certs
def get_endpoints(self, options, **kwargs):
endpoints = []
account_number = self.get_option('accountNumber', options)
regions = self.get_option('regions', options)
if not regions:
regions = get_regions(account_number=account_number)
else:
regions = regions.split(',')
for region in regions:
elbs = get_all_elbs(account_number=account_number, region=region)
current_app.logger.info("Describing load balancers in {0}-{1}".format(account_number, region))
for elb in elbs:
for listener in elb['ListenerDescriptions']:
if not listener['Listener'].get('SSLCertificateId'):
continue
if listener['Listener']['SSLCertificateId'] == 'Invalid-Certificate':
continue
endpoint = dict(
name=elb['LoadBalancerName'],
dnsname=elb['DNSName'],
type='elb',
port=listener['Listener']['LoadBalancerPort'],
certificate_name=iam.get_name_from_arn(listener['Listener']['SSLCertificateId'])
)
if listener['PolicyNames']:
policy = describe_load_balancer_policies(elb['LoadBalancerName'], listener['PolicyNames'], account_number=account_number, region=region)
endpoint['policy'] = format_elb_cipher_policy(policy)
endpoints.append(endpoint)
return endpoints
def clean(self, options, **kwargs):
account_number = self.get_option('accountNumber', options)
certificates = self.get_certificates(options)
endpoints = self.get_endpoints(options)
orphaned = []
for certificate in certificates:
for endpoint in endpoints:
if certificate['name'] == endpoint['certificate_name']:
break
else:
orphaned.append(certificate['name'])
iam.delete_cert(account_number, certificate)
return orphaned
def format_elb_cipher_policy(policy):
"""
Attempts to format cipher policy information into a common format.
:param policy:
:return:
"""
ciphers = []
name = None
for descr in policy['PolicyDescriptions']:
for attr in descr['PolicyAttributeDescriptions']:
if attr['AttributeName'] == 'Reference-Security-Policy':
name = attr['AttributeValue']
continue
if attr['AttributeValue'] == 'true':
ciphers.append(attr['AttributeName'])
return dict(name=name, ciphers=ciphers)
class S3DestinationPlugin(DestinationPlugin):
title = 'AWS-S3'
slug = 'aws-s3'
description = 'Allow the uploading of certificates to Amazon S3'
author = 'Mikhail Khodorovskiy, Harm Weites <harm@weites.com>'
author_url = 'https://github.com/Netflix/lemur'
options = [
{
'name': 'bucket',
'type': 'str',
'required': True,
'validation': '/^$|\s+/',
'helpMessage': 'Must be a valid S3 bucket name!',
},
{
'name': 'accountNumber',
'type': 'str',
'required': True,
'validation': '/^[0-9]{12,12}$/',
'helpMessage': 'A valid AWS account number with permission to access S3',
},
{
'name': 'region',
'type': 'str',
'default': 'eu-west-1',
'required': False,
'validation': '/^\w+-\w+-\d+$/',
'helpMessage': 'Availability zone to use',
},
{
'name': 'encrypt',
'type': 'bool',
'required': False,
'helpMessage': 'Availability zone to use',
'default': True
},
{
'name': 'key',
'type': 'str',
'required': False,
'validation': '/^$|\s+/',
'helpMessage': 'Must be a valid S3 object key!',
},
{
'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):
super(S3DestinationPlugin, self).__init__(*args, **kwargs)
def upload(self, name, body, private_key, cert_chain, options, **kwargs):
account_number = self.get_option('accountNumber', 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:
s3.write_to_s3(account_number, bucket, key, private_key, encrypt=encrypt)
s3.write_to_s3(account_number, bucket, ca_key, cert_chain, encrypt=encrypt)
s3.write_to_s3(account_number, bucket, cert_key, body, encrypt=encrypt)
else:
pem_body = key + '\n' + body + '\n' + cert_chain + '\n'
s3.write_to_s3(account_number, bucket, name, pem_body, encrypt=encrypt)

View File

@ -0,0 +1,26 @@
"""
.. module: lemur.plugins.lemur_aws.s3
:platform: Unix
:synopsis: Contains helper functions for interactive with AWS S3 Apis.
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from boto.s3.key import Key
from lemur.plugins.lemur_aws.sts import assume_service
def write_to_s3(account_number, bucket_name, key, data, encrypt=True):
"""
Use STS to write to an S3 bucket
:param account_number:
:param bucket_name:
:param data:
"""
conn = assume_service(account_number, 's3')
b = conn.get_bucket(bucket_name, validate=False) # validate=False removes need for ListObjects permission
k = Key(bucket=b, name=key)
k.set_contents_from_string(data, encrypt_key=encrypt)
k.set_canned_acl("bucket-owner-read")

View File

@ -5,13 +5,16 @@
: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 functools import wraps
import boto import boto
import boto.ec2.elb import boto.ec2.elb
import boto3
from flask import current_app from flask import current_app
def assume_service(account_number, service, region=None): def assume_service(account_number, service, region='us-east-1'):
conn = boto.connect_sts() conn = boto.connect_sts()
role = conn.assume_role('arn:aws:iam::{0}:role/{1}'.format( role = conn.assume_role('arn:aws:iam::{0}:role/{1}'.format(
@ -35,3 +38,47 @@ def assume_service(account_number, service, region=None):
aws_access_key_id=role.credentials.access_key, aws_access_key_id=role.credentials.access_key,
aws_secret_access_key=role.credentials.secret_key, aws_secret_access_key=role.credentials.secret_key,
security_token=role.credentials.session_token) 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 decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
sts = boto3.client('sts')
arn = 'arn:aws:iam::{0}:role/{1}'.format(
kwargs.pop('account_number'),
current_app.config.get('LEMUR_INSTANCE_PROFILE', 'Lemur')
)
# TODO add user specific information to RoleSessionName
role = sts.assume_role(RoleArn=arn, RoleSessionName='lemur')
if service_type == 'client':
client = boto3.client(
service,
region_name=kwargs.pop('region', 'us-east-1'),
aws_access_key_id=role['Credentials']['AccessKeyId'],
aws_secret_access_key=role['Credentials']['SecretAccessKey'],
aws_session_token=role['Credentials']['SessionToken']
)
kwargs['client'] = client
elif service_type == 'resource':
resource = boto3.resource(
service,
region_name=kwargs.pop('region', 'us-east-1'),
aws_access_key_id=role['Credentials']['AccessKeyId'],
aws_secret_access_key=role['Credentials']['SecretAccessKey'],
aws_session_token=role['Credentials']['SessionToken']
)
kwargs['resource'] = resource
return f(*args, **kwargs)
return decorated_function
return decorator

View File

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

View File

@ -0,0 +1,14 @@
import boto
from moto import mock_sts, mock_elb
@mock_sts()
@mock_elb()
def test_get_all_elbs(app):
from lemur.plugins.lemur_aws.elb import get_all_elbs
conn = boto.ec2.elb.connect_to_region('us-east-1')
elbs = get_all_elbs(account_number='123456789012', region='us-east-1')
assert not elbs
conn.create_load_balancer('example-lb', ['us-east-1a', 'us-east-1b'], [(443, 5443, 'tcp')])
elbs = get_all_elbs(account_number='123456789012', region='us-east-1')
assert elbs

View File

@ -1,8 +1,7 @@
import pytest
from moto import mock_iam, mock_sts from moto import mock_iam, mock_sts
from lemur.certificates.models import Certificate from lemur.tests.vectors import EXTERNAL_VALID_STR, PRIVATE_KEY_STR
from lemur.tests.certs import EXTERNAL_VALID_STR, PRIVATE_KEY_STR
def test_get_name_from_arn(): def test_get_name_from_arn():
@ -11,12 +10,12 @@ def test_get_name_from_arn():
assert get_name_from_arn(arn) == 'tttt2.netflixtest.net-NetflixInc-20150624-20150625' assert get_name_from_arn(arn) == 'tttt2.netflixtest.net-NetflixInc-20150624-20150625'
@pytest.mark.skipif(True, reason="this fails because moto is not currently returning what boto does")
@mock_sts() @mock_sts()
@mock_iam() @mock_iam()
def test_get_all_server_certs(app): def test_get_all_server_certs(app):
from lemur.plugins.lemur_aws.iam import upload_cert, get_all_server_certs from lemur.plugins.lemur_aws.iam import upload_cert, get_all_server_certs
cert = Certificate(EXTERNAL_VALID_STR) upload_cert('123456789012', 'testCert', EXTERNAL_VALID_STR.decode('utf-8'), PRIVATE_KEY_STR.decode('utf-8'))
upload_cert('123456789012', cert, PRIVATE_KEY_STR)
certs = get_all_server_certs('123456789012') certs = get_all_server_certs('123456789012')
assert len(certs) == 1 assert len(certs) == 1
@ -25,7 +24,6 @@ def test_get_all_server_certs(app):
@mock_iam() @mock_iam()
def test_get_cert_from_arn(app): def test_get_cert_from_arn(app):
from lemur.plugins.lemur_aws.iam import upload_cert, get_cert_from_arn from lemur.plugins.lemur_aws.iam import upload_cert, get_cert_from_arn
cert = Certificate(EXTERNAL_VALID_STR) upload_cert('123456789012', 'testCert', EXTERNAL_VALID_STR.decode('utf-8'), PRIVATE_KEY_STR.decode('utf-8'))
upload_cert('123456789012', cert, PRIVATE_KEY_STR) body, chain = get_cert_from_arn('arn:aws:iam::123456789012:server-certificate/testCert')
body, chain = get_cert_from_arn('arn:aws:iam::123456789012:server-certificate/tttt2.netflixtest.net-NetflixInc-20150624-20150625') assert body.replace('\n', '') == EXTERNAL_VALID_STR.decode('utf-8').replace('\n', '')
assert body.replace('\n', '') == EXTERNAL_VALID_STR.replace('\n', '')

View File

@ -0,0 +1,6 @@
def test_get_certificates(app):
from lemur.plugins.base import plugins
p = plugins.get('aws-s3')
assert p

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,64 @@
"""
.. module: lemur.plugins.lemur_cfssl.plugin
:platform: Unix
:synopsis: This module is responsible for communicating with the CFSSL private CA.
:copyright: (c) 2016 by Thomson Reuters
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Charles Hendrie <chad.hendrie@tr.com>
"""
import json
import requests
from flask import current_app
from lemur.plugins.bases import IssuerPlugin
from lemur.plugins import lemur_cfssl as cfssl
class CfsslIssuerPlugin(IssuerPlugin):
title = 'CFSSL'
slug = 'cfssl-issuer'
description = 'Enables the creation of certificates by CFSSL private CA'
version = cfssl.VERSION
author = 'Charles Hendrie'
author_url = 'https://github.com/netflix/lemur.git'
def __init__(self, *args, **kwargs):
self.session = requests.Session()
super(CfsslIssuerPlugin, self).__init__(*args, **kwargs)
def create_certificate(self, csr, issuer_options):
"""
Creates a CFSSL certificate.
:param csr:
:param issuer_options:
:return:
"""
current_app.logger.info("Requesting a new cfssl certificate with csr: {0}".format(csr))
url = "{0}{1}".format(current_app.config.get('CFSSL_URL'), '/api/v1/cfssl/sign')
data = {'certificate_request': csr.decode('utf_8')}
data = json.dumps(data)
response = self.session.post(url, data=data.encode(encoding='utf_8', errors='strict'))
response_json = json.loads(response.content.decode('utf_8'))
cert = response_json['result']['certificate']
return cert, current_app.config.get('CFSSL_INTERMEDIATE'),
@staticmethod
def create_authority(options):
"""
Creates an authority, this authority is then used by Lemur to allow a user
to specify which Certificate Authority they want to sign their certificate.
:param options:
:return:
"""
role = {'username': '', 'password': '', 'name': 'cfssl'}
return current_app.config.get('CFSSL_ROOT'), "", [role]

View File

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

View File

@ -0,0 +1,6 @@
def test_get_certificates(app):
from lemur.plugins.base import plugins
p = plugins.get('cfssl-issuer')
assert p

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,133 @@
"""
.. module: lemur.plugins.lemur_cryptography.plugin
:platform: Unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
import uuid
from flask import current_app
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from lemur.plugins.bases import IssuerPlugin
from lemur.plugins import lemur_cryptography as cryptography_issuer
def build_root_certificate(options):
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
subject = issuer = x509.Name([
x509.NameAttribute(x509.OID_COUNTRY_NAME, options['country']),
x509.NameAttribute(x509.OID_STATE_OR_PROVINCE_NAME, options['state']),
x509.NameAttribute(x509.OID_LOCALITY_NAME, options['location']),
x509.NameAttribute(x509.OID_ORGANIZATION_NAME, options['organization']),
x509.NameAttribute(x509.OID_ORGANIZATIONAL_UNIT_NAME, options['organizational_unit']),
x509.NameAttribute(x509.OID_COMMON_NAME, options['common_name'])
])
builder = x509.CertificateBuilder(
subject_name=subject,
issuer_name=issuer,
public_key=private_key.public_key(),
not_valid_after=options['validity_end'],
not_valid_before=options['validity_start'],
serial_number=options['first_serial']
)
builder.add_extension(x509.SubjectAlternativeName([x509.DNSName(options['common_name'])]), critical=False)
cert = builder.sign(private_key, hashes.SHA256(), default_backend())
cert_pem = cert.public_bytes(
encoding=serialization.Encoding.PEM
).decode('utf-8')
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL, # would like to use PKCS8 but AWS ELBs don't like it
encryption_algorithm=serialization.NoEncryption()
)
return cert_pem, private_key_pem
def issue_certificate(csr, options):
csr = x509.load_pem_x509_csr(csr, default_backend())
builder = x509.CertificateBuilder(
issuer_name=x509.Name([
x509.NameAttribute(
x509.OID_ORGANIZATION_NAME,
options['authority'].authority_certificate.issuer
)]
),
subject_name=csr.subject,
public_key=csr.public_key(),
not_valid_before=options['validity_start'],
not_valid_after=options['validity_end'],
extensions=csr.extensions)
# TODO figure out a better way to increment serial
builder = builder.serial_number(int(uuid.uuid4()))
private_key = serialization.load_pem_private_key(
bytes(str(options['authority'].authority_certificate.private_key).encode('utf-8')),
password=None,
backend=default_backend()
)
cert = builder.sign(private_key, hashes.SHA256(), default_backend())
return cert.public_bytes(
encoding=serialization.Encoding.PEM
).decode('utf-8')
class CryptographyIssuerPlugin(IssuerPlugin):
title = 'Cryptography'
slug = 'cryptography-issuer'
description = 'Enables the creation and signing of self-signed certificates'
version = cryptography_issuer.VERSION
author = 'Kevin Glisson'
author_url = 'https://github.com/netflix/lemur.git'
def create_certificate(self, csr, options):
"""
Creates a certificate.
:param csr:
:param options:
:return: :raise Exception:
"""
current_app.logger.debug("Issuing new cryptography certificate with options: {0}".format(options))
cert = issue_certificate(csr, options)
return cert, ""
@staticmethod
def create_authority(options):
"""
Creates an authority, this authority is then used by Lemur to allow a user
to specify which Certificate Authority they want to sign their certificate.
:param options:
:return:
"""
current_app.logger.debug("Issuing new cryptography authority with options: {0}".format(options))
cert, private_key = build_root_certificate(options)
roles = [
{'username': '', 'password': '', 'name': options['name'] + '_admin'},
{'username': '', 'password': '', 'name': options['name'] + '_operator'}
]
return cert, private_key, "", roles

View File

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

View File

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

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